visibility

This commit is contained in:
vcoppe
2026-10-04 09:40:17 +02:00
parent 9ac2a2b69c
commit cd870f0186
4 changed files with 289 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@
"postbuild": "npx tsx src/lib/scripts/sitemap.ts", "postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "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", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
+32
View File
@@ -1,6 +1,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { get, writable, type Readable, type Writable } from 'svelte/store'; import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors'; 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 { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs'; import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
@@ -50,6 +51,11 @@ export type FileState = {
segments: FeatureCollection<LineString, SegmentProperties>; segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */ /** One Point feature per waypoint, in file order. */
waypoints: FeatureCollection<Point, WaypointProperties>; waypoints: FeatureCollection<Point, WaypointProperties>;
/**
* 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 }; const EMPTY_STATISTICS: FileStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
@@ -89,6 +95,11 @@ class Engine {
private _order = writable<string[]>([]); private _order = writable<string[]>([]);
private _colors = new FileColorAllocator(); 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<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map()); private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' }); private _selection = writable<Selection>({ type: 'empty' });
@@ -160,6 +171,26 @@ class Engine {
return this.run((w) => w.reorder(idsToBytes(fileIds), index)); 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. // 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. // 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, statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments }, segments: { type: 'FeatureCollection', features: segments },
waypoints, waypoints,
visibility: this._visibility.get(id) ?? new Map(),
}; };
} }
} }
+144
View File
@@ -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]]);
});
});
+112
View File
@@ -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<string, boolean>;
/** 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;
}