mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +00:00
visibility
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<LineString, SegmentProperties>;
|
||||
/** One Point feature per waypoint, in file order. */
|
||||
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 };
|
||||
@@ -89,6 +95,11 @@ class Engine {
|
||||
|
||||
private _order = writable<string[]>([]);
|
||||
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 _selection = writable<Selection>({ 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user