Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 38 additions & 47 deletions scripts/validate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Ajv from 'ajv'
import { mapManifestPath, trialDocumentPath } from '../src/core/model/dataPaths.ts'
import {
checkUniqueIds,
CONTRIBUTORS_PATH,
ELEMENT_LIBRARY_PATH,
MAPS_INDEX_PATH,
ZONE_LIBRARY_PATH,
mapManifestPath,
trialDocumentPath,
trialsDirPath,
} from '../src/core/model/dataPaths.ts'
import {
collectContributorIssues,
collectLibraryIssues,
collectManifestIssues,
collectMapsIndexIssues,
collectTrialLogicIssues,
collectZoneLibraryIssues,
} from '../src/core/model/validation.ts'
Expand All @@ -27,22 +35,14 @@ const SCREENSHOT_MAX_BYTES = 500 * 1024
const SCREENSHOT_WARN_BYTES = 300 * 1024
const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])

const SCHEMA_IDS = {
mapsIndex: 'https://maps.outlasttrialsstats.com/schemas/maps-index.schema.json',
map: 'https://maps.outlasttrialsstats.com/schemas/map.schema.json',
trial: 'https://maps.outlasttrialsstats.com/schemas/trial.schema.json',
elements: 'https://maps.outlasttrialsstats.com/schemas/elements.schema.json',
zones: 'https://maps.outlasttrialsstats.com/schemas/zones.schema.json',
contributors: 'https://maps.outlasttrialsstats.com/schemas/contributors.schema.json',
}

const ajv = new Ajv({ allErrors: true })
ajv.addSchema(readJson('public/schemas/maps-index.schema.json'))
ajv.addSchema(readJson('public/schemas/map.schema.json'))
ajv.addSchema(readJson('public/schemas/trial.schema.json'))
ajv.addSchema(readJson('public/schemas/elements.schema.json'))
ajv.addSchema(readJson('public/schemas/zones.schema.json'))
ajv.addSchema(readJson('public/schemas/contributors.schema.json'))
// All $refs are schema-local, so plain per-file compilation is enough.
const validators = Object.fromEntries(
['maps-index', 'map', 'trial', 'elements', 'zones', 'contributors'].map((name) => [
name,
ajv.compile(readJson(`public/schemas/${name}.schema.json`)),
]),
)

const errors = []
const warnings = []
Expand All @@ -65,8 +65,7 @@ function checkScreenshot(relPath) {
}
}

function validateSchema(relPath, schemaId, data) {
const validate = ajv.getSchema(schemaId)
function validateSchema(relPath, validate, data) {
if (!validate(data)) {
for (const err of validate.errors) {
errors.push(`${relPath}${err.instancePath}: ${err.message}`)
Expand All @@ -82,28 +81,22 @@ function reportIssues(relPath, issues) {
}
}

const library = readJson('public/data/elements.json')
if (validateSchema('public/data/elements.json', SCHEMA_IDS.elements, library)) {
reportIssues('public/data/elements.json', collectLibraryIssues(library))
const libraryPath = `public/data/${ELEMENT_LIBRARY_PATH}`
const library = readJson(libraryPath)
if (validateSchema(libraryPath, validators.elements, library)) {
reportIssues(libraryPath, collectLibraryIssues(library))
}

const zones = readJson('public/data/zones.json')
if (validateSchema('public/data/zones.json', SCHEMA_IDS.zones, zones)) {
reportIssues('public/data/zones.json', collectZoneLibraryIssues(zones))
const zonesPath = `public/data/${ZONE_LIBRARY_PATH}`
const zones = readJson(zonesPath)
if (validateSchema(zonesPath, validators.zones, zones)) {
reportIssues(zonesPath, collectZoneLibraryIssues(zones))
}

const mapsIndex = readJson('public/data/maps/index.json')
validateSchema('public/data/maps/index.json', SCHEMA_IDS.mapsIndex, mapsIndex)
{
const indexIssues = []
checkUniqueIds(
indexIssues,
'maps',
'map',
mapsIndex.maps.map((entry) => entry.id),
)
reportIssues('public/data/maps/index.json', indexIssues)
}
const mapsIndexPath = `public/data/${MAPS_INDEX_PATH}`
const mapsIndex = readJson(mapsIndexPath)
validateSchema(mapsIndexPath, validators['maps-index'], mapsIndex)
reportIssues(mapsIndexPath, collectMapsIndexIssues(mapsIndex))

let validatedManifests = 0
let validatedTrials = 0
Expand All @@ -120,7 +113,7 @@ for (const entry of mapsIndex.maps) {
continue
}
const manifest = readJson(relPath)
if (!validateSchema(relPath, SCHEMA_IDS.map, manifest)) {
if (!validateSchema(relPath, validators.map, manifest)) {
continue
}
if (manifest.id !== entry.id) {
Expand All @@ -130,7 +123,7 @@ for (const entry of mapsIndex.maps) {
reportIssues(relPath, collectManifestIssues(manifest))
validatedManifests += 1

const trialsDir = dirname(`public/data/${trialDocumentPath(entry.id, 'x')}`)
const trialsDir = `public/data/${trialsDirPath(entry.id)}`
const manifestTrialIds = new Set(manifest.trials.map((trial) => trial.id))
for (const trial of manifest.trials) {
const trialPath = `public/data/${trialDocumentPath(entry.id, trial.id)}`
Expand All @@ -141,7 +134,7 @@ for (const entry of mapsIndex.maps) {
continue
}
const trialDoc = readJson(trialPath)
if (!validateSchema(trialPath, SCHEMA_IDS.trial, trialDoc)) {
if (!validateSchema(trialPath, validators.trial, trialDoc)) {
continue
}
if (trialDoc.mapId !== entry.id) {
Expand Down Expand Up @@ -173,20 +166,18 @@ for (const entry of mapsIndex.maps) {
}
}

const contributors = readJson('public/data/contributors.json')
if (validateSchema('public/data/contributors.json', SCHEMA_IDS.contributors, contributors)) {
const contributorsPath = `public/data/${CONTRIBUTORS_PATH}`
const contributors = readJson(contributorsPath)
if (validateSchema(contributorsPath, validators.contributors, contributors)) {
const knownMapIds = new Set(mapsIndex.maps.map((entry) => entry.id))
reportIssues(
'public/data/contributors.json',
collectContributorIssues(contributors, authorsByMapId, knownMapIds),
)
reportIssues(contributorsPath, collectContributorIssues(contributors, authorsByMapId, knownMapIds))
// Missing profiles do not block a PR — they show the maintainer what is still open.
const credited = new Set(contributors.contributors.map((entry) => entry.name))
for (const [mapId, authors] of authorsByMapId) {
for (const author of authors) {
if (!credited.has(author)) {
warnings.push(
`public/data/contributors.json: "${author}" (map "${mapId}") has no entry — the start page shows no profile link`,
`${contributorsPath}: "${author}" (map "${mapId}") has no entry — the start page shows no profile link`,
)
}
}
Expand Down
86 changes: 2 additions & 84 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,95 +1,13 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import { useCustomCursor } from './core/interaction/useCustomCursor'

const { cursorEnabled } = useCustomCursor()
const cursorEl = ref<HTMLElement>()
const isPressed = ref(false)

/** Offset so the fingertip of the hand image sits on the mouse position. */
const CURSOR_HOTSPOT_OFFSET_PX = 2

let rafId: number | null = null
let cursorX = 0
let cursorY = 0

// Pointer instead of mouse events: while panning, d3-zoom suppresses
// mousemove/mouseup via stopImmediatePropagation, but not pointer events.
function onPointerMove(event: PointerEvent) {
cursorX = event.clientX - CURSOR_HOTSPOT_OFFSET_PX
cursorY = event.clientY - CURSOR_HOTSPOT_OFFSET_PX
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (cursorEl.value) {
cursorEl.value.style.transform = `translate(${cursorX}px, ${cursorY}px)`
}
rafId = null
})
}
}

// Left mouse button only: on a right-click the native context menu swallows
// the pointerup, which would leave the pressed pose stuck.
function onPointerDown(event: PointerEvent) {
if (event.button === 0) {
isPressed.value = true
}
}

function releasePressed() {
isPressed.value = false
}

onMounted(() => {
// Preload the pressed variant so the first click does not flicker
new Image().src = `${import.meta.env.BASE_URL}images/cursor/cursor_pressed.webp`

window.addEventListener('pointermove', onPointerMove, { passive: true })
window.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointerup', releasePressed)
window.addEventListener('blur', releasePressed)
})

onUnmounted(() => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointerup', releasePressed)
window.removeEventListener('blur', releasePressed)
if (rafId !== null) cancelAnimationFrame(rafId)
})
import CustomCursorOverlay from './core/ui/CustomCursorOverlay.vue'
</script>

<template>
<div
v-show="cursorEnabled"
ref="cursorEl"
class="custom-cursor"
:class="{ 'custom-cursor--active': isPressed }"
/>
<CustomCursorOverlay />
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</template>

<style>
.custom-cursor {
position: fixed;
top: 0;
left: 0;
width: 48px;
height: 48px;
background-image: url('/images/cursor/cursor.webp');
background-size: contain;
background-repeat: no-repeat;
pointer-events: none;
z-index: 99999;
will-change: transform;
}

.custom-cursor--active {
background-image: url('/images/cursor/cursor_pressed.webp');
}
</style>
31 changes: 26 additions & 5 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
CONTRIBUTORS_PATH,
ELEMENT_LIBRARY_PATH,
MAPS_INDEX_PATH,
ZONE_LIBRARY_PATH,
} from './model/dataPaths'

/** Base URL of all content fetched at runtime (public/data); BASE_URL always ends with '/'. */
export const DATA_BASE_URL = `${import.meta.env.BASE_URL}data`
export const MAPS_INDEX_URL = `${DATA_BASE_URL}/maps/index.json`
export const ELEMENT_LIBRARY_URL = `${DATA_BASE_URL}/elements.json`
export const ZONE_LIBRARY_URL = `${DATA_BASE_URL}/zones.json`
export const CONTRIBUTORS_URL = `${DATA_BASE_URL}/contributors.json`
export const MAPS_INDEX_URL = `${DATA_BASE_URL}/${MAPS_INDEX_PATH}`
export const ELEMENT_LIBRARY_URL = `${DATA_BASE_URL}/${ELEMENT_LIBRARY_PATH}`
export const ZONE_LIBRARY_URL = `${DATA_BASE_URL}/${ZONE_LIBRARY_PATH}`
export const CONTRIBUTORS_URL = `${DATA_BASE_URL}/${CONTRIBUTORS_PATH}`
export const SCHEMA_BASE_URL = `${import.meta.env.BASE_URL}schemas`

/** Repo behind the join link of the contributors section. */
Expand Down Expand Up @@ -50,7 +57,10 @@ export const EDITOR_AUTOSAVE_KEY = 'outlasttrials-maps:editor-autosave'
export const EDITOR_AUTOSAVE_VERSION = 4

export const CURSOR_STORAGE_KEY = 'outlasttrials-maps:custom-cursor'
/** The custom cursor image points slightly left of its top-left corner. */
export const CURSOR_HOTSPOT_OFFSET_PX = 2
export const AUTOSAVE_DEBOUNCE_MS = 1000
export const TOAST_LIFE_MS = 5000
export const UNDO_STACK_LIMIT = 100
/** Commits with the same coalesce key within this window share one undo snapshot. */
export const UNDO_COALESCE_MS = 800
Expand Down Expand Up @@ -82,9 +92,11 @@ export const VIEW_PAN_STEP_LARGE_PX = 200
* Rotations are limited to fixed steps (R key and properties select);
* in sync with the `multipleOf` of the `rotation` field in the map schema.
*/
export const FULL_CIRCLE_DEG = 360
export const HALF_CIRCLE_DEG = 180
export const ROTATION_STEP_DEG = 45
export const ROTATION_VALUES = Array.from(
{ length: 360 / ROTATION_STEP_DEG },
{ length: FULL_CIRCLE_DEG / ROTATION_STEP_DEG },
(_, index) => index * ROTATION_STEP_DEG,
)

Expand All @@ -100,6 +112,11 @@ export const MARQUEE_MIN_DRAG_PX = 4
/** Smallest width/height a room can be resized to, in map units (one grid cell). */
export const ROOM_RESIZE_MIN_SIZE = 5

/** Open polylines (routes, inner lines) need at least a start and an end point. */
export const MIN_OPEN_PATH_POINTS = 2
/** A polygon room needs at least a triangle. */
export const MIN_POLYGON_POINTS = 3

/** Wall gaps (openings in a room outline), in map units. */
export const WALL_GAP_DEFAULT_LENGTH = 8
export const WALL_GAP_MIN_LENGTH = 1
Expand Down Expand Up @@ -138,6 +155,10 @@ export const BARRICADE_PLANK_GAP = 0.5
export const BARRICADE_HATCH_SPACING = 2
/** Spacing of the slanted bars of a crawl passage. */
export const CRAWL_BAR_SPACING = 1.6
/** Spacing of the step rungs on stairs. */
export const STAIRS_RUNG_SPACING = 3
/** Spacing of the teeth on obstacles. */
export const OBSTACLE_TOOTH_SPACING = 2.5

// Number marker (dot → leader line → diamond badge with a number)
export const MARKER_COLOR = '#aaaaaa'
Expand Down
32 changes: 14 additions & 18 deletions src/core/interaction/usePanZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ import {
ZOOM_MAX,
ZOOM_MIN,
} from '../constants'
import type { Vec2 } from '../model/types'
import type { Bounds, Vec2 } from '../model/types'
import { clamp, distance } from '../model/vec2'
import { isEditableTarget } from './eventTargets'
import type { ViewTransform } from './viewTransform'

export interface WorldBounds {
min: Vec2
max: Vec2
}

export interface PanZoomOptions {
/** Pan with the left mouse button as well — in the editor it stays reserved for the tools. */
dragPan?: boolean
Expand Down Expand Up @@ -84,7 +80,7 @@ export function usePanZoom(svgRef: Readonly<Ref<SVGSVGElement | null>>, options?
if (!rightDragStart || rightDragMoved) {
return
}
const travel = Math.hypot(event.clientX - rightDragStart[0], event.clientY - rightDragStart[1])
const travel = distance([event.clientX, event.clientY], rightDragStart)
rightDragMoved = travel > RIGHT_DRAG_PAN_THRESHOLD_PX
}
/** `rightDragMoved` has to survive this: `contextmenu` only fires after the pointer-up. */
Expand Down Expand Up @@ -180,27 +176,26 @@ export function usePanZoom(svgRef: Readonly<Ref<SVGSVGElement | null>>, options?
}
}

/** Fits the view to the given world bounds (without bounds: identity). */
function resetView(bounds?: WorldBounds): void {
function resetView(): void {
if (behavior && selection) {
selection.call(behavior.transform, zoomIdentity)
}
}

function fitBounds(bounds: Bounds): void {
const svg = svgRef.value
if (!svg || !behavior || !selection) {
return
}
if (!bounds) {
selection.call(behavior.transform, zoomIdentity)
return
}
const rect = svg.getBoundingClientRect()
const width = bounds.max[0] - bounds.min[0]
const height = bounds.max[1] - bounds.min[1]
if (rect.width === 0 || rect.height === 0 || width <= 0 || height <= 0) {
return
}
const scale = Math.min(
Math.max(
Math.min(rect.width / width, rect.height / height) * FIT_VIEW_PADDING_RATIO,
ZOOM_MIN,
),
const scale = clamp(
Math.min(rect.width / width, rect.height / height) * FIT_VIEW_PADDING_RATIO,
ZOOM_MIN,
ZOOM_MAX,
)
const center: Vec2 = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2]
Expand All @@ -218,6 +213,7 @@ export function usePanZoom(svgRef: Readonly<Ref<SVGSVGElement | null>>, options?
isSpacePanning: readonly(isSpacePanning),
isPanning: readonly(isPanning),
resetView,
fitBounds,
zoomBy,
panBy,
}
Expand Down
Loading
Loading