From cd870f0186ba957a2110c7e626771799779c4414 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 09:40:17 +0200 Subject: [PATCH] visibility --- website/package.json | 1 + website/src/lib/engine.ts | 32 ++++++ website/src/lib/file-visibility.test.ts | 144 ++++++++++++++++++++++++ website/src/lib/file-visibility.ts | 112 ++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 website/src/lib/file-visibility.test.ts create mode 100644 website/src/lib/file-visibility.ts diff --git a/website/package.json b/website/package.json index 6ea0e1e17..9d68561a0 100644 --- a/website/package.json +++ b/website/package.json @@ -9,6 +9,7 @@ "postbuild": "npx tsx src/lib/scripts/sitemap.ts", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "node --import tsx --test \"src/**/*.test.ts\"", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 852510e92..074dc1bb3 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { get, writable, type Readable, type Writable } from 'svelte/store'; import { FileColorAllocator, normalizeColor } from '$lib/file-colors'; +import { setHidden, type Visibility } from '$lib/file-visibility'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; import type { FileStatistics, FileStructure, Selection } from 'gpx-rs'; @@ -50,6 +51,11 @@ export type FileState = { segments: FeatureCollection; /** One Point feature per waypoint, in file order. */ waypoints: FeatureCollection; + /** + * Visibility the user set explicitly (see `Visibility` and `isHidden`, `isSegmentHidden`, + * `isWaypointHidden` in `file-visibility`). Hidden elements are still part of the statistics. + */ + visibility: Visibility; }; const EMPTY_STATISTICS: FileStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }; @@ -89,6 +95,11 @@ class Engine { private _order = writable([]); private _colors = new FileColorAllocator(); + /** + * Explicit visibility per file. Not dropped with the files: it is back when a deletion is + * undone. UI state only, it is not part of the engine's history. + */ + private _visibility = new Map(); private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); @@ -160,6 +171,26 @@ class Engine { return this.run((w) => w.reorder(idsToBytes(fileIds), index)); } + /** + * Hides or shows elements of a file (see `FileState.visibility`). What was set explicitly + * below these elements is reset, so that hiding a file then showing one of its segments + * leaves only that segment visible, and hiding the file again hides all of it. + */ + setHidden(fileId: string, ids: string[], hidden: boolean) { + const store = get(this._files).get(fileId); + if (!store) { + return; + } + const visibility = setHidden( + get(store).structure, + this._visibility.get(fileId) ?? new Map(), + ids, + hidden + ); + this._visibility.set(fileId, visibility); + store.update((state) => ({ ...state, visibility })); + } + // Coordinates, as flat [lng, lat, ...] arrays. They are copied out of the WASM memory. // Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed. @@ -304,6 +335,7 @@ class Engine { statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS, segments: { type: 'FeatureCollection', features: segments }, waypoints, + visibility: this._visibility.get(id) ?? new Map(), }; } } diff --git a/website/src/lib/file-visibility.test.ts b/website/src/lib/file-visibility.test.ts new file mode 100644 index 000000000..85858d38b --- /dev/null +++ b/website/src/lib/file-visibility.test.ts @@ -0,0 +1,144 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { FileStructure } from 'gpx-rs'; +import { + isHidden, + isSegmentHidden, + isWaypointHidden, + setHidden, + waypointsKey, + type Visibility, + type VisibilityState, +} from './file-visibility'; + +const segment = (id: string) => ({ id, rev: `rev-${id}`, length: 2 }); + +// F +// ├── waypoints: W1, W2 +// ├── T1: S1, S2 +// └── T2: S3 +const structure: FileStructure = { + id: 'F', + name: 'file', + waypointsRev: 'wpt-rev', + waypoints: [{ id: 'W1' }, { id: 'W2' }], + tracks: [ + { id: 'T1', segments: [segment('S1'), segment('S2')] }, + { id: 'T2', segments: [segment('S3')] }, + ], +}; +const WPTS = waypointsKey('F'); +const elements = ['F', WPTS, 'W1', 'W2', 'T1', 'S1', 'S2', 'T2', 'S3']; + +/** Applies the actions one after the other, starting with everything visible. */ +function apply(...actions: [ids: string[], hidden: boolean][]): VisibilityState { + let visibility: Visibility = new Map(); + for (const [ids, hidden] of actions) { + visibility = setHidden(structure, visibility, ids, hidden); + } + return { structure, visibility }; +} + +function hiddenElements(state: VisibilityState): string[] { + return elements.filter((id) => isHidden(state, id)); +} + +describe('file visibility', () => { + it('shows everything by default', () => { + assert.deepEqual(hiddenElements(apply()), []); + }); + + it('hides an element and what is below it', () => { + assert.deepEqual(hiddenElements(apply([['T1'], true])), ['T1', 'S1', 'S2']); + assert.deepEqual(hiddenElements(apply([['S1'], true])), ['S1']); + assert.deepEqual(hiddenElements(apply([[WPTS], true])), [WPTS, 'W1', 'W2']); + assert.deepEqual(hiddenElements(apply([['W2'], true])), ['W2']); + }); + + it('hides a whole file', () => { + assert.deepEqual(hiddenElements(apply([['F'], true])), elements); + }); + + it('shows again what was hidden', () => { + assert.deepEqual(hiddenElements(apply([['F'], true], [['F'], false])), []); + assert.deepEqual(hiddenElements(apply([['S1'], true], [['S1'], false])), []); + }); + + it('keeps the other segments hidden when one is shown below a hidden file', () => { + const state = apply([['F'], true], [['S1'], false]); + assert.deepEqual(hiddenElements(state), [WPTS, 'W1', 'W2', 'S2', 'T2', 'S3']); + // the file and the track of the visible segment are not hidden, they have visible content + assert.equal(isHidden(state, 'F'), false); + assert.equal(isHidden(state, 'T1'), false); + assert.equal(isHidden(state, 'S1'), false); + }); + + it('hides the file again once what was shown is hidden', () => { + const state = apply([['F'], true], [['S1'], false], [['S1'], true]); + assert.deepEqual(hiddenElements(state), elements); + }); + + it('resets what was shown below when hiding a parent', () => { + const state = apply([['F'], true], [['S1'], false], [['F'], true]); + assert.deepEqual(hiddenElements(state), elements); + assert.equal(state.visibility.has('S1'), false); + }); + + it('resets what was hidden below when showing a parent', () => { + const state = apply([['S1', 'S3', 'W1'], true], [['F'], false]); + assert.deepEqual(hiddenElements(state), []); + assert.equal(state.visibility.has('S1'), false); + }); + + it('shows a track hidden below a hidden file, with all its segments', () => { + const state = apply([['F'], true], [['T1'], false]); + assert.deepEqual(hiddenElements(state), [WPTS, 'W1', 'W2', 'T2', 'S3']); + }); + + it('shows a waypoint below a hidden file', () => { + const state = apply([['F'], true], [['W1'], false]); + assert.deepEqual(hiddenElements(state), ['W2', 'T1', 'S1', 'S2', 'T2', 'S3']); + assert.equal(isHidden(state, WPTS), false); + assert.equal(isHidden(state, 'F'), false); + }); + + it('hides a segment shown inside a hidden track of a visible file', () => { + const state = apply([['T1'], true], [['S2'], false]); + assert.deepEqual(hiddenElements(state), ['S1']); + }); + + it('does not touch the other branches of the tree', () => { + const state = apply([['T1'], true], [['T2'], true], [['T1'], false]); + assert.deepEqual(hiddenElements(state), ['T2', 'S3']); + }); + + it('has leaf helpers agreeing with isHidden', () => { + const states = [ + apply(), + apply([['F'], true]), + apply([['F'], true], [['S1'], false], [['W2'], false]), + apply([['T1'], true], [['S2'], false]), + apply([[WPTS], true], [['W1'], false]), + ]; + for (const state of states) { + for (const track of structure.tracks) { + for (const { id } of track.segments) { + assert.equal(isSegmentHidden(state, track.id, id), isHidden(state, id)); + } + } + for (const { id } of structure.waypoints) { + assert.equal(isWaypointHidden(state, id), isHidden(state, id)); + } + } + }); + + it('does not consider unknown ids hidden', () => { + assert.equal(isHidden(apply([['F'], true]), 'unknown'), false); + }); + + it('does not mutate the previous visibility', () => { + const before: Visibility = new Map([['S1', false]]); + setHidden(structure, before, ['F'], true); + assert.deepEqual([...before], [['S1', false]]); + }); +}); diff --git a/website/src/lib/file-visibility.ts b/website/src/lib/file-visibility.ts new file mode 100644 index 000000000..119171cbd --- /dev/null +++ b/website/src/lib/file-visibility.ts @@ -0,0 +1,112 @@ +import type { FileStructure } from 'gpx-rs'; + +/** + * Visibility the user set explicitly, by element id (file, track, segment, waypoint, or the + * waypoints of the file as a whole, see `waypointsKey`): `false` hides, `true` shows. An element + * without entry follows its parent, and everything is shown by default. + */ +export type Visibility = ReadonlyMap; + +/** What is needed to tell whether the elements of a file are hidden (a `FileState` fits). */ +export type VisibilityState = { structure: FileStructure; visibility: Visibility }; + +/** Id to hide or show all the waypoints of a file at once. */ +export function waypointsKey(fileId: string): string { + return `${fileId}:waypoints`; +} + +/** Ids from the file down to `id` in the file tree (file, tracks, segments / waypoints group). */ +function pathTo(structure: FileStructure, id: string): string[] { + const fileId = structure.id; + if (id === fileId) { + return [fileId]; + } + if (id === waypointsKey(fileId)) { + return [fileId, id]; + } + if (structure.waypoints.some((waypoint) => waypoint.id === id)) { + return [fileId, waypointsKey(fileId), id]; + } + for (const track of structure.tracks) { + if (track.id === id) { + return [fileId, id]; + } + if (track.segments.some((segment) => segment.id === id)) { + return [fileId, track.id, id]; + } + } + return []; +} + +function children(structure: FileStructure, id: string): string[] { + if (id === structure.id) { + return [waypointsKey(id), ...structure.tracks.map((track) => track.id)]; + } + if (id === waypointsKey(structure.id)) { + return structure.waypoints.map((waypoint) => waypoint.id); + } + return structure.tracks.find((track) => track.id === id)?.segments.map((s) => s.id) ?? []; +} + +function descendants(structure: FileStructure, id: string): string[] { + return children(structure, id).flatMap((child) => [child, ...descendants(structure, child)]); +} + +/** The closest explicit visibility on a path (from the file down): whether it is hidden. */ +function followsHidden(visibility: Visibility, path: string[]): boolean { + for (let i = path.length - 1; i >= 0; i--) { + const shown = visibility.get(path[i]); + if (shown !== undefined) { + return !shown; + } + } + return false; +} + +/** + * Whether an element is hidden: its closest explicit ancestor (or itself) is hidden, and none + * of its descendants was shown. A file whose only visible segment was shown on its own is not + * hidden, its other segments are. Unknown ids are not hidden. + */ +export function isHidden({ structure, visibility }: VisibilityState, id: string): boolean { + return ( + followsHidden(visibility, pathTo(structure, id)) && + !descendants(structure, id).some((descendant) => visibility.get(descendant) === true) + ); +} + +// Leaf elements have no descendants: no need to look at the structure. + +export function isSegmentHidden( + { structure, visibility }: VisibilityState, + trackId: string, + segmentId: string +): boolean { + return followsHidden(visibility, [structure.id, trackId, segmentId]); +} + +export function isWaypointHidden( + { structure, visibility }: VisibilityState, + waypointId: string +): boolean { + return followsHidden(visibility, [structure.id, waypointsKey(structure.id), waypointId]); +} + +/** + * Hides or shows elements of a file, and returns the new visibility. What was set explicitly + * below these elements is reset, so that hiding a file then showing one of its segments leaves + * only that segment visible, and hiding the file again hides all of it. + */ +export function setHidden( + structure: FileStructure, + visibility: Visibility, + ids: string[], + hidden: boolean +): Visibility { + const next = new Map(visibility); + ids.forEach((id) => { + descendants(structure, id).forEach((child) => next.delete(child)); + next.set(id, !hidden); + }); + return next; +}