From 3044db2e87ab13821d754402d49c992a2d08b321 Mon Sep 17 00:00:00 2001 From: SuprexDE <46200229+SuprexDE@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:25:41 +0200 Subject: [PATCH] chore: Cleanup Code --- scripts/validate-data.mjs | 85 +++---- src/App.vue | 86 +------ src/core/constants.ts | 31 ++- src/core/interaction/usePanZoom.ts | 32 ++- src/core/interaction/viewTransform.ts | 6 - src/core/model/contributorIndex.ts | 2 +- src/core/model/dataPaths.ts | 16 +- src/core/model/dataSource.ts | 27 ++- src/core/model/elementIndex.ts | 5 +- src/core/model/roomCameras.ts | 9 + src/core/model/roomPath.ts | 158 ++++++------ src/core/model/types.ts | 6 + src/core/model/validation.ts | 174 ++++++++----- src/core/model/vec2.ts | 11 + src/core/model/wallGaps.ts | 32 ++- src/core/render/CalloutMarker.vue | 4 +- src/core/render/PlacementIcon.vue | 2 +- src/core/render/StructuralMarker.vue | 45 ++-- src/core/render/structuralShapes.ts | 24 +- src/core/render/useIconFallback.ts | 6 +- src/core/ui/CustomCursorOverlay.vue | 87 +++++++ src/editor/EditorCanvas.vue | 45 ++-- src/editor/EditorShell.vue | 63 +---- src/editor/EditorToolbar.vue | 12 +- src/editor/ToolOverlayLayer.vue | 14 +- src/editor/editor-forms.css | 62 +++++ src/editor/panels/ElementCrudDialogs.vue | 40 +++ src/editor/panels/ElementDialog.vue | 68 ++---- src/editor/panels/ElementGroupList.vue | 47 ++++ src/editor/panels/ElementsManager.vue | 64 +---- src/editor/panels/ExportDialog.vue | 7 +- src/editor/panels/FiltersEditor.vue | 47 +--- src/editor/panels/FloorsEditor.vue | 13 +- src/editor/panels/ImportDialog.vue | 9 +- src/editor/panels/MapMetaForm.vue | 18 +- src/editor/panels/PaletteElementButton.vue | 2 +- src/editor/panels/PalettePanel.vue | 70 ++---- src/editor/panels/PlacementMarkerEditor.vue | 49 +--- src/editor/panels/PlacementProperties.vue | 40 +-- src/editor/panels/PropsSchemaForm.vue | 11 - src/editor/panels/RoomImagesEditor.vue | 25 +- src/editor/panels/RoomInnerLinesEditor.vue | 11 +- src/editor/panels/RoomProperties.vue | 55 +---- src/editor/panels/RoomWallGapsEditor.vue | 35 +-- src/editor/panels/RouteProperties.vue | 38 +-- src/editor/panels/ShortcutHelpDialog.vue | 13 +- src/editor/panels/StartDialog.vue | 131 +--------- src/editor/panels/TrialStep.vue | 138 +++++++++++ src/editor/panels/TrialsEditor.vue | 13 +- src/editor/panels/UsageDeleteDialog.vue | 10 - src/editor/panels/useIconField.ts | 14 ++ src/editor/store/documentIO.ts | 2 +- src/editor/store/editorStore.ts | 40 ++- src/editor/store/libraryStore.ts | 4 + src/editor/store/useExportArtifacts.ts | 34 +-- src/editor/store/useValidationIssues.ts | 45 ++++ src/editor/tools/toolTypes.ts | 8 +- src/editor/tools/useCameraPickMode.ts | 8 +- src/editor/tools/useClipboard.ts | 4 +- src/editor/tools/usePlacementTool.ts | 3 +- src/editor/tools/useRoomResize.ts | 50 ++-- src/editor/tools/useRoomTool.ts | 204 +++------------- src/editor/tools/useRoomVertexMode.ts | 168 +++++++++++++ src/editor/tools/useRouteEditMode.ts | 64 +++-- src/editor/tools/useRouteTool.ts | 5 +- src/editor/tools/useSelectTool.ts | 29 ++- src/editor/tools/useWallGapMode.ts | 30 +-- src/theme/tokens.css | 31 ++- src/viewer/ContributorsSection.vue | 4 - src/viewer/CursorSeverAnimation.vue | 226 +++++++++++++++++ src/viewer/CursorToggle.vue | 255 ++------------------ src/viewer/HeroBanner.vue | 4 +- src/viewer/MapCard.vue | 8 +- src/viewer/MapOverview.vue | 50 +--- src/viewer/MapViewer.vue | 36 +-- src/viewer/PageBackdrop.vue | 45 ++++ src/viewer/ViewerCanvas.vue | 22 +- src/viewer/panels/MapCredits.vue | 11 +- src/viewer/panels/ViewerPanel.vue | 28 +-- src/viewer/store/viewerStore.ts | 46 ++-- src/viewer/useViewerUrlSync.ts | 31 +-- 81 files changed, 1683 insertions(+), 1824 deletions(-) create mode 100644 src/core/model/roomCameras.ts create mode 100644 src/core/model/vec2.ts create mode 100644 src/core/ui/CustomCursorOverlay.vue create mode 100644 src/editor/editor-forms.css create mode 100644 src/editor/panels/ElementCrudDialogs.vue create mode 100644 src/editor/panels/ElementGroupList.vue create mode 100644 src/editor/panels/TrialStep.vue create mode 100644 src/editor/panels/useIconField.ts create mode 100644 src/editor/store/useValidationIssues.ts create mode 100644 src/editor/tools/useRoomVertexMode.ts create mode 100644 src/viewer/CursorSeverAnimation.vue create mode 100644 src/viewer/PageBackdrop.vue diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index f06a487..1ce4032 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -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' @@ -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 = [] @@ -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}`) @@ -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 @@ -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) { @@ -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)}` @@ -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) { @@ -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`, ) } } diff --git a/src/App.vue b/src/App.vue index d478211..31bf559 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,95 +1,13 @@ - - diff --git a/src/core/constants.ts b/src/core/constants.ts index f895ce1..5632261 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -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. */ @@ -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 @@ -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, ) @@ -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 @@ -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' diff --git a/src/core/interaction/usePanZoom.ts b/src/core/interaction/usePanZoom.ts index d7c19d2..2501d1d 100644 --- a/src/core/interaction/usePanZoom.ts +++ b/src/core/interaction/usePanZoom.ts @@ -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 @@ -84,7 +80,7 @@ export function usePanZoom(svgRef: Readonly>, 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. */ @@ -180,27 +176,26 @@ export function usePanZoom(svgRef: Readonly>, 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] @@ -218,6 +213,7 @@ export function usePanZoom(svgRef: Readonly>, options? isSpacePanning: readonly(isSpacePanning), isPanning: readonly(isPanning), resetView, + fitBounds, zoomBy, panBy, } diff --git a/src/core/interaction/viewTransform.ts b/src/core/interaction/viewTransform.ts index 8f60fe6..faa7c44 100644 --- a/src/core/interaction/viewTransform.ts +++ b/src/core/interaction/viewTransform.ts @@ -7,12 +7,6 @@ export interface ViewTransform { k: number } -export const IDENTITY_TRANSFORM: ViewTransform = { x: 0, y: 0, k: 1 } - export function screenToWorld(point: Vec2, transform: ViewTransform): Vec2 { return [(point[0] - transform.x) / transform.k, (point[1] - transform.y) / transform.k] } - -export function worldToScreen(point: Vec2, transform: ViewTransform): Vec2 { - return [point[0] * transform.k + transform.x, point[1] * transform.k + transform.y] -} diff --git a/src/core/model/contributorIndex.ts b/src/core/model/contributorIndex.ts index e9d6543..a11d5b3 100644 --- a/src/core/model/contributorIndex.ts +++ b/src/core/model/contributorIndex.ts @@ -1,6 +1,6 @@ import type { Contributor, Contributors } from './types' -export type ContributorIndex = ReadonlyMap +type ContributorIndex = ReadonlyMap /** Lookup table author name → contributor (link to `meta.authors`). */ export function buildContributorIndex(contributors: Contributors | null): ContributorIndex { diff --git a/src/core/model/dataPaths.ts b/src/core/model/dataPaths.ts index 4f5a6d7..55ca2a3 100644 --- a/src/core/model/dataPaths.ts +++ b/src/core/model/dataPaths.ts @@ -1,13 +1,23 @@ /** - * Layout of a map's files relative to the data root — the single source for + * Layout of the data files relative to the data root — the single source for * fetch URLs, export repo paths and the CI script. Import-free on purpose: * scripts/validate-data.mjs loads it via Node type stripping. */ +export const MAPS_INDEX_PATH = 'maps/index.json' +export const ELEMENT_LIBRARY_PATH = 'elements.json' +export const ZONE_LIBRARY_PATH = 'zones.json' +export const CONTRIBUTORS_PATH = 'contributors.json' +export const MAP_MANIFEST_FILENAME = 'map.json' + export function mapManifestPath(mapId: string): string { - return `maps/${mapId}/map.json` + return `maps/${mapId}/${MAP_MANIFEST_FILENAME}` +} + +export function trialsDirPath(mapId: string): string { + return `maps/${mapId}/trials` } export function trialDocumentPath(mapId: string, trialId: string): string { - return `maps/${mapId}/trials/${trialId}.json` + return `${trialsDirPath(mapId)}/${trialId}.json` } diff --git a/src/core/model/dataSource.ts b/src/core/model/dataSource.ts index 1c78011..b0098d9 100644 --- a/src/core/model/dataSource.ts +++ b/src/core/model/dataSource.ts @@ -25,21 +25,22 @@ export async function fetchJson(url: string): Promise { return (await response.json()) as T } -export function loadMapsIndex(): Promise { - return fetchJson(MAPS_INDEX_URL) -} - -export function loadElementLibrary(): Promise { - return fetchJson(ELEMENT_LIBRARY_URL) -} - -export function loadZoneLibrary(): Promise { - return fetchJson(ZONE_LIBRARY_URL) +/** Shares one fetch per session for static files; a failure clears the cache so retries work. */ +function once(load: () => Promise): () => Promise { + let promise: Promise | null = null + return () => { + promise ??= load().catch((error: unknown) => { + promise = null + throw error + }) + return promise + } } -export function loadContributors(): Promise { - return fetchJson(CONTRIBUTORS_URL) -} +export const loadMapsIndex = once(() => fetchJson(MAPS_INDEX_URL)) +export const loadElementLibrary = once(() => fetchJson(ELEMENT_LIBRARY_URL)) +export const loadZoneLibrary = once(() => fetchJson(ZONE_LIBRARY_URL)) +export const loadContributors = once(() => fetchJson(CONTRIBUTORS_URL)) export function loadMapManifest(mapId: string): Promise { return fetchJson(`${DATA_BASE_URL}/${mapManifestPath(mapId)}`) diff --git a/src/core/model/elementIndex.ts b/src/core/model/elementIndex.ts index 7e16f70..f79f366 100644 --- a/src/core/model/elementIndex.ts +++ b/src/core/model/elementIndex.ts @@ -1,11 +1,12 @@ import type { ElementDefinition, ElementLibrary, Zone, ZoneLibrary } from './types' -export type ElementIndex = Map +export type ElementIndex = ReadonlyMap +export type ZoneIndex = ReadonlyMap export function buildElementIndex(library: ElementLibrary | null): ElementIndex { return new Map((library?.elements ?? []).map((def) => [def.id, def])) } -export function buildZoneIndex(library: ZoneLibrary | null): ReadonlyMap { +export function buildZoneIndex(library: ZoneLibrary | null): ZoneIndex { return new Map((library?.zones ?? []).map((zone) => [zone.id, zone])) } diff --git a/src/core/model/roomCameras.ts b/src/core/model/roomCameras.ts new file mode 100644 index 0000000..74ae158 --- /dev/null +++ b/src/core/model/roomCameras.ts @@ -0,0 +1,9 @@ +import type { CameraInfo, Room } from './types' + +/** Cameras of the room's images, shown only while its floor is the active one. */ +export function roomCameras(room: Room | null | undefined, activeFloor: number): CameraInfo[] { + if (!room || room.floor !== activeFloor) { + return [] + } + return (room.info?.images ?? []).flatMap((image) => (image.camera ? [image.camera] : [])) +} diff --git a/src/core/model/roomPath.ts b/src/core/model/roomPath.ts index a16d991..c07cb66 100644 --- a/src/core/model/roomPath.ts +++ b/src/core/model/roomPath.ts @@ -1,4 +1,4 @@ -import type { Room, RoomShape, Vec2 } from './types' +import type { Bounds, Room, RoomShape, Vec2 } from './types' /** * Minimal path parser for room outlines — paths are only parsed for editing in @@ -11,7 +11,18 @@ const COMMAND_RE = /^[A-Za-z]$/ const TOKEN_RE = /[A-Za-z]|-?(?:\d*\.\d+|\d+)(?:[eE][+-]?\d+)?/g const CHARSET_RE = /^[MmLlHhVvZz0-9eE+,.\s-]*$/ -const fmt = (value: number): string => String(Math.round(value * 1000) / 1000) +/** Path numbers are rounded to 3 decimals so emitted `d` strings stay compact. */ +export const formatPathNumber = (value: number): string => + String(Math.round(value * 1000) / 1000) + +const LINETO = { + L: ([x, y]: number[], current: Vec2, absolute: boolean): Vec2 => + absolute ? [x, y] : [current[0] + x, current[1] + y], + H: ([x]: number[], current: Vec2, absolute: boolean): Vec2 => + absolute ? [x, current[1]] : [current[0] + x, current[1]], + V: ([y]: number[], current: Vec2, absolute: boolean): Vec2 => + absolute ? [current[0], y] : [current[0], current[1] + y], +} function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | null { if (!CHARSET_RE.test(path)) { @@ -29,13 +40,20 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu let index = 0 const isNumberNext = (): boolean => index < tokens.length && !COMMAND_RE.test(tokens[index]) - const nextNumber = (): number | null => { - if (!isNumberNext()) { - return null + const readNumbers = (count: number): number[] | null => { + const values: number[] = [] + while (values.length < count) { + if (!isNumberNext()) { + return null + } + const value = Number(tokens[index]) + index += 1 + if (!Number.isFinite(value)) { + return null + } + values.push(value) } - const value = Number(tokens[index]) - index += 1 - return Number.isFinite(value) ? value : null + return values } const push = (point: Vec2): void => { current = point @@ -52,69 +70,38 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu return null } index += 1 - switch (command) { - case 'M': - case 'm': { - if (started) { + const upper = command.toUpperCase() + const absolute = command === upper + if (upper === 'M') { + if (started) { + return null + } + const args = readNumbers(2) + if (!args) { + return null + } + current = absolute ? [args[0], args[1]] : [current[0] + args[0], current[1] + args[1]] + points[0] = current + // Further coordinate pairs after M/m are implicit linetos. + while (isNumberNext()) { + const lineArgs = readNumbers(2) + if (!lineArgs) { return null } - const x = nextNumber() - const y = nextNumber() - if (x === null || y === null) { + push(LINETO.L(lineArgs, current, absolute)) + } + } else if (upper === 'L' || upper === 'H' || upper === 'V') { + do { + const args = readNumbers(upper === 'L' ? 2 : 1) + if (!args) { return null } - current = command === 'M' ? [x, y] : [current[0] + x, current[1] + y] - points[0] = current - while (isNumberNext()) { - const lx = nextNumber() - const ly = nextNumber() - if (lx === null || ly === null) { - return null - } - push(command === 'M' ? [lx, ly] : [current[0] + lx, current[1] + ly]) - } - break - } - case 'L': - case 'l': { - do { - const x = nextNumber() - const y = nextNumber() - if (x === null || y === null) { - return null - } - push(command === 'L' ? [x, y] : [current[0] + x, current[1] + y]) - } while (isNumberNext()) - break - } - case 'H': - case 'h': { - do { - const x = nextNumber() - if (x === null) { - return null - } - push(command === 'H' ? [x, current[1]] : [current[0] + x, current[1]]) - } while (isNumberNext()) - break - } - case 'V': - case 'v': { - do { - const y = nextNumber() - if (y === null) { - return null - } - push(command === 'V' ? [current[0], y] : [current[0], current[1] + y]) - } while (isNumberNext()) - break - } - case 'Z': - case 'z': - closed = true - break - default: - return null + push(LINETO[upper](args, current, absolute)) + } while (isNumberNext()) + } else if (upper === 'Z') { + closed = true + } else { + return null } } @@ -122,7 +109,7 @@ function parsePathPoints(path: string): { points: Vec2[]; closed: boolean } | nu } /** Points in shape-local coordinates (relative to the origin, implicit "M 0 0"). */ -export function parseRoomPath(path: string): Vec2[] | null { +function parseRoomPath(path: string): Vec2[] | null { const parsed = parsePathPoints(path) if (!parsed || parsed.points.length < 3) { return null @@ -145,7 +132,8 @@ export function parseOpenPath(path: string): Vec2[] | null { return parsed.points } -function appendRelativeSegments(parts: string[], points: Vec2[]): void { +function relativeSegments(points: Vec2[]): string[] { + const parts: string[] = [] for (let i = 1; i < points.length; i += 1) { const dx = points[i][0] - points[i - 1][0] const dy = points[i][1] - points[i - 1][1] @@ -153,24 +141,23 @@ function appendRelativeSegments(parts: string[], points: Vec2[]): void { continue } if (dy === 0) { - parts.push(`h${fmt(dx)}`) + parts.push(`h${formatPathNumber(dx)}`) } else if (dx === 0) { - parts.push(`v${fmt(dy)}`) + parts.push(`v${formatPathNumber(dy)}`) } else { - parts.push(`l${fmt(dx)},${fmt(dy)}`) + parts.push(`l${formatPathNumber(dx)},${formatPathNumber(dy)}`) } } + return parts } export function pointsToRelativePath(points: Vec2[]): string { - const parts: string[] = [] const start = points[0] - if (start[0] !== 0 || start[1] !== 0) { - parts.push(`M${fmt(start[0])},${fmt(start[1])}`) - } - appendRelativeSegments(parts, points) - parts.push('z') - return parts.join(' ') + const prefix = + start[0] !== 0 || start[1] !== 0 + ? [`M${formatPathNumber(start[0])},${formatPathNumber(start[1])}`] + : [] + return [...prefix, ...relativeSegments(points), 'z'].join(' ') } /** Open path (inner lines, routes): explicit M to the first point, then h/v/l. */ @@ -178,9 +165,10 @@ export function pointsToOpenPath(points: Vec2[]): string { if (points.length === 0) { return '' } - const parts: string[] = [`M${fmt(points[0][0])},${fmt(points[0][1])}`] - appendRelativeSegments(parts, points) - return parts.join(' ') + return [ + `M${formatPathNumber(points[0][0])},${formatPathNumber(points[0][1])}`, + ...relativeSegments(points), + ].join(' ') } const ABSOLUTE_START_RE = /^\s*M\s*(-?(?:\d*\.\d+|\d+))[\s,]+(-?(?:\d*\.\d+|\d+))([\s\S]*)$/ @@ -208,7 +196,7 @@ export function translateAbsolutePathStart(path: string, delta: Vec2): string | if (!parts) { return null } - return `M${fmt(parts.start[0] + delta[0])},${fmt(parts.start[1] + delta[1])}${parts.rest}` + return `M${formatPathNumber(parts.start[0] + delta[0])},${formatPathNumber(parts.start[1] + delta[1])}${parts.rest}` } /** Vertices of a room shape in local coordinates; `rect` becomes 4 points. */ @@ -225,7 +213,7 @@ export function shapeToPoints(shape: RoomShape): Vec2[] | null { return parseRoomPath(shape.path) } -export function pointsBounds(points: Vec2[]): { min: Vec2; max: Vec2 } { +export function pointsBounds(points: Vec2[]): Bounds { const min: Vec2 = [Infinity, Infinity] const max: Vec2 = [-Infinity, -Infinity] for (const [x, y] of points) { @@ -243,7 +231,7 @@ export function roomWorldPoints(room: Room): Vec2[] { return points.map(([x, y]): Vec2 => [x + room.shape.origin[0], y + room.shape.origin[1]]) } -export function roomsBounds(rooms: Room[]): { min: Vec2; max: Vec2 } | null { +export function roomsBounds(rooms: Room[]): Bounds | null { const worldPoints = rooms.flatMap(roomWorldPoints) return worldPoints.length > 0 ? pointsBounds(worldPoints) : null } diff --git a/src/core/model/types.ts b/src/core/model/types.ts index 26289cd..ae4076e 100644 --- a/src/core/model/types.ts +++ b/src/core/model/types.ts @@ -6,6 +6,12 @@ /** Point or size in map units, [x, y] or [w, h]. */ export type Vec2 = [number, number] +/** Axis-aligned bounding box in world or shape-local coordinates. */ +export interface Bounds { + min: Vec2 + max: Vec2 +} + // --------------------------------------------------------------------------- // Map registry — public/data/maps/index.json // --------------------------------------------------------------------------- diff --git a/src/core/model/validation.ts b/src/core/model/validation.ts index df2ef1a..c06a67a 100644 --- a/src/core/model/validation.ts +++ b/src/core/model/validation.ts @@ -3,8 +3,11 @@ import { shapeToPoints } from './roomPath.ts' import type { Contributors, + ElementDefinition, ElementLibrary, MapManifest, + MapsIndex, + Placement, Room, TrialDocument, ZoneLibrary, @@ -23,7 +26,7 @@ export interface ValidationIssue { message: string } -export function checkUniqueIds( +function checkUniqueIds( issues: ValidationIssue[], path: string, label: string, @@ -75,6 +78,17 @@ export function collectZoneLibraryIssues(zones: ZoneLibrary): ValidationIssue[] return issues } +export function collectMapsIndexIssues(mapsIndex: MapsIndex): ValidationIssue[] { + const issues: ValidationIssue[] = [] + checkUniqueIds( + issues, + 'maps', + 'map', + mapsIndex.maps.map((entry) => entry.id), + ) + return issues +} + /** * Checks of the contributor list (contributors.json). The link runs through the * name, so every entry must appear in `meta.authors` of the map it names. @@ -163,88 +177,114 @@ export function collectManifestIssues(manifest: MapManifest): ValidationIssue[] return issues } -export function collectTrialLogicIssues( - trial: TrialDocument, - library: ElementLibrary | null, - zones: ZoneLibrary | null, -): ValidationIssue[] { - const issues: ValidationIssue[] = [] - - const floorIndexes = new Set(trial.floors.map((floor) => floor.index)) - const zoneIds = new Set((zones?.zones ?? []).map((zone) => zone.id)) - const categoryIds = new Set((library?.categories ?? []).map((category) => category.id)) - const elementsById = new Map((library?.elements ?? []).map((element) => [element.id, element])) - const roomsById = new Map(trial.rooms.map((room) => [room.id, room])) - - checkUniqueIds(issues, 'floors', 'floor', trial.floors.map((floor) => floor.index)) - checkUniqueIds(issues, 'filters', 'filter', trial.filters.map((filter) => filter.id)) - checkUniqueIds(issues, 'rooms', 'room', trial.rooms.map((room) => room.id)) - checkUniqueIds(issues, 'placements', 'placement', trial.placements.map((p) => p.id)) - checkUniqueIds(issues, 'routes', 'route', trial.routes.map((route) => route.id)) +/** Lookup sets shared by the per-entity collectors of `collectTrialLogicIssues`. */ +interface TrialContext { + floorIndexes: ReadonlySet + zoneIds: ReadonlySet + categoryIds: ReadonlySet + elementsById: ReadonlyMap + roomsById: ReadonlyMap +} - trial.rooms.forEach((room, index) => { - if (!floorIndexes.has(room.floor)) { - issues.push({ path: `rooms[${index}].floor`, message: `unknown floor ${room.floor}` }) - } - if (!zoneIds.has(room.zone)) { - issues.push({ path: `rooms[${index}].zone`, message: `unknown zone "${room.zone}"` }) - } - issues.push(...collectWallGapIssues(room, `rooms[${index}]`)) - }) +function collectRoomIssues(room: Room, index: number, context: TrialContext): ValidationIssue[] { + const issues: ValidationIssue[] = [] + if (!context.floorIndexes.has(room.floor)) { + issues.push({ path: `rooms[${index}].floor`, message: `unknown floor ${room.floor}` }) + } + if (!context.zoneIds.has(room.zone)) { + issues.push({ path: `rooms[${index}].zone`, message: `unknown zone "${room.zone}"` }) + } + issues.push(...collectWallGapIssues(room, `rooms[${index}]`)) + return issues +} - trial.placements.forEach((placement, index) => { - const element = elementsById.get(placement.element) - if (!element) { +function collectPlacementIssues( + placement: Placement, + index: number, + context: TrialContext, +): ValidationIssue[] { + const issues: ValidationIssue[] = [] + const element = context.elementsById.get(placement.element) + if (!element) { + issues.push({ + path: `placements[${index}].element`, + message: `unknown element "${placement.element}"`, + }) + } + if (placement.size !== undefined) { + if (element && !element.render) { issues.push({ - path: `placements[${index}].element`, - message: `unknown element "${placement.element}"`, + path: `placements[${index}].size`, + message: `"size" is only allowed on structural elements (element "${placement.element}" has none)`, }) } - if (placement.size !== undefined) { - if (element && !element.render) { - issues.push({ - path: `placements[${index}].size`, - message: `"size" is only allowed on structural elements (element "${placement.element}" has none)`, - }) - } - if (placement.size.some((value) => value <= 0)) { - issues.push({ - path: `placements[${index}].size`, - message: 'size values must be greater than 0', - }) - } - } - if (!floorIndexes.has(placement.floor)) { + if (placement.size.some((value) => value <= 0)) { issues.push({ - path: `placements[${index}].floor`, - message: `unknown floor ${placement.floor}`, + path: `placements[${index}].size`, + message: 'size values must be greater than 0', }) } - if (placement.roomId !== undefined) { - const room = roomsById.get(placement.roomId) - if (!room) { - issues.push({ - path: `placements[${index}].roomId`, - message: `unknown room "${placement.roomId}"`, - }) - } else if (room.floor !== placement.floor) { - issues.push({ - path: `placements[${index}].roomId`, - message: `placement is on floor ${placement.floor} but room "${room.id}" is on floor ${room.floor}`, - }) - } + } + if (!context.floorIndexes.has(placement.floor)) { + issues.push({ + path: `placements[${index}].floor`, + message: `unknown floor ${placement.floor}`, + }) + } + if (placement.roomId !== undefined) { + const room = context.roomsById.get(placement.roomId) + if (!room) { + issues.push({ + path: `placements[${index}].roomId`, + message: `unknown room "${placement.roomId}"`, + }) + } else if (room.floor !== placement.floor) { + issues.push({ + path: `placements[${index}].roomId`, + message: `placement is on floor ${placement.floor} but room "${room.id}" is on floor ${room.floor}`, + }) } - }) + } + return issues +} + +export function collectTrialLogicIssues( + trial: TrialDocument, + library: ElementLibrary | null, + zones: ZoneLibrary | null, +): ValidationIssue[] { + const issues: ValidationIssue[] = [] + const context: TrialContext = { + floorIndexes: new Set(trial.floors.map((floor) => floor.index)), + zoneIds: new Set((zones?.zones ?? []).map((zone) => zone.id)), + categoryIds: new Set((library?.categories ?? []).map((category) => category.id)), + elementsById: new Map((library?.elements ?? []).map((element) => [element.id, element])), + roomsById: new Map(trial.rooms.map((room) => [room.id, room])), + } + const uniqueIdChecks: Array<[string, string, Array]> = [ + ['floors', 'floor', trial.floors.map((floor) => floor.index)], + ['filters', 'filter', trial.filters.map((filter) => filter.id)], + ['rooms', 'room', trial.rooms.map((room) => room.id)], + ['placements', 'placement', trial.placements.map((placement) => placement.id)], + ['routes', 'route', trial.routes.map((route) => route.id)], + ] + for (const [path, label, ids] of uniqueIdChecks) { + checkUniqueIds(issues, path, label, ids) + } + + trial.rooms.forEach((room, index) => issues.push(...collectRoomIssues(room, index, context))) + trial.placements.forEach((placement, index) => + issues.push(...collectPlacementIssues(placement, index, context)), + ) trial.routes.forEach((route, index) => { - if (!floorIndexes.has(route.floor)) { + if (!context.floorIndexes.has(route.floor)) { issues.push({ path: `routes[${index}].floor`, message: `unknown floor ${route.floor}` }) } }) - trial.filters.forEach((filter, filterIndex) => { filter.categories.forEach((category) => { - if (!categoryIds.has(category)) { + if (!context.categoryIds.has(category)) { issues.push({ path: `filters[${filterIndex}].categories`, message: `unknown category "${category}"`, diff --git a/src/core/model/vec2.ts b/src/core/model/vec2.ts new file mode 100644 index 0000000..fa99e79 --- /dev/null +++ b/src/core/model/vec2.ts @@ -0,0 +1,11 @@ +import type { Vec2 } from './types' + +export const distance = (a: Vec2, b: Vec2): number => Math.hypot(b[0] - a[0], b[1] - a[1]) + +export const midpoint = (a: Vec2, b: Vec2): Vec2 => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] + +export const withinRadius = (a: Vec2, b: Vec2, radius: number): boolean => + distance(a, b) <= radius + +export const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max) diff --git a/src/core/model/wallGaps.ts b/src/core/model/wallGaps.ts index f648874..14a126d 100644 --- a/src/core/model/wallGaps.ts +++ b/src/core/model/wallGaps.ts @@ -1,14 +1,14 @@ -import type { Vec2, WallGap } from './types' +import type { Room, Vec2, WallGap } from './types' +import { formatPathNumber } from './roomPath.ts' +import { clamp, distance } from './vec2.ts' /** * Wall gap geometry: a gap is anchored to one edge of the closed room outline, - * measured from that edge's start point. Import-free on purpose (only types): + * measured from that edge's start point. Imports use explicit .ts specifiers: * scripts/validate-data.mjs reaches this module through validation.ts via Node * type stripping. */ -const fmt = (value: number): string => String(Math.round(value * 1000) / 1000) - const samePoint = (a: Vec2, b: Vec2): boolean => a[0] === b[0] && a[1] === b[1] /** Edges of the closed outline, including the closing edge back to point 0. */ @@ -17,7 +17,7 @@ export function edgeSegments(points: Vec2[]): Array<[Vec2, Vec2]> { } export function edgeLength([from, to]: [Vec2, Vec2]): number { - return Math.hypot(to[0] - from[0], to[1] - from[1]) + return distance(from, to) } export function pointOnEdge([from, to]: [Vec2, Vec2], distance: number): Vec2 { @@ -38,12 +38,11 @@ export function projectOnEdge([from, to]: [Vec2, Vec2], point: Vec2): number { return 0 } const ratio = ((point[0] - from[0]) * dx + (point[1] - from[1]) * dy) / lengthSq - return Math.min(Math.max(ratio, 0), 1) * Math.sqrt(lengthSq) + return clamp(ratio, 0, 1) * Math.sqrt(lengthSq) } export function distanceToEdge(edge: [Vec2, Vec2], point: Vec2): number { - const projected = pointOnEdge(edge, projectOnEdge(edge, point)) - return Math.hypot(point[0] - projected[0], point[1] - projected[1]) + return distance(point, pointOnEdge(edge, projectOnEdge(edge, point))) } /** Start and end point of a gap in shape-local coordinates. */ @@ -86,8 +85,10 @@ function mergedIntervals(gaps: WallGap[], edgeIndex: number, length: number): In function runToPath(run: Vec2[], closed: boolean): string { const [first, ...rest] = run - const line = rest.map((point) => `L${fmt(point[0])},${fmt(point[1])}`).join('') - return `M${fmt(first[0])},${fmt(first[1])}${line}${closed ? ' z' : ''}` + const line = rest + .map((point) => `L${formatPathNumber(point[0])},${formatPathNumber(point[1])}`) + .join('') + return `M${formatPathNumber(first[0])},${formatPathNumber(first[1])}${line}${closed ? ' z' : ''}` } /** @@ -141,6 +142,15 @@ export function wallRunsPath(points: Vec2[], gaps: WallGap[]): string { return runs.map((entry) => runToPath(entry, false)).join(' ') } +/** Writes the gap list back to the room; an empty list removes the property. */ +export function setWallGaps(room: Room, gaps: WallGap[]): void { + if (gaps.length === 0) { + delete room.wallGaps + } else { + room.wallGaps = gaps + } +} + /** Drops gaps on edges that no longer exist and clamps them to their edge length. */ export function clampWallGaps(points: Vec2[], gaps: WallGap[], minLength: number): WallGap[] { const edges = edgeSegments(points) @@ -150,7 +160,7 @@ export function clampWallGaps(points: Vec2[], gaps: WallGap[], minLength: number } const edge = edges[gap.edge] const length = edgeLength(edge) - const start = Math.min(Math.max(gap.start, 0), length) + const start = clamp(gap.start, 0, length) const gapLength = Math.min(gap.length, length - start) return gapLength >= minLength ? [{ edge: gap.edge, start, length: gapLength }] : [] }) diff --git a/src/core/render/CalloutMarker.vue b/src/core/render/CalloutMarker.vue index 44c81e9..a8add96 100644 --- a/src/core/render/CalloutMarker.vue +++ b/src/core/render/CalloutMarker.vue @@ -34,7 +34,7 @@ const label = computed(() => ('label' in props.marker ? props.marker.label : nul const iconUrl = computed(() => 'icon' in props.marker ? elementIconUrl(props.marker.icon) : undefined, ) -const { showIcon, onIconError } = useIconFallback(() => iconUrl.value) +const { showIcon, onIconError } = useIconFallback(iconUrl) /** Diamond centered on the origin, `radius` = half diagonal. */ function diamondPath(radius: number): string { @@ -61,7 +61,7 @@ const innerDiamond = diamondPath(MARKER_BADGE_INNER_RADIUS) elementIconUrl(props.element?.icon)) -const { showIcon, onIconError } = useIconFallback(() => iconUrl.value) +const { showIcon, onIconError } = useIconFallback(iconUrl) const size = computed(() => props.element?.size ?? ICON_DEFAULT_SIZE) diff --git a/src/core/render/StructuralMarker.vue b/src/core/render/StructuralMarker.vue index a612de1..c3b2c23 100644 --- a/src/core/render/StructuralMarker.vue +++ b/src/core/render/StructuralMarker.vue @@ -34,23 +34,24 @@ const props = defineProps<{ const iconUrl = computed(() => elementIconUrl(props.element.icon)) -const { showIcon, onIconError } = useIconFallback(() => iconUrl.value) +const { showIcon, onIconError } = useIconFallback(iconUrl) -const render = computed(() => props.element.render) -const kind = computed(() => render.value?.kind) +const kind = computed(() => props.element.render?.kind) const meta = computed(() => (kind.value ? STRUCTURAL_META[kind.value] : undefined)) -const dims = computed<[number, number]>(() => { - const fallback: [number, number] = [render.value?.length ?? 0, render.value?.thickness ?? 0] +const dims = computed(() => { + const render = props.element.render + const fallback = { length: render?.length ?? 0, thickness: render?.thickness ?? 0 } if (!meta.value?.resizable) { return fallback } - return [props.placement.size?.[0] ?? fallback[0], props.placement.size?.[1] ?? fallback[1]] + return { + length: props.placement.size?.[0] ?? fallback.length, + thickness: props.placement.size?.[1] ?? fallback.thickness, + } }) -const length = computed(() => dims.value[0]) -const thickness = computed(() => dims.value[1]) -const rectPath = computed(() => centeredRectPath(length.value, thickness.value)) +const rectPath = computed(() => centeredRectPath(dims.value.length, dims.value.thickness)) const ascending = computed(() => props.placement.props?.direction !== 'down') /** Door/window/stairs variants differ only by the library color, not by code. */ @@ -67,12 +68,12 @@ const icon = computed(() => { return undefined } const size = props.element.size ?? ICON_DEFAULT_SIZE - const centerY = meta.value?.anchor === 'edge' ? -thickness.value / 2 : 0 + const centerY = meta.value?.anchor === 'edge' ? -dims.value.thickness / 2 : 0 return { url, size, x: -size / 2, y: centerY - size / 2 } }) const selectionBounds = computed(() => { - const [l, t] = dims.value + const { length: l, thickness: t } = dims.value const offset = SELECTION_RING_OFFSET return { x: -l / 2 - offset, @@ -92,35 +93,35 @@ const selectionBounds = computed(() => { >