diff --git a/website/src/lib/components/App.svelte b/website/src/lib/components/App.svelte index de791aaf..1a338b3b 100644 --- a/website/src/lib/components/App.svelte +++ b/website/src/lib/components/App.svelte @@ -8,6 +8,10 @@ import Toolbar from '$lib/components/toolbar/Toolbar.svelte'; import LayerControl from '$lib/components/layer-control/LayerControl.svelte'; import { Toaster } from '$lib/components/ui/sonner'; + + import { settings } from '$lib/db'; + + const { verticalFileView } = settings;
@@ -19,16 +23,20 @@ -
- -
+ {#if !$verticalFileView} +
+ +
+ {/if}
- + {#if $verticalFileView} + + {/if} diff --git a/website/src/lib/components/FileListItem.svelte b/website/src/lib/components/FileListItem.svelte deleted file mode 100644 index e9fe7ab0..00000000 --- a/website/src/lib/components/FileListItem.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - -{#if $file} -
{ - if (!get(selectedFiles).has($file.file._data.id)) { - get(selectFiles).select($file.file._data.id); - } - }} - > - - - - - - - - {$_('menu.duplicate')} - - - - {$_('menu.delete')} - - - -
-{/if} diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 5073fd6b..2eff5990 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -22,18 +22,19 @@ Zap, Thermometer, Sun, - Moon + Moon, + Rows3 } from 'lucide-svelte'; import { map, - selectedFiles, exportAllFiles, exportSelectedFiles, triggerFileInput, selectFiles, createFile } from '$lib/stores'; + import { selection } from '$lib/components/file-list/Selection'; import { derived } from 'svelte/store'; import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db'; @@ -47,6 +48,7 @@ distanceUnits, velocityUnits, temperatureUnits, + verticalFileView, mode, currentBasemap, previousBasemap, @@ -113,16 +115,13 @@ {$_('menu.load_drive')} - + {$_('menu.duplicate')} - + {$_('menu.export')} @@ -154,7 +153,7 @@ - + {$_('menu.delete')} @@ -173,6 +172,10 @@ {$_('menu.view')} + + {$_('menu.vertical_file_view')} + + {$_('menu.switch_basemap')} diff --git a/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte b/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte index f57d0c89..386a135d 100644 --- a/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte +++ b/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte @@ -2,26 +2,30 @@ import * as Collapsible from '$lib/components/ui/collapsible'; import { Button } from '$lib/components/ui/button'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte'; - import { getContext } from 'svelte'; + import { getContext, setContext } from 'svelte'; import type { Writable } from 'svelte/store'; - export let id: string; + export let id: string | number; let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state'); let open = getContext>>('collapsible-tree-state'); let side = getContext<'left' | 'right'>('collapsible-tree-side'); let margin = getContext('collapsible-tree-margin'); let nohover = getContext('collapsible-tree-nohover'); + let parentId = getContext('collapsible-tree-parent-id'); + + let fullId = `${parentId}.${id}`; + setContext('collapsible-tree-parent-id', fullId); open.update((value) => { - if (!value.hasOwnProperty(id)) { - value[id] = defaultState === 'open'; + if (!value.hasOwnProperty(fullId)) { + value[fullId] = defaultState === 'open'; } return value; }); - + - + {$_('menu.duplicate')} - {$_('menu.delete')} ; @@ -14,9 +15,9 @@ {#if $file} {#if recursive} - + {:else} - + {/if} {/if} diff --git a/website/src/lib/components/file-list/Selection.ts b/website/src/lib/components/file-list/Selection.ts new file mode 100644 index 00000000..52fb7858 --- /dev/null +++ b/website/src/lib/components/file-list/Selection.ts @@ -0,0 +1,14 @@ +import { get, writable } from "svelte/store"; +import { ListFileItem, ListRootItem, SelectionTreeType } from "./FileList"; +import { fileObservers } from "$lib/db"; + +export const selection = writable(new SelectionTreeType(new ListRootItem())); + +export function selectAll() { + selection.update(($selection) => { + get(fileObservers).forEach((_file, fileId) => { + $selection.set(new ListFileItem(fileId), true); + }); + return $selection; + }); +} \ No newline at end of file diff --git a/website/src/lib/components/gpx-layer/GPXLayers.svelte b/website/src/lib/components/gpx-layer/GPXLayers.svelte index f6773fdf..979128de 100644 --- a/website/src/lib/components/gpx-layer/GPXLayers.svelte +++ b/website/src/lib/components/gpx-layer/GPXLayers.svelte @@ -1,9 +1,10 @@ diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts index 142efca4..f1913d4d 100644 --- a/website/src/lib/db.ts +++ b/website/src/lib/db.ts @@ -1,10 +1,12 @@ import Dexie, { liveQuery } from 'dexie'; -import { GPXFile, GPXStatistics } from 'gpx'; +import { GPXFile, GPXStatistics, Track } from 'gpx'; import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, Immer } from 'immer'; import { writable, get, derived, type Readable, type Writable } from 'svelte/store'; -import { initTargetMapBounds, selectedFiles, updateTargetMapBounds } from './stores'; +import { initTargetMapBounds, updateTargetMapBounds } from './stores'; import { mode } from 'mode-watcher'; import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers'; +import { selection } from '$lib/components/file-list/Selection'; +import { ListFileItem, ListItem, type ListLevel } from '$lib/components/file-list/FileList'; enableMapSet(); enablePatches(); @@ -73,9 +75,10 @@ function dexieUninitializedSettingStore(setting: string, initial: any): Writable } export const settings = { - distanceUnits: dexieSettingStore('distanceUnits', 'metric'), + distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'), velocityUnits: dexieSettingStore('velocityUnits', 'speed'), temperatureUnits: dexieSettingStore('temperatureUnits', 'celsius'), + verticalFileView: dexieSettingStore('fileView', false), mode: dexieSettingStore('mode', (() => { let currentMode: string | undefined = get(mode); if (currentMode === undefined) { @@ -110,7 +113,49 @@ function dexieStore(querier: () => T | Promise, initial?: T): Readable }; } -export type GPXFileWithStatistics = { file: GPXFile, statistics: GPXStatistics }; +export class GPXStatisticsTree { + level: ListLevel; + statistics: { + [key: number]: GPXStatisticsTree | GPXStatistics; + } = {}; + + constructor(element: GPXFile | Track) { + if (element instanceof GPXFile) { + this.level = 'file'; + element.children.forEach((child, index) => { + this.statistics[index] = new GPXStatisticsTree(child); + }); + } else { + this.level = 'track'; + element.children.forEach((child, index) => { + this.statistics[index] = child.getStatistics(); + }); + } + } + + getStatisticsFor(item: ListItem): GPXStatistics { + let statistics = new GPXStatistics(); + let id = item.getIdAtLevel(this.level); + if (id === undefined || id === 'waypoints') { + Object.keys(this.statistics).forEach(key => { + if (this.statistics[key] instanceof GPXStatistics) { + statistics.mergeWith(this.statistics[key]); + } else { + statistics.mergeWith(this.statistics[key].getStatisticsFor(item)); + } + }); + } else { + let child = this.statistics[id]; + if (child instanceof GPXStatistics) { + statistics.mergeWith(child); + } else if (child !== undefined) { + statistics.mergeWith(child.getStatisticsFor(item)); + } + } + return statistics; + } +}; +export type GPXFileWithStatistics = { file: GPXFile, statistics: GPXStatisticsTree }; // Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, also takes care of the conversion to a GPXFile object function dexieGPXFileStore(id: string): Readable & { destroy: () => void } { @@ -118,10 +163,12 @@ function dexieGPXFileStore(id: string): Readable & { dest let query = liveQuery(() => db.files.get(id)).subscribe(value => { if (value !== undefined) { let gpx = new GPXFile(value); - let statistics = gpx.getStatistics(); + + let statistics = new GPXStatisticsTree(gpx); if (!fileState.has(id)) { // Update the map bounds for new files - updateTargetMapBounds(statistics.global.bounds); + updateTargetMapBounds(statistics.getStatisticsFor(new ListFileItem(id)).global.bounds); } + fileState.set(id, gpx); store.set({ file: gpx, @@ -304,14 +351,14 @@ export const dbUtils = { applyToFile: (id: string, callback: (file: WritableDraft) => GPXFile) => { applyToFiles([id], callback); }, - applyToSelectedFiles: (callback: (file: WritableDraft) => GPXFile) => { - applyToFiles(get(settings.fileOrder).filter(fileId => get(selectedFiles).has(fileId)), callback); + applyToSelection: (callback: (file: WritableDraft) => GPXFile) => { + applyToFiles(get(selection).forEach(fileId), callback); }, - duplicateSelectedFiles: () => { + duplicateSelection: () => { applyGlobal((draft) => { let ids = getFileIds(get(settings.fileOrder).length); get(settings.fileOrder).forEach((fileId, index) => { - if (get(selectedFiles).has(fileId)) { + if (get(selection).has(fileId)) { let file = draft.get(fileId); if (file) { let clone = file.clone(); @@ -322,10 +369,13 @@ export const dbUtils = { }); }); }, - deleteSelectedFiles: () => { + deleteSelection: () => { applyGlobal((draft) => { - get(selectedFiles).forEach((fileId) => { - draft.delete(fileId); + get(selection).forEach((item) => { + if (item instanceof ListFileItem) { + draft.delete(item.getId()); + } + // TODO: Implement deletion of tracks, segments, waypoints }); }); }, diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index c6716900..a091cf1c 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -5,61 +5,58 @@ import { GPXFile, buildGPX, parseGPX, GPXStatistics, type Coordinates } from 'gp import { tick } from 'svelte'; import { _ } from 'svelte-i18n'; import type { GPXLayer } from '$lib/components/gpx-layer/GPXLayer'; -import { dbUtils, fileObservers, settings } from './db'; +import { settings, dbUtils, fileObservers } from './db'; +import { selection } from '$lib/components/file-list/Selection'; +import { ListFileItem, ListWaypointItem, type ListItem } from '$lib/components/file-list/FileList'; export const map = writable(null); -export const selectedFiles = writable>(new Set()); export const selectFiles = writable<{ [key: string]: (fileId?: string) => void }>({}); -const { fileOrder } = settings; - -fileObservers.subscribe((files) => { // Update selectedFiles automatically when files are deleted (either by action or by undo-redo) - let deletedFileIds: string[] = []; - get(selectedFiles).forEach((fileId) => { - if (!files.has(fileId)) { - deletedFileIds.push(fileId); - } - }); - if (deletedFileIds.length > 0) { - selectedFiles.update((selectedFiles) => { - deletedFileIds.forEach((fileId) => selectedFiles.delete(fileId)); - return selectedFiles; - }); - } -}); - export const gpxStatistics: Writable = writable(new GPXStatistics()); +const { fileOrder } = settings; + function updateGPXData() { - let fileIds: string[] = get(fileOrder).filter((f) => get(selectedFiles).has(f)); - gpxStatistics.set(fileIds.reduce((stats: GPXStatistics, fileId: string) => { + let statistics = new GPXStatistics(); + get(fileOrder).forEach((fileId) => { // Get statistics in the order of the file list let fileStore = get(fileObservers).get(fileId); if (fileStore) { - let statistics = get(fileStore)?.statistics; - if (statistics) { - stats.mergeWith(statistics); + let stats = get(fileStore)?.statistics; + if (stats !== undefined) { + let first = true; + get(selection).getChild(fileId)?.getSelected().forEach((item) => { // Get statistics for selected items within the file + if (!(item instanceof ListWaypointItem) || first) { + statistics.mergeWith(stats.getStatisticsFor(item)); + first = false; + } + }); } } - return stats; - }, new GPXStatistics())); + }); + gpxStatistics.set(statistics); } -let unsubscribes: Function[] = []; -selectedFiles.subscribe((selectedFiles) => { // Maintain up-to-date statistics for the current selection +let unsubscribes: Map void> = new Map(); +selection.subscribe(($selection) => { // Maintain up-to-date statistics for the current selection updateGPXData(); - while (unsubscribes.length > 0) { - unsubscribes.pop()(); + while (unsubscribes.size > 0) { + let [fileId, unsubscribe] = unsubscribes.entries().next().value; + unsubscribe(); + unsubscribes.delete(fileId); } - selectedFiles.forEach((fileId) => { - let fileObserver = get(fileObservers).get(fileId); - if (fileObserver) { - let first = true; - unsubscribes.push(fileObserver.subscribe(() => { - if (first) first = false; - else updateGPXData(); - })); + $selection.forEach((item) => { + let fileId = item.getFileId(); + if (!unsubscribes.has(fileId)) { + let fileObserver = get(fileObservers).get(fileId); + if (fileObserver) { + let first = true; + unsubscribes.set(fileId, fileObserver.subscribe(() => { + if (first) first = false; + else updateGPXData(); + })); + } } }); }); @@ -191,7 +188,11 @@ function selectFileWhenLoaded(fileId: string) { const unsubscribe = fileObservers.subscribe((files) => { if (files.has(fileId)) { tick().then(() => { - get(selectFiles).select(fileId); + selection.update(($selection) => { + $selection.clear(); + $selection.toggle(new ListFileItem(fileId)); + return $selection; + }); }); unsubscribe(); } @@ -200,7 +201,7 @@ function selectFileWhenLoaded(fileId: string) { export function exportSelectedFiles() { get(fileObservers).forEach(async (file, fileId) => { - if (get(selectedFiles).has(fileId)) { + if (get(selection).has(fileId)) { let f = get(file); if (f) { exportFile(f.file); diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 42a277be..8b50b82f 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -15,6 +15,7 @@ "delete_all": "Delete all", "select_all": "Select all", "view": "View", + "vertical_file_view": "Show vertical file list", "switch_basemap": "Switch to previous basemap", "toggle_overlays": "Toggle overlays", "toggle_3d": "Toggle 3D",