mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
280 lines
9.8 KiB
TypeScript
280 lines
9.8 KiB
TypeScript
import type { Clipboard, FileStructure, PathInfo, Selection } from 'gpx-wasm';
|
|||
|
|
import { isHidden, waypointsKey, type VisibilityState } from '$lib/engine/file-visibility';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An element of the file tree, to ask questions about the selection:
|
||
|
|
*
|
||
|
|
* file ─┬─ route
|
||
|
|
* ├─ track ─── segment
|
||
|
|
* └─ waypoints ─── waypoint
|
||
|
|
*
|
||
|
|
* A route is like a track of a single segment, which is not a node of its own. `waypoints` is the
|
||
|
|
* node standing for all the waypoints of a file.
|
||
|
|
*/
|
||
|
|
export type FileTreeNode =
|
||
|
|
| { type: 'file'; fileId: string }
|
||
|
|
| { type: 'route'; fileId: string; routeId: string }
|
||
|
|
| { type: 'track'; fileId: string; trackId: string }
|
||
|
|
| { type: 'segment'; fileId: string; trackId: string; segmentId: string }
|
||
|
|
| { type: 'waypoints'; fileId: string }
|
||
|
|
| { type: 'waypoint'; fileId: string; waypointId: string };
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The id of the element of a node, as used by the visibility (`waypointsKey` for the waypoints
|
||
|
|
* node).
|
||
|
|
*/
|
||
|
|
export function elementId(node: FileTreeNode): string {
|
||
|
|
switch (node.type) {
|
||
|
|
case 'file':
|
||
|
|
return node.fileId;
|
||
|
|
case 'route':
|
||
|
|
return node.routeId;
|
||
|
|
case 'track':
|
||
|
|
return node.trackId;
|
||
|
|
case 'segment':
|
||
|
|
return node.segmentId;
|
||
|
|
case 'waypoints':
|
||
|
|
return waypointsKey(node.fileId);
|
||
|
|
case 'waypoint':
|
||
|
|
return node.waypointId;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The name, description and style of a route or track node, `undefined` for the other nodes. */
|
||
|
|
export function pathInfo(structure: FileStructure, node: FileTreeNode): PathInfo | undefined {
|
||
|
|
switch (node.type) {
|
||
|
|
case 'route':
|
||
|
|
return structure.routes.find((route) => route.id === node.routeId);
|
||
|
|
case 'track':
|
||
|
|
return structure.tracks.find((track) => track.id === node.trackId);
|
||
|
|
default:
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the selection is made of segments: files, routes, tracks or segments, not waypoints (the
|
||
|
|
* selection the tools that work on trackpoints work on).
|
||
|
|
*/
|
||
|
|
export function coversSegments(selection: Selection): boolean {
|
||
|
|
return (
|
||
|
|
selection.type === 'file' ||
|
||
|
|
selection.type === 'route' ||
|
||
|
|
selection.type === 'track' ||
|
||
|
|
selection.type === 'segment'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isEmpty(selection: Selection): boolean {
|
||
|
|
return selection.type === 'empty';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Ids of the files holding something selected (or selected themselves). */
|
||
|
|
export function selectedFileIds(selection: Selection): string[] {
|
||
|
|
switch (selection.type) {
|
||
|
|
case 'empty':
|
||
|
|
return [];
|
||
|
|
case 'file':
|
||
|
|
return selection.fileIds;
|
||
|
|
default:
|
||
|
|
return [selection.fileId];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the node is one of the elements in the clipboard. */
|
||
|
|
export function isInClipboard(clipboard: Clipboard | undefined, node: FileTreeNode): boolean {
|
||
|
|
if (!clipboard) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
switch (node.type) {
|
||
|
|
case 'file':
|
||
|
|
return clipboard.type === 'files' && clipboard.ids.includes(node.fileId);
|
||
|
|
case 'route':
|
||
|
|
return clipboard.type === 'routes' && clipboard.ids.includes(node.routeId);
|
||
|
|
case 'track':
|
||
|
|
return clipboard.type === 'tracks' && clipboard.ids.includes(node.trackId);
|
||
|
|
case 'segment':
|
||
|
|
return clipboard.type === 'segments' && clipboard.ids.includes(node.segmentId);
|
||
|
|
case 'waypoint':
|
||
|
|
return clipboard.type === 'waypoints' && clipboard.ids.includes(node.waypointId);
|
||
|
|
case 'waypoints':
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Number of selected elements (the waypoints node counts for one). */
|
||
|
|
export function selectionSize(selection: Selection): number {
|
||
|
|
switch (selection.type) {
|
||
|
|
case 'empty':
|
||
|
|
return 0;
|
||
|
|
case 'file':
|
||
|
|
return selection.fileIds.length;
|
||
|
|
case 'route':
|
||
|
|
return selection.routeIds.length;
|
||
|
|
case 'track':
|
||
|
|
return selection.trackIds.length;
|
||
|
|
case 'segment':
|
||
|
|
return selection.segmentIds.length;
|
||
|
|
case 'waypoints':
|
||
|
|
return 1;
|
||
|
|
case 'waypoint':
|
||
|
|
return selection.waypointIds.length;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The ids of the selected elements, by file, as used by the visibility (a file, its routes, its
|
||
|
|
* tracks, its segments, its waypoints, or `waypointsKey` for the waypoints node).
|
||
|
|
*/
|
||
|
|
export function selectedElementIds(selection: Selection): { fileId: string; ids: string[] }[] {
|
||
|
|
switch (selection.type) {
|
||
|
|
case 'empty':
|
||
|
|
return [];
|
||
|
|
case 'file':
|
||
|
|
return selection.fileIds.map((fileId) => ({ fileId, ids: [fileId] }));
|
||
|
|
case 'route':
|
||
|
|
return [{ fileId: selection.fileId, ids: selection.routeIds }];
|
||
|
|
case 'track':
|
||
|
|
return [{ fileId: selection.fileId, ids: selection.trackIds }];
|
||
|
|
case 'segment':
|
||
|
|
return [{ fileId: selection.fileId, ids: selection.segmentIds }];
|
||
|
|
case 'waypoints':
|
||
|
|
return [{ fileId: selection.fileId, ids: [waypointsKey(selection.fileId)] }];
|
||
|
|
case 'waypoint':
|
||
|
|
return [{ fileId: selection.fileId, ids: selection.waypointIds }];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the node itself is selected. */
|
||
|
|
export function isSelected(selection: Selection, node: FileTreeNode): boolean {
|
||
|
|
switch (node.type) {
|
||
|
|
case 'file':
|
||
|
|
return selection.type === 'file' && selection.fileIds.includes(node.fileId);
|
||
|
|
case 'route':
|
||
|
|
return (
|
||
|
|
selection.type === 'route' &&
|
||
|
|
selection.fileId === node.fileId &&
|
||
|
|
selection.routeIds.includes(node.routeId)
|
||
|
|
);
|
||
|
|
case 'track':
|
||
|
|
return (
|
||
|
|
selection.type === 'track' &&
|
||
|
|
selection.fileId === node.fileId &&
|
||
|
|
selection.trackIds.includes(node.trackId)
|
||
|
|
);
|
||
|
|
case 'segment':
|
||
|
|
return (
|
||
|
|
selection.type === 'segment' &&
|
||
|
|
selection.fileId === node.fileId &&
|
||
|
|
selection.trackId === node.trackId &&
|
||
|
|
selection.segmentIds.includes(node.segmentId)
|
||
|
|
);
|
||
|
|
case 'waypoints':
|
||
|
|
return selection.type === 'waypoints' && selection.fileId === node.fileId;
|
||
|
|
case 'waypoint':
|
||
|
|
return (
|
||
|
|
selection.type === 'waypoint' &&
|
||
|
|
selection.fileId === node.fileId &&
|
||
|
|
selection.waypointIds.includes(node.waypointId)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The parent of a node, `undefined` for a file. */
|
||
|
|
function parent(node: FileTreeNode): FileTreeNode | undefined {
|
||
|
|
switch (node.type) {
|
||
|
|
case 'file':
|
||
|
|
return undefined;
|
||
|
|
case 'route':
|
||
|
|
case 'track':
|
||
|
|
case 'waypoints':
|
||
|
|
return { type: 'file', fileId: node.fileId };
|
||
|
|
case 'segment':
|
||
|
|
return { type: 'track', fileId: node.fileId, trackId: node.trackId };
|
||
|
|
case 'waypoint':
|
||
|
|
return { type: 'waypoints', fileId: node.fileId };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the node, or one of its ancestors, is selected: the node is covered by the selection. */
|
||
|
|
export function isCovered(selection: Selection, node: FileTreeNode): boolean {
|
||
|
|
for (let n: FileTreeNode | undefined = node; n; n = parent(n)) {
|
||
|
|
if (isSelected(selection, n)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the node, or one of its descendants, is selected. */
|
||
|
|
export function hasSelectionWithin(selection: Selection, node: FileTreeNode): boolean {
|
||
|
|
switch (node.type) {
|
||
|
|
case 'file':
|
||
|
|
return selectedFileIds(selection).includes(node.fileId);
|
||
|
|
case 'track':
|
||
|
|
return (
|
||
|
|
isSelected(selection, node) ||
|
||
|
|
(selection.type === 'segment' &&
|
||
|
|
selection.fileId === node.fileId &&
|
||
|
|
selection.trackId === node.trackId)
|
||
|
|
);
|
||
|
|
case 'waypoints':
|
||
|
|
return (
|
||
|
|
isSelected(selection, node) ||
|
||
|
|
(selection.type === 'waypoint' && selection.fileId === node.fileId)
|
||
|
|
);
|
||
|
|
case 'route':
|
||
|
|
case 'segment':
|
||
|
|
case 'waypoint':
|
||
|
|
return isSelected(selection, node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether all the selected elements are hidden (false when nothing is selected). `states` holds
|
||
|
|
* the files by id: an element of an unknown file is not hidden.
|
||
|
|
*/
|
||
|
|
export function isSelectionHidden(
|
||
|
|
selection: Selection,
|
||
|
|
states: ReadonlyMap<string, VisibilityState>
|
||
|
|
): boolean {
|
||
|
|
const selected = selectedElementIds(selection);
|
||
|
|
return (
|
||
|
|
selected.length > 0 &&
|
||
|
|
selected.every(({ fileId, ids }) => {
|
||
|
|
const state = states.get(fileId);
|
||
|
|
return state !== undefined && ids.every((id) => isHidden(state, id));
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* What the selected files convert to: `'route'` when they all have tracks only (with segments),
|
||
|
|
* `'track'` when they all have routes only, `undefined` otherwise (other selections, files with
|
||
|
|
* both or neither, or a mix of the two). `states` holds the files by id.
|
||
|
|
*/
|
||
|
|
export function fileConversion(
|
||
|
|
selection: Selection,
|
||
|
|
states: ReadonlyMap<string, { structure: FileStructure }>
|
||
|
|
): 'route' | 'track' | undefined {
|
||
|
|
if (selection.type !== 'file' || selection.fileIds.length === 0) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const targets = new Set(
|
||
|
|
selection.fileIds.map((fileId) => {
|
||
|
|
const structure = states.get(fileId)?.structure;
|
||
|
|
if (structure === undefined) {
|
||
|
|
return undefined;
|
||
|
|
} else if (structure.routes.length > 0 && structure.tracks.length === 0) {
|
||
|
|
return 'track' as const;
|
||
|
|
} else if (
|
||
|
|
structure.routes.length === 0 &&
|
||
|
|
structure.tracks.some((track) => track.segments.length > 0)
|
||
|
|
) {
|
||
|
|
return 'route' as const;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
})
|
||
|
|
);
|
||
|
|
return targets.size === 1 ? targets.values().next().value : undefined;
|
||
|
|
}
|