Files
gpx.studio/website/src/lib/db.ts

935 lines
43 KiB
TypeScript
Raw Normal View History

2024-05-02 19:51:08 +02:00
import Dexie, { liveQuery } from 'dexie';
2024-06-19 16:15:21 +02:00
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension } from 'gpx';
2024-05-24 16:37:26 +02:00
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, freeze, produceWithPatches, original, produce } from 'immer';
2024-05-02 19:51:08 +02:00
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
2024-06-18 12:35:24 +02:00
import { gpxStatistics, initTargetMapBounds, splitAs, updateTargetMapBounds } from './stores';
2024-05-04 15:10:30 +02:00
import { mode } from 'mode-watcher';
2024-06-26 17:19:41 +02:00
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer } from './assets/layers';
2024-06-11 19:08:46 +02:00
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
2024-05-24 16:37:26 +02:00
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
2024-05-23 12:57:24 +02:00
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
2024-06-10 20:03:57 +02:00
import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte';
2024-05-02 19:51:08 +02:00
2024-05-07 18:14:47 +02:00
enableMapSet();
enablePatches();
2024-05-16 13:27:12 +02:00
2024-05-02 19:51:08 +02:00
class Database extends Dexie {
2024-05-03 15:59:34 +02:00
fileids!: Dexie.Table<string, string>;
2024-05-07 18:14:47 +02:00
files!: Dexie.Table<GPXFile, string>;
2024-05-07 13:19:02 +02:00
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
2024-05-02 19:51:08 +02:00
settings!: Dexie.Table<any, string>;
constructor() {
2024-05-03 15:59:34 +02:00
super("Database", {
cache: 'immutable'
});
2024-05-02 19:51:08 +02:00
this.version(1).stores({
2024-05-03 15:59:34 +02:00
fileids: ',&fileid',
files: '',
patches: ',patch',
settings: ''
2024-05-02 19:51:08 +02:00
});
2024-05-03 15:59:34 +02:00
this.files.add
2024-05-02 19:51:08 +02:00
}
}
const db = new Database();
2024-05-05 18:59:09 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
2024-05-21 22:37:52 +02:00
function dexieSettingStore<T>(setting: string, initial: T): Writable<T> {
2024-05-05 18:59:09 +02:00
let store = writable(initial);
liveQuery(() => db.settings.get(setting)).subscribe(value => {
if (value !== undefined) {
store.set(value);
}
});
return {
subscribe: store.subscribe,
2024-06-25 16:06:11 +02:00
set: (value: any) => {
2024-06-26 17:19:41 +02:00
if (typeof value === 'object' || value !== get(store)) {
2024-06-25 16:06:11 +02:00
db.settings.put(value, setting);
}
},
2024-05-05 18:59:09 +02:00
update: (callback: (value: any) => any) => {
let newValue = callback(get(store));
2024-06-26 17:19:41 +02:00
if (typeof newValue === 'object' || newValue !== get(store)) {
2024-06-25 16:06:11 +02:00
db.settings.put(newValue, setting);
}
2024-05-05 18:59:09 +02:00
}
};
}
2024-05-06 15:52:11 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
function dexieUninitializedSettingStore(setting: string, initial: any): Writable<any> {
let store = writable(undefined);
liveQuery(() => db.settings.get(setting)).subscribe(value => {
if (value !== undefined) {
store.set(value);
} else {
store.set(initial);
}
});
return {
subscribe: store.subscribe,
set: (value: any) => db.settings.put(value, setting),
update: (callback: (value: any) => any) => {
let newValue = callback(get(store));
db.settings.put(newValue, setting);
}
};
}
2024-05-05 18:59:09 +02:00
export const settings = {
2024-05-22 16:05:31 +02:00
distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'),
2024-05-05 18:59:09 +02:00
velocityUnits: dexieSettingStore('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore('temperatureUnits', 'celsius'),
2024-06-09 17:22:41 +02:00
elevationProfile: dexieSettingStore('elevationProfile', true),
2024-05-22 16:05:31 +02:00
verticalFileView: dexieSettingStore<boolean>('fileView', false),
2024-05-05 18:59:09 +02:00
mode: dexieSettingStore('mode', (() => {
let currentMode: string | undefined = get(mode);
if (currentMode === undefined) {
currentMode = 'system';
}
return currentMode;
})()),
2024-06-10 12:06:32 +02:00
minimizeRoutingMenu: dexieSettingStore('minimizeRoutingMenu', false),
2024-05-05 18:59:09 +02:00
routing: dexieSettingStore('routing', true),
routingProfile: dexieSettingStore('routingProfile', 'bike'),
privateRoads: dexieSettingStore('privateRoads', false),
currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap),
previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap),
selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree),
2024-05-06 15:52:11 +02:00
currentOverlays: dexieUninitializedSettingStore('currentOverlays', defaultOverlays),
2024-05-05 18:59:09 +02:00
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
2024-06-26 17:19:41 +02:00
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
2024-05-08 12:35:31 +02:00
directionMarkers: dexieSettingStore('directionMarkers', false),
distanceMarkers: dexieSettingStore('distanceMarkers', false),
2024-06-06 18:11:03 +02:00
stravaHeatmapColor: dexieSettingStore('stravaHeatmapColor', 'bluered'),
2024-06-24 19:41:44 +02:00
streetViewSource: dexieSettingStore('streetViewSource', 'mapillary'),
2024-05-21 22:37:52 +02:00
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
2024-06-20 15:18:21 +02:00
defaultOpacity: dexieSettingStore('defaultOpacity', 0.7),
2024-06-19 16:15:21 +02:00
defaultWeight: dexieSettingStore('defaultWeight', 5),
2024-06-25 16:06:11 +02:00
bottomPanelSize: dexieSettingStore('bottomPanelSize', 192),
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
2024-05-05 18:59:09 +02:00
};
2024-05-04 14:27:12 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
function dexieStore<T>(querier: () => T | Promise<T>, initial?: T): Readable<T> {
let store = writable<T>(initial);
2024-05-03 15:59:34 +02:00
liveQuery(querier).subscribe(value => {
if (value !== undefined) {
2024-05-04 14:27:12 +02:00
store.set(value);
2024-05-03 15:59:34 +02:00
}
});
2024-05-02 19:51:08 +02:00
return {
2024-05-04 14:27:12 +02:00
subscribe: store.subscribe,
2024-05-03 15:59:34 +02:00
};
}
2024-05-22 16:05:31 +02:00
export class GPXStatisticsTree {
level: ListLevel;
statistics: {
[key: number]: GPXStatisticsTree | GPXStatistics;
} = {};
constructor(element: GPXFile | Track) {
if (element instanceof GPXFile) {
2024-05-24 13:16:41 +02:00
this.level = ListLevel.FILE;
2024-05-22 16:05:31 +02:00
element.children.forEach((child, index) => {
this.statistics[index] = new GPXStatisticsTree(child);
});
} else {
2024-05-24 13:16:41 +02:00
this.level = ListLevel.TRACK;
2024-05-22 16:05:31 +02:00
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 };
2024-05-08 21:31:54 +02:00
2024-05-04 14:27:12 +02:00
// 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
2024-05-15 11:47:42 +02:00
function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { destroy: () => void } {
2024-05-08 21:31:54 +02:00
let store = writable<GPXFileWithStatistics>(undefined);
2024-05-15 11:47:42 +02:00
let query = liveQuery(() => db.files.get(id)).subscribe(value => {
2024-05-03 15:59:34 +02:00
if (value !== undefined) {
2024-05-04 14:27:12 +02:00
let gpx = new GPXFile(value);
2024-05-23 12:57:24 +02:00
updateAnchorPoints(gpx);
2024-05-22 16:05:31 +02:00
let statistics = new GPXStatisticsTree(gpx);
2024-05-15 11:47:42 +02:00
if (!fileState.has(id)) { // Update the map bounds for new files
2024-05-22 16:05:31 +02:00
updateTargetMapBounds(statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
2024-05-08 21:31:54 +02:00
}
2024-05-22 16:05:31 +02:00
2024-05-15 11:47:42 +02:00
fileState.set(id, gpx);
2024-05-08 21:31:54 +02:00
store.set({
file: gpx,
statistics
});
2024-05-02 19:51:08 +02:00
}
2024-05-03 15:59:34 +02:00
});
return {
2024-05-09 00:02:27 +02:00
subscribe: store.subscribe,
2024-05-15 11:47:42 +02:00
destroy: () => {
fileState.delete(id);
query.unsubscribe();
}
2024-05-03 15:59:34 +02:00
};
}
2024-06-08 17:19:22 +02:00
// Commit the changes to the file state to the database
function commitFileStateChange(newFileState: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
let changedFileIds = getChangedFileIds(patch);
let updatedFileIds: string[] = [], deletedFileIds: string[] = [];
changedFileIds.forEach(id => {
if (newFileState.has(id)) {
updatedFileIds.push(id);
} else {
deletedFileIds.push(id);
}
2024-06-05 21:08:01 +02:00
});
2024-05-02 19:51:08 +02:00
2024-06-08 17:19:22 +02:00
let updatedFiles = updatedFileIds.map(id => newFileState.get(id)).filter(file => file !== undefined) as GPXFile[];
updatedFileIds = updatedFiles.map(file => file._data.id);
2024-05-03 15:59:34 +02:00
return db.transaction('rw', db.fileids, db.files, async () => {
2024-06-08 17:19:22 +02:00
if (updatedFileIds.length > 0) {
await db.fileids.bulkPut(updatedFileIds, updatedFileIds);
await db.files.bulkPut(updatedFiles, updatedFileIds);
}
if (deletedFileIds.length > 0) {
await db.fileids.bulkDelete(deletedFileIds);
await db.files.bulkDelete(deletedFileIds);
}
2024-05-03 15:59:34 +02:00
});
}
2024-05-09 00:02:27 +02:00
export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics | undefined> & { destroy: () => void }>> = writable(new Map());
2024-05-03 17:37:34 +02:00
const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
2024-05-02 19:51:08 +02:00
2024-05-04 14:27:12 +02:00
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files
2024-05-03 15:59:34 +02:00
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
2024-05-02 19:51:08 +02:00
// Find new files to observe
2024-05-07 12:36:54 +02:00
let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id)).sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
2024-05-02 19:51:08 +02:00
// Find deleted files to stop observing
2024-05-03 15:59:34 +02:00
let deletedFiles = Array.from(get(fileObservers).keys()).filter(id => !dbFileIds.find(fileId => fileId === id));
2024-05-08 21:31:54 +02:00
2024-05-02 19:51:08 +02:00
// Update the store
if (newFiles.length > 0 || deletedFiles.length > 0) {
fileObservers.update($files => {
2024-05-23 16:35:20 +02:00
if (newFiles.length > 0) { // Reset the target map bounds when new files are added
initTargetMapBounds($files.size === 0);
}
2024-05-02 19:51:08 +02:00
newFiles.forEach(id => {
2024-05-15 11:47:42 +02:00
$files.set(id, dexieGPXFileStore(id));
2024-05-02 19:51:08 +02:00
});
deletedFiles.forEach(id => {
2024-05-09 00:02:27 +02:00
$files.get(id)?.destroy();
2024-05-02 19:51:08 +02:00
$files.delete(id);
});
return $files;
});
2024-05-23 16:35:20 +02:00
if (deletedFiles.length > 0) {
selection.update(($selection) => {
deletedFiles.forEach((fileId) => {
$selection.deleteChild(fileId);
});
return $selection;
});
}
2024-06-05 21:08:01 +02:00
settings.fileOrder.update((order) => {
newFiles.forEach((fileId) => {
if (!order.includes(fileId)) {
order.push(fileId);
}
});
deletedFiles.forEach((fileId) => {
let index = order.indexOf(fileId);
if (index !== -1) {
order.splice(index, 1);
}
});
return order;
});
2024-05-02 19:51:08 +02:00
}
});
2024-06-15 18:44:17 +02:00
export function getFile(fileId: string): GPXFile | undefined {
let fileStore = get(fileObservers).get(fileId);
return fileStore ? get(fileStore)?.file : undefined;
}
export function getStatistics(fileId: string): GPXStatisticsTree | undefined {
let fileStore = get(fileObservers).get(fileId);
return fileStore ? get(fileStore)?.statistics : undefined;
}
2024-05-03 15:59:34 +02:00
const patchIndex: Readable<number> = dexieStore(() => db.settings.get('patchIndex'), -1);
2024-05-07 15:09:44 +02:00
const patchMinMaxIndex: Readable<{ min: number, max: number }> = dexieStore(() => db.patches.orderBy(':id').keys().then(keys => {
if (keys.length === 0) {
return { min: 0, max: 0 };
} else {
return { min: keys[0], max: keys[keys.length - 1] + 1 };
}
}), { min: 0, max: 0 });
2024-05-07 13:19:02 +02:00
export const canUndo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex >= $patchMinMaxIndex.min);
2024-05-07 15:09:44 +02:00
export const canRedo: Readable<boolean> = derived([patchIndex, patchMinMaxIndex], ([$patchIndex, $patchMinMaxIndex]) => $patchIndex < $patchMinMaxIndex.max - 1);
2024-05-02 19:51:08 +02:00
2024-05-04 14:27:12 +02:00
// Helper function to apply a callback to the global file state
function applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
2024-05-23 12:57:24 +02:00
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, callback);
2024-05-02 19:51:08 +02:00
2024-05-04 14:27:12 +02:00
storePatches(patch, inversePatch);
2024-05-02 19:51:08 +02:00
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Helper function to apply a callback to multiple files
2024-05-15 15:30:02 +02:00
function applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>) => GPXFile) {
2024-05-23 12:57:24 +02:00
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, (draft) => {
2024-05-02 19:51:08 +02:00
fileIds.forEach((fileId) => {
2024-05-03 15:59:34 +02:00
let file = draft.get(fileId);
if (file) {
2024-05-15 15:30:02 +02:00
draft.set(fileId, castDraft(callback(file)));
2024-05-03 15:59:34 +02:00
}
2024-05-02 19:51:08 +02:00
});
});
2024-05-04 14:27:12 +02:00
storePatches(patch, inversePatch);
2024-05-02 19:51:08 +02:00
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
2024-05-02 19:51:08 +02:00
}
2024-06-04 16:11:47 +02:00
// Helper function to apply different callbacks to multiple files
2024-06-05 21:08:01 +02:00
function applyEachToFilesAndGlobal(fileIds: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => GPXFile)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) {
2024-06-04 16:11:47 +02:00
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, (draft) => {
fileIds.forEach((fileId, index) => {
let file = draft.get(fileId);
if (file) {
draft.set(fileId, castDraft(callbacks[index](file, context)));
}
});
2024-06-05 21:08:01 +02:00
globalCallback(draft, context);
2024-06-04 16:11:47 +02:00
});
storePatches(patch, inversePatch);
return commitFileStateChange(newFileState, patch);
}
2024-05-07 15:09:44 +02:00
const MAX_PATCHES = 100;
2024-05-04 14:27:12 +02:00
// Store the new patches in the database
async function storePatches(patch: Patch[], inversePatch: Patch[]) {
2024-05-03 15:59:34 +02:00
if (get(patchIndex) !== undefined) {
2024-05-07 13:19:02 +02:00
db.patches.where(':id').above(get(patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
2024-05-07 15:09:44 +02:00
let minmax = get(patchMinMaxIndex);
if (minmax.max - minmax.min + 1 > MAX_PATCHES) {
db.patches.where(':id').belowOrEqual(get(patchMinMaxIndex).max - MAX_PATCHES).delete();
}
2024-05-03 15:59:34 +02:00
}
db.transaction('rw', db.patches, db.settings, async () => {
2024-05-07 13:19:02 +02:00
let index = get(patchIndex) + 1;
2024-05-03 15:59:34 +02:00
await db.patches.put({
patch,
2024-05-07 13:19:02 +02:00
inversePatch,
index
}, index);
await db.settings.put(index, 'patchIndex');
2024-05-02 19:51:08 +02:00
});
}
2024-05-04 14:27:12 +02:00
// Apply a patch to the file state
2024-05-02 19:51:08 +02:00
function applyPatch(patch: Patch[]) {
let newFileState = applyPatches(fileState, patch);
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
}
2024-05-04 14:27:12 +02:00
// Get the file ids of the files that have changed in the patch
2024-05-07 18:14:47 +02:00
function getChangedFileIds(patch: Patch[]): string[] {
2024-05-15 11:47:42 +02:00
let changedFileIds = new Set<string>();
2024-05-02 19:51:08 +02:00
for (let p of patch) {
2024-05-07 18:14:47 +02:00
changedFileIds.add(p.path[0]);
2024-05-02 19:51:08 +02:00
}
2024-05-07 18:14:47 +02:00
return Array.from(changedFileIds);
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Generate unique file ids, different from the ones in the database
2024-06-05 21:08:01 +02:00
export function getFileIds(n: number) {
2024-05-03 15:59:34 +02:00
let ids = [];
for (let index = 0; ids.length < n; index++) {
2024-05-02 19:51:08 +02:00
let id = `gpx-${index}`;
if (!get(fileObservers).has(id)) {
2024-05-03 15:59:34 +02:00
ids.push(id);
2024-05-02 19:51:08 +02:00
}
}
2024-05-03 15:59:34 +02:00
return ids;
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Helper functions for file operations
2024-05-02 19:51:08 +02:00
export const dbUtils = {
add: (file: GPXFile) => {
if (file._data.id === undefined) {
file._data.id = getFileIds(1)[0];
}
2024-05-03 15:59:34 +02:00
return applyGlobal((draft) => {
2024-05-23 12:57:24 +02:00
draft.set(file._data.id, freeze(file));
2024-05-02 19:51:08 +02:00
});
},
addMultiple: (files: GPXFile[]) => {
2024-05-03 15:59:34 +02:00
return applyGlobal((draft) => {
let ids = getFileIds(files.length);
files.forEach((file, index) => {
file._data.id = ids[index];
2024-05-23 12:57:24 +02:00
draft.set(file._data.id, freeze(file));
2024-05-02 19:51:08 +02:00
});
});
},
2024-05-15 15:30:02 +02:00
applyToFile: (id: string, callback: (file: WritableDraft<GPXFile>) => GPXFile) => {
2024-05-02 19:51:08 +02:00
applyToFiles([id], callback);
},
applyToFiles: (ids: string[], callback: (file: WritableDraft<GPXFile>) => GPXFile) => {
applyToFiles(ids, callback);
},
2024-06-05 21:08:01 +02:00
applyEachToFilesAndGlobal: (ids: string[], callbacks: ((file: WritableDraft<GPXFile>, context?: any) => GPXFile)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) => {
applyEachToFilesAndGlobal(ids, callbacks, globalCallback, context);
2024-06-04 16:11:47 +02:00
},
2024-05-24 17:23:26 +02:00
duplicateSelection: () => {
if (get(selection).size === 0) {
return;
}
applyGlobal((draft) => {
let ids = getFileIds(get(settings.fileOrder).length);
let index = 0;
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
if (level === ListLevel.FILE) {
newFile = file.clone();
newFile._data.id = ids[index++];
} else if (level === ListLevel.TRACK) {
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceTracks(trackIndex + 1, trackIndex, [file.trk[trackIndex].clone()]);
newFile = result;
2024-05-24 17:23:26 +02:00
}
} else if (level === ListLevel.SEGMENT) {
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceTrackSegments(trackIndex, segmentIndex + 1, segmentIndex, [file.trk[trackIndex].trkseg[segmentIndex].clone()]);
newFile = result;
2024-05-24 17:23:26 +02:00
}
} else if (level === ListLevel.WAYPOINT) {
for (let item of items) {
let waypointIndex = (item as ListWaypointItem).getWaypointIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceWaypoints(waypointIndex + 1, waypointIndex, [file.wpt[waypointIndex].clone()]);
newFile = result;
2024-05-24 17:23:26 +02:00
}
}
draft.set(newFile._data.id, freeze(newFile));
}
});
});
},
2024-05-24 16:37:26 +02:00
reverseSelection: () => {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
return;
}
applyGlobal((draft) => {
2024-05-23 16:35:20 +02:00
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
2024-05-23 14:44:07 +02:00
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
2024-05-24 13:16:41 +02:00
if (level === ListLevel.FILE) {
2024-05-24 16:37:26 +02:00
newFile = file.reverse();
2024-05-24 13:16:41 +02:00
} else if (level === ListLevel.TRACK) {
2024-05-23 14:44:07 +02:00
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
2024-05-24 16:37:26 +02:00
newFile = newFile.reverseTrack(trackIndex);
2024-05-23 14:44:07 +02:00
}
2024-05-24 13:16:41 +02:00
} else if (level === ListLevel.SEGMENT) {
2024-05-23 14:44:07 +02:00
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
2024-05-24 16:37:26 +02:00
newFile = newFile.reverseTrackSegment(trackIndex, segmentIndex);
2024-05-23 14:44:07 +02:00
}
2024-05-02 19:51:08 +02:00
}
2024-05-23 16:35:20 +02:00
draft.set(newFile._data.id, freeze(newFile));
2024-05-02 19:51:08 +02:00
}
});
});
},
2024-06-08 17:19:22 +02:00
mergeSelection: (mergeTraces: boolean) => {
applyGlobal((draft) => {
let first = true;
let target: ListItem = new ListRootItem();
let targetFile: GPXFile | undefined = undefined;
let toMerge: {
trk: Track[],
trkseg: TrackSegment[],
wpt: Waypoint[]
} = {
trk: [],
trkseg: [],
wpt: []
};
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
if (level === ListLevel.FILE) {
{
let [result, removed] = newFile.replaceTracks(0, newFile.trk.length - 1, []);
toMerge.trk.push(...removed);
newFile = result;
}
{
let [result, removed] = newFile.replaceWaypoints(0, newFile.wpt.length - 1, []);
toMerge.wpt.push(...removed);
newFile = result;
}
if (first) {
target = items[0];
targetFile = newFile;
} else {
draft.delete(fileId);
}
} else {
if (level === ListLevel.TRACK) {
items.forEach((item, index) => {
let trackIndex = (item as ListTrackItem).getTrackIndex();
if (index === items.length - 1) { // Order is reversed, so the last track is the first one and the one to keep
let [result, removed] = newFile.replaceTrackSegments(trackIndex, 0, newFile.trk[trackIndex].trkseg.length - 1, []);
toMerge.trkseg.splice(0, 0, ...removed);
newFile = result;
target = item;
} else {
let [result, removed] = newFile.replaceTracks(trackIndex, trackIndex, []);
toMerge.trkseg.push(...removed[0].trkseg);
newFile = result;
}
});
} else if (level === ListLevel.SEGMENT) {
items.forEach((item, index) => {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
if (index === items.length - 1) { // Order is reversed, so the last segment is the first one and the one to keep
target = item;
}
let [result, removed] = newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, []);
toMerge.trkseg.splice(0, 0, ...removed);
newFile = result;
});
}
if (first) {
targetFile = newFile;
} else {
draft.set(fileId, freeze(newFile));
}
}
first = false;
}
});
if (mergeTraces) {
2024-06-18 12:35:24 +02:00
let statistics = get(gpxStatistics);
let speed = statistics.global.speed.moving > 0 ? statistics.global.speed.moving : undefined;
let startTime: Date | undefined = undefined;
if (speed !== undefined) {
if (statistics.local.points.length > 0 && statistics.local.points[0].time !== undefined) {
startTime = statistics.local.points[0].time;
} else {
let index = statistics.local.points.findIndex((point) => point.time !== undefined);
if (index !== -1) {
startTime = new Date(statistics.local.points[index].time.getTime() - 1000 * 3600 * statistics.local.distance.total[index] / speed);
}
}
}
2024-06-08 17:19:22 +02:00
if (toMerge.trk.length > 0) {
2024-06-17 13:33:58 +02:00
let s = new TrackSegment();
toMerge.trk.map((track) => {
2024-06-08 17:19:22 +02:00
track.trkseg.forEach((segment) => {
2024-06-18 12:35:24 +02:00
s = s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime);
2024-06-08 17:19:22 +02:00
});
});
2024-06-17 13:33:58 +02:00
toMerge.trk = [toMerge.trk[0].replaceTrackSegments(0, toMerge.trk[0].trkseg.length - 1, [s])[0]];
2024-06-08 17:19:22 +02:00
}
if (toMerge.trkseg.length > 0) {
2024-06-17 13:33:58 +02:00
let s = new TrackSegment();
2024-06-08 17:19:22 +02:00
toMerge.trkseg.forEach((segment) => {
2024-06-18 12:35:24 +02:00
s = s.replaceTrackPoints(s.trkpt.length, s.trkpt.length, segment.trkpt.slice(), speed, startTime);
2024-06-08 17:19:22 +02:00
});
2024-06-17 13:33:58 +02:00
toMerge.trkseg = [s];
2024-06-08 17:19:22 +02:00
}
}
if (targetFile) {
if (target instanceof ListFileItem) {
targetFile = targetFile.replaceTracks(0, targetFile.trk.length - 1, toMerge.trk)[0];
targetFile = targetFile.replaceWaypoints(0, targetFile.wpt.length - 1, toMerge.wpt)[0];
} else if (target instanceof ListTrackItem) {
let trackIndex = target.getTrackIndex();
targetFile = targetFile.replaceTrackSegments(trackIndex, 0, -1, toMerge.trkseg)[0];
} else if (target instanceof ListTrackSegmentItem) {
let trackIndex = target.getTrackIndex();
let segmentIndex = target.getSegmentIndex();
targetFile = targetFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex - 1, toMerge.trkseg)[0];
}
draft.set(targetFile._data.id, freeze(targetFile));
}
});
},
2024-06-10 20:03:57 +02:00
cropSelection: (start: number, end: number) => {
if (get(selection).size === 0) {
return;
}
applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
let length = file.getNumberOfTrackPoints();
if (start >= length || end < 0) {
draft.delete(fileId);
} else if (start > 0 || end < length - 1) {
let newFile = file.crop(Math.max(0, start), Math.min(length - 1, end));
draft.set(newFile._data.id, freeze(newFile));
}
start -= length;
end -= length;
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
let newFile = file.crop(start, end, trackIndices);
draft.set(newFile._data.id, freeze(newFile));
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
let newFile = file.crop(start, end, trackIndices, segmentIndices);
draft.set(newFile._data.id, freeze(newFile));
}
}
}, false);
});
},
2024-06-15 18:44:17 +02:00
extractSelection: () => {
return applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
if (file.trk.length > 1) {
let fileIds = getFileIds(file.trk.length);
let closest = file.wpt.map((wpt, wptIndex) => {
return {
wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE
};
})
file.trk.forEach((track, index) => {
track.getSegments().forEach((segment) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(point.getCoordinates(), wpt.getCoordinates());
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
} else if (dist === closest[wptIndex].distance) {
closest[wptIndex].index.push(index);
}
});
})
});
});
file.trk.forEach((track, index) => {
let tracks = track.trkseg.map((segment, segmentIndex) => {
let t = track.replaceTrackSegments(0, track.trkseg.length - 1, [segment])[0];
if (track.name) {
t.name = `${track.name} (${segmentIndex + 1})`;
}
return t;
});
let newFile = file.replaceTracks(0, file.trk.length - 1, tracks)[0];
newFile = newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]))[0];
newFile = produce(newFile, (f) => {
f._data.id = fileIds[index];
f.metadata.name = track.name ?? `${file.metadata.name} (${index + 1})`;
});
draft.set(newFile._data.id, freeze(newFile));
});
} else if (file.trk.length === 1) {
let fileIds = getFileIds(file.trk[0].trkseg.length);
let closest = file.wpt.map((wpt, wptIndex) => {
return {
wptIndex: wptIndex,
index: [0],
distance: Number.MAX_VALUE
};
})
file.trk[0].trkseg.forEach((segment, index) => {
segment.trkpt.forEach((point) => {
file.wpt.forEach((wpt, wptIndex) => {
let dist = distance(point.getCoordinates(), wpt.getCoordinates());
if (dist < closest[wptIndex].distance) {
closest[wptIndex].distance = dist;
closest[wptIndex].index = [index];
} else if (dist === closest[wptIndex].distance) {
closest[wptIndex].index.push(index);
}
});
});
});
file.trk[0].trkseg.forEach((segment, index) => {
let newFile = file.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [segment])[0];
newFile = newFile.replaceWaypoints(0, file.wpt.length - 1, closest.filter((c) => c.index.includes(index)).map((c) => file.wpt[c.wptIndex]))[0];
newFile = produce(newFile, (f) => {
f._data.id = fileIds[index];
f.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
});
draft.set(newFile._data.id, freeze(newFile));
});
}
draft.delete(fileId);
} else if (level === ListLevel.TRACK) {
let newFile = file;
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
let track = file.trk[trackIndex];
let tracks = track.trkseg.map((segment, segmentIndex) => {
let t = track.clone().replaceTrackSegments(0, track.trkseg.length - 1, [segment])[0];
if (track.name) {
t.name = `${track.name} (${segmentIndex + 1})`;
}
return t;
});
newFile = newFile.replaceTracks(trackIndex, trackIndex, tracks)[0];
}
draft.set(newFile._data.id, freeze(newFile));
}
}
});
});
},
2024-06-10 20:03:57 +02:00
split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates) {
let splitType = get(splitAs);
return applyGlobal((draft) => {
let file = original(draft)?.get(fileId);
if (file) {
let segment = file.trk[trackIndex].trkseg[segmentIndex];
// Find the point closest to split
let minDistance = Number.MAX_VALUE;
let minIndex = 0;
for (let i = 0; i < segment.trkpt.length; i++) {
let dist = distance(segment.trkpt[i].getCoordinates(), coordinates);
if (dist < minDistance) {
minDistance = dist;
minIndex = i;
}
}
let absoluteIndex = minIndex;
file.forEachSegment((seg, trkIndex, segIndex) => {
if ((trkIndex < trackIndex && splitType === SplitType.FILES) || (trkIndex === trackIndex && segIndex < segmentIndex)) {
absoluteIndex += seg.trkpt.length;
}
});
if (splitType === SplitType.FILES) {
let newFile = file.crop(0, absoluteIndex);
draft.set(newFile._data.id, freeze(newFile));
let newFile2 = file.clone();
newFile2._data.id = getFileIds(1)[0];
newFile2 = newFile2.crop(absoluteIndex, file.getNumberOfTrackPoints() - 1);
draft.set(newFile2._data.id, freeze(newFile2));
} else if (splitType === SplitType.TRACKS) {
let newFile = file.replaceTracks(trackIndex, trackIndex, [file.trk[trackIndex].crop(0, absoluteIndex), file.trk[trackIndex].crop(absoluteIndex, file.trk[trackIndex].getNumberOfTrackPoints() - 1)])[0];
draft.set(newFile._data.id, freeze(newFile));
} else if (splitType === SplitType.SEGMENTS) {
let newFile = file.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [segment.crop(0, minIndex), segment.crop(minIndex, segment.trkpt.length - 1)])[0];
draft.set(newFile._data.id, freeze(newFile));
}
}
});
},
2024-06-11 16:33:06 +02:00
cleanSelection: (bounds: [Coordinates, Coordinates], inside: boolean, deleteTrackPoints: boolean, deleteWaypoints: boolean) => {
if (get(selection).size === 0) {
return;
}
applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
if (level === ListLevel.FILE) {
newFile = file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints);
} else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
newFile = newFile.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices);
2024-06-11 16:33:06 +02:00
} else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
newFile = newFile.clean(bounds, inside, deleteTrackPoints, deleteWaypoints, trackIndices, segmentIndices);
2024-06-11 16:33:06 +02:00
} else if (level === ListLevel.WAYPOINTS) {
newFile = newFile.clean(bounds, inside, false, deleteWaypoints);
} else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
newFile = newFile.clean(bounds, inside, false, deleteWaypoints, [], [], waypointIndices);
}
draft.set(newFile._data.id, freeze(newFile));
2024-06-11 19:08:46 +02:00
}
});
});
},
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
if (itemsAndPoints.size === 0) {
return;
}
applyGlobal((draft) => {
let allItems = Array.from(itemsAndPoints.keys());
applyToOrderedItemsFromFile(allItems, (fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
for (let item of items) {
if (item instanceof ListTrackSegmentItem) {
let trackIndex = item.getTrackIndex();
let segmentIndex = item.getSegmentIndex();
let points = itemsAndPoints.get(item);
if (points) {
newFile = newFile.replaceTrackPoints(trackIndex, segmentIndex, 0, file.trk[trackIndex].trkseg[segmentIndex].getNumberOfTrackPoints() - 1, points);
}
}
}
draft.set(newFile._data.id, freeze(newFile));
2024-06-19 16:15:21 +02:00
}
});
});
},
setStyleToSelection: (style: LineStyleExtension) => {
if (get(selection).size === 0) {
return;
}
applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
if (level === ListLevel.FILE) {
newFile = file.setStyle(style);
} else if (level === ListLevel.TRACK) {
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
newFile = newFile.replaceTracks(trackIndex, trackIndex, [file.trk[trackIndex].setStyle(style)])[0];
}
if (items.length === file.trk.length) {
newFile = newFile.setStyle(style);
}
}
draft.set(newFile._data.id, freeze(newFile));
2024-06-11 16:33:06 +02:00
}
});
});
},
2024-05-22 16:05:31 +02:00
deleteSelection: () => {
2024-05-23 16:35:20 +02:00
if (get(selection).size === 0) {
return;
}
2024-05-02 19:51:08 +02:00
applyGlobal((draft) => {
2024-05-23 16:35:20 +02:00
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
2024-05-24 13:16:41 +02:00
if (level === ListLevel.FILE) {
2024-05-23 16:35:20 +02:00
draft.delete(fileId);
} else {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = file;
2024-05-24 13:16:41 +02:00
if (level === ListLevel.TRACK) {
2024-05-23 16:35:20 +02:00
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceTracks(trackIndex, trackIndex, []);
newFile = result;
2024-05-23 16:35:20 +02:00
}
2024-05-24 13:16:41 +02:00
} else if (level === ListLevel.SEGMENT) {
2024-05-23 16:35:20 +02:00
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, []);
newFile = result;
2024-05-23 16:35:20 +02:00
}
2024-05-24 13:16:41 +02:00
} else if (level === ListLevel.WAYPOINTS) {
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceWaypoints(0, newFile.wpt.length - 1, []);
newFile = result;
2024-05-24 13:16:41 +02:00
} else if (level === ListLevel.WAYPOINT) {
2024-05-23 16:35:20 +02:00
for (let item of items) {
let waypointIndex = (item as ListWaypointItem).getWaypointIndex();
2024-06-04 16:11:47 +02:00
let [result, _removed] = newFile.replaceWaypoints(waypointIndex, waypointIndex, []);
newFile = result;
2024-05-23 16:35:20 +02:00
}
}
draft.set(newFile._data.id, freeze(newFile));
2024-05-23 11:21:57 +02:00
}
2024-05-23 16:35:20 +02:00
}
2024-05-02 19:51:08 +02:00
});
});
},
deleteAllFiles: () => {
applyGlobal((draft) => {
draft.clear();
});
},
2024-05-04 14:27:12 +02:00
// undo-redo
undo: () => {
if (get(canUndo)) {
let index = get(patchIndex);
db.patches.get(index).then(patch => {
if (patch) {
applyPatch(patch.inversePatch);
db.settings.put(index - 1, 'patchIndex');
}
});
}
},
redo: () => {
if (get(canRedo)) {
let index = get(patchIndex) + 1;
db.patches.get(index).then(patch => {
if (patch) {
applyPatch(patch.patch);
db.settings.put(index, 'patchIndex');
}
});
}
}
2024-05-05 18:59:09 +02:00
}