mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-12-03 10:22:13 +00:00
progress
This commit is contained in:
206
website/src/lib/logic/file-action-manager.svelte.ts
Normal file
206
website/src/lib/logic/file-action-manager.svelte.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { db, type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { applyPatches, produceWithPatches, type Patch, type WritableDraft } from 'immer';
|
||||
import { fileStateCollection, type GPXFileStateCollection } from '$lib/logic/file-state.svelte';
|
||||
|
||||
const MAX_PATCHES = 100;
|
||||
|
||||
export class FileActionManager {
|
||||
private _db: Database;
|
||||
private _files: Map<string, GPXFile>;
|
||||
private _patchIndex: number;
|
||||
private _patchMinIndex: number;
|
||||
private _patchMaxIndex: number;
|
||||
|
||||
constructor(db: Database, fileStateCollection: GPXFileStateCollection) {
|
||||
this._db = db;
|
||||
|
||||
this._files = $derived.by(() => {
|
||||
let files = new Map<string, GPXFile>();
|
||||
fileStateCollection.files.forEach((state, id) => {
|
||||
if (state.file) {
|
||||
files.set(id, state.file);
|
||||
}
|
||||
});
|
||||
return files;
|
||||
});
|
||||
|
||||
this._patchIndex = $state(-1);
|
||||
this._patchMinIndex = $state(0);
|
||||
this._patchMaxIndex = $state(0);
|
||||
|
||||
liveQuery(() => db.settings.get('patchIndex')).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
this._patchIndex = value;
|
||||
}
|
||||
});
|
||||
liveQuery(() =>
|
||||
(db.patches.orderBy(':id').keys() as Promise<number[]>).then((keys) => {
|
||||
if (keys.length === 0) {
|
||||
return { min: 0, max: 0 };
|
||||
} else {
|
||||
return { min: keys[0], max: keys[keys.length - 1] + 1 };
|
||||
}
|
||||
})
|
||||
).subscribe((value) => {
|
||||
this._patchMinIndex = value.min;
|
||||
this._patchMaxIndex = value.max;
|
||||
});
|
||||
}
|
||||
|
||||
async store(patch: Patch[], inversePatch: Patch[]) {
|
||||
this._db.patches.where(':id').above(this._patchIndex).delete(); // Delete all patches after the current patch to avoid redoing them
|
||||
if (this._patchMaxIndex - this._patchMinIndex + 1 > MAX_PATCHES) {
|
||||
this._db.patches
|
||||
.where(':id')
|
||||
.belowOrEqual(this._patchMaxIndex - MAX_PATCHES)
|
||||
.delete();
|
||||
}
|
||||
this._db.transaction('rw', this._db.patches, this._db.settings, async () => {
|
||||
let index = this._patchIndex + 1;
|
||||
await this._db.patches.put(
|
||||
{
|
||||
patch,
|
||||
inversePatch,
|
||||
index,
|
||||
},
|
||||
index
|
||||
);
|
||||
await this._db.settings.put(index, 'patchIndex');
|
||||
});
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this._patchIndex >= this._patchMinIndex;
|
||||
}
|
||||
|
||||
get canRedo(): boolean {
|
||||
return this._patchIndex < this._patchMaxIndex - 1;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.canUndo) {
|
||||
this._db.patches.get(this._patchIndex).then((patch) => {
|
||||
if (patch) {
|
||||
this.apply(patch.inversePatch);
|
||||
this._db.settings.put(this._patchIndex - 1, 'patchIndex');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this.canRedo) {
|
||||
this._db.patches.get(this._patchIndex + 1).then((patch) => {
|
||||
if (patch) {
|
||||
this.apply(patch.patch);
|
||||
this._db.settings.put(this._patchIndex + 1, 'patchIndex');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apply(patch: Patch[]) {
|
||||
let newFiles = applyPatches(this._files, patch);
|
||||
return this.commitFileStateChange(newFiles, patch);
|
||||
}
|
||||
|
||||
commitFileStateChange(newFiles: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
|
||||
let changedFileIds = getChangedFileIds(patch);
|
||||
let updatedFileIds: string[] = [],
|
||||
deletedFileIds: string[] = [];
|
||||
|
||||
changedFileIds.forEach((id) => {
|
||||
if (newFiles.has(id)) {
|
||||
updatedFileIds.push(id);
|
||||
} else {
|
||||
deletedFileIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
let updatedFiles = updatedFileIds
|
||||
.map((id) => newFiles.get(id))
|
||||
.filter((file) => file !== undefined) as GPXFile[];
|
||||
updatedFileIds = updatedFiles.map((file) => file._data.id);
|
||||
|
||||
// updateSelection(updatedFiles, deletedFileIds);
|
||||
|
||||
// @ts-ignore
|
||||
return db.transaction('rw', db.fileids, db.files, async () => {
|
||||
if (updatedFileIds.length > 0) {
|
||||
await this._db.fileids.bulkPut(updatedFileIds, updatedFileIds);
|
||||
await this._db.files.bulkPut(updatedFiles, updatedFileIds);
|
||||
}
|
||||
if (deletedFileIds.length > 0) {
|
||||
await this._db.fileids.bulkDelete(deletedFileIds);
|
||||
await this._db.files.bulkDelete(deletedFileIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(this._files, callback);
|
||||
|
||||
this.store(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
|
||||
applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>) => void) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(
|
||||
this._files,
|
||||
(draft) => {
|
||||
fileIds.forEach((fileId) => {
|
||||
let file = draft.get(fileId);
|
||||
if (file) {
|
||||
callback(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.store(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
|
||||
applyToFile(fileId: string, callback: (file: WritableDraft<GPXFile>) => void) {
|
||||
return this.applyToFiles([fileId], callback);
|
||||
}
|
||||
|
||||
applyEachToFilesAndGlobal(
|
||||
fileIds: string[],
|
||||
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
|
||||
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
|
||||
context?: any
|
||||
) {
|
||||
const [newFileCollection, patch, inversePatch] = produceWithPatches(
|
||||
this._files,
|
||||
(draft) => {
|
||||
fileIds.forEach((fileId, index) => {
|
||||
let file = draft.get(fileId);
|
||||
if (file) {
|
||||
callbacks[index](file, context);
|
||||
}
|
||||
});
|
||||
globalCallback(draft, context);
|
||||
}
|
||||
);
|
||||
|
||||
this.store(patch, inversePatch);
|
||||
|
||||
return this.commitFileStateChange(newFileCollection, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file ids of the files that have changed in the patch
|
||||
function getChangedFileIds(patch: Patch[]): string[] {
|
||||
let changedFileIds = new Set<string>();
|
||||
for (let p of patch) {
|
||||
changedFileIds.add(p.path[0] as string);
|
||||
}
|
||||
return Array.from(changedFileIds);
|
||||
}
|
||||
|
||||
export const fileActionManager = new FileActionManager(db, fileStateCollection);
|
||||
1161
website/src/lib/logic/file-actions.svelte.ts
Normal file
1161
website/src/lib/logic/file-actions.svelte.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
website/src/lib/logic/file-state.svelte.ts
Normal file
128
website/src/lib/logic/file-state.svelte.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
||||
import { db, type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { GPXFile } from 'gpx';
|
||||
import { GPXStatisticsTree, type GPXFileWithStatistics } from '$lib/logic/statistics';
|
||||
import { settings } from '$lib/logic/settings.svelte';
|
||||
|
||||
// Observe a single file from the database, and maintain its statistics
|
||||
class GPXFileState {
|
||||
private _file: GPXFileWithStatistics | undefined;
|
||||
private _subscription: { unsubscribe: () => void } | undefined;
|
||||
|
||||
constructor(db: Database, fileId: string) {
|
||||
this._file = $state(undefined);
|
||||
let first = true;
|
||||
|
||||
this._subscription = liveQuery(() => db.files.get(fileId)).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
let file = new GPXFile(value);
|
||||
updateAnchorPoints(file);
|
||||
|
||||
let statistics = new GPXStatisticsTree(file);
|
||||
if (first) {
|
||||
// Update the map bounds for new files
|
||||
// updateTargetMapBounds(
|
||||
// id,
|
||||
// statistics.getStatisticsFor(new ListFileItem(id)).global.bounds
|
||||
// );
|
||||
first = false;
|
||||
}
|
||||
|
||||
this._file = { file, statistics };
|
||||
|
||||
// if (get(selection).hasAnyChildren(new ListFileItem(id))) {
|
||||
// updateAllHidden();
|
||||
// }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._subscription?.unsubscribe();
|
||||
this._subscription = undefined;
|
||||
this._file = undefined;
|
||||
}
|
||||
|
||||
get file(): GPXFile | undefined {
|
||||
return this._file?.file;
|
||||
}
|
||||
|
||||
get statistics(): GPXStatisticsTree | undefined {
|
||||
return this._file?.statistics;
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the file ids in the database, and maintain a map of file states for the corresponding files
|
||||
export class GPXFileStateCollection {
|
||||
private _db: Database;
|
||||
private _files: Map<string, GPXFileState>;
|
||||
|
||||
constructor(db: Database) {
|
||||
this._db = db;
|
||||
this._files = $state(new Map());
|
||||
}
|
||||
|
||||
initialize(fitBounds: boolean) {
|
||||
let initialize = true;
|
||||
liveQuery(() => this._db.fileids.toArray()).subscribe((dbFileIds) => {
|
||||
if (initialize) {
|
||||
// if (fitBounds && dbFileIds.length > 0) {
|
||||
// initTargetMapBounds(dbFileIds);
|
||||
// }
|
||||
initialize = false;
|
||||
}
|
||||
// Find new files to observe
|
||||
let newFiles = dbFileIds
|
||||
.filter((id) => !this._files.has(id))
|
||||
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
|
||||
// Find deleted files to stop observing
|
||||
let deletedFiles = Array.from(this._files.keys()).filter(
|
||||
(id) => !dbFileIds.find((fileId) => fileId === id)
|
||||
);
|
||||
|
||||
if (newFiles.length > 0 || deletedFiles.length > 0) {
|
||||
// Update the map of file states
|
||||
let files = new Map(this._files);
|
||||
newFiles.forEach((id) => {
|
||||
files.set(id, new GPXFileState(this._db, id));
|
||||
});
|
||||
deletedFiles.forEach((id) => {
|
||||
files.get(id)?.destroy();
|
||||
files.delete(id);
|
||||
});
|
||||
this._files = files;
|
||||
|
||||
// Update the file order
|
||||
let fileOrder = settings.fileOrder.value.filter((id) => !deletedFiles.includes(id));
|
||||
newFiles.forEach((id) => {
|
||||
if (!fileOrder.includes(id)) {
|
||||
fileOrder.push(id);
|
||||
}
|
||||
});
|
||||
settings.fileOrder.value = fileOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get files(): ReadonlyMap<string, GPXFileState> {
|
||||
return this._files;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._files.size;
|
||||
}
|
||||
|
||||
getFile(fileId: string): GPXFile | undefined {
|
||||
let fileState = this._files.get(fileId);
|
||||
return fileState?.file;
|
||||
}
|
||||
|
||||
getStatistics(fileId: string): GPXStatisticsTree | undefined {
|
||||
let fileState = this._files.get(fileId);
|
||||
return fileState?.statistics;
|
||||
}
|
||||
}
|
||||
|
||||
// Collection of all file states
|
||||
export const fileStateCollection = new GPXFileStateCollection(db);
|
||||
@@ -1,40 +0,0 @@
|
||||
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
|
||||
import { GPXStatisticsTree, type Database } from '$lib/db';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { GPXFile } from 'gpx';
|
||||
|
||||
class GPXFileState {
|
||||
private _db: Database;
|
||||
private _file: GPXFile | undefined;
|
||||
|
||||
constructor(db: Database, fileId: string, file: GPXFile) {
|
||||
this._db = db;
|
||||
this._file = $state(undefined);
|
||||
|
||||
liveQuery(() => db.files.get(fileId)).subscribe((value) => {
|
||||
if (value !== undefined) {
|
||||
let gpx = new GPXFile(value);
|
||||
updateAnchorPoints(gpx);
|
||||
|
||||
let statistics = new GPXStatisticsTree(gpx);
|
||||
if (!fileState.has(id)) {
|
||||
// Update the map bounds for new files
|
||||
updateTargetMapBounds(
|
||||
id,
|
||||
statistics.getStatisticsFor(new ListFileItem(id)).global.bounds
|
||||
);
|
||||
}
|
||||
|
||||
fileState.set(id, gpx);
|
||||
store.set({
|
||||
file: gpx,
|
||||
statistics,
|
||||
});
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(id))) {
|
||||
updateAllHidden();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
@@ -9,223 +8,187 @@ import {
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
moveItems,
|
||||
} from './FileList';
|
||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
||||
} from '$lib/components/file-list/file-list';
|
||||
import { SelectionTreeType } from '$lib/logic/selection';
|
||||
import { fileStateCollection } from '$lib/logic/file-state.svelte';
|
||||
import { settings } from '$lib/logic/settings.svelte';
|
||||
import type { GPXFile } from 'gpx';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
export class Selection {
|
||||
private _selection: SelectionTreeType;
|
||||
private _copied: ListItem[] | undefined;
|
||||
private _cut: boolean;
|
||||
|
||||
constructor(item: ListItem) {
|
||||
this.item = item;
|
||||
this.selected = false;
|
||||
this.children = {};
|
||||
constructor() {
|
||||
this._selection = $state(new SelectionTreeType(new ListRootItem()));
|
||||
this._copied = $state(undefined);
|
||||
this._cut = $state(false);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selected = false;
|
||||
for (let key in this.children) {
|
||||
this.children[key].clear();
|
||||
}
|
||||
this.size = 0;
|
||||
get value(): SelectionTreeType {
|
||||
return this._selection;
|
||||
}
|
||||
|
||||
_setOrToggle(item: ListItem, value?: boolean) {
|
||||
if (item.level === this.item.level) {
|
||||
let newSelected = value === undefined ? !this.selected : value;
|
||||
if (this.selected !== newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.size += this.selected ? 1 : -1;
|
||||
}
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (!this.children.hasOwnProperty(id)) {
|
||||
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
||||
}
|
||||
this.size -= this.children[id].size;
|
||||
this.children[id]._setOrToggle(item, value);
|
||||
this.size += this.children[id].size;
|
||||
}
|
||||
}
|
||||
selectItem(item: ListItem) {
|
||||
let selection = new SelectionTreeType(new ListRootItem());
|
||||
selection.set(item, true);
|
||||
this._selection = selection;
|
||||
}
|
||||
|
||||
set(item: ListItem, value: boolean) {
|
||||
this._setOrToggle(item, value);
|
||||
selectFile(fileId: string) {
|
||||
this.selectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
toggle(item: ListItem) {
|
||||
this._setOrToggle(item);
|
||||
addSelectItem(item: ListItem) {
|
||||
this._selection.toggle(item);
|
||||
}
|
||||
|
||||
has(item: ListItem): boolean {
|
||||
if (item.level === this.item.level) {
|
||||
return this.selected;
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].has(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
addSelectFile(fileId: string) {
|
||||
this.addSelectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyParent(item, self);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let key in this.children) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
||||
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSelected(selection: ListItem[] = []): ListItem[] {
|
||||
if (this.selected) {
|
||||
selection.push(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].getSelected(selection);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
forEach(callback: (item: ListItem) => void) {
|
||||
if (this.selected) {
|
||||
callback(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].forEach(callback);
|
||||
}
|
||||
}
|
||||
|
||||
getChild(id: string | number): SelectionTreeType | undefined {
|
||||
return this.children[id];
|
||||
}
|
||||
|
||||
deleteChild(id: string | number) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
this.size -= this.children[id].size;
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||
|
||||
export function selectItem(item: ListItem) {
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
$selection.set(item, true);
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFile(fileId: string) {
|
||||
selectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
export function addSelectItem(item: ListItem) {
|
||||
selection.update(($selection) => {
|
||||
$selection.toggle(item);
|
||||
return $selection;
|
||||
});
|
||||
}
|
||||
|
||||
export function addSelectFile(fileId: string) {
|
||||
addSelectItem(new ListFileItem(fileId));
|
||||
}
|
||||
|
||||
export function selectAll() {
|
||||
selection.update(($selection) => {
|
||||
selectAll() {
|
||||
let item: ListItem = new ListRootItem();
|
||||
$selection.forEach((i) => {
|
||||
this._selection.forEach((i) => {
|
||||
item = i;
|
||||
});
|
||||
|
||||
let selection = new SelectionTreeType(new ListRootItem());
|
||||
if (item instanceof ListRootItem || item instanceof ListFileItem) {
|
||||
$selection.clear();
|
||||
get(fileObservers).forEach((_file, fileId) => {
|
||||
$selection.set(new ListFileItem(fileId), true);
|
||||
fileStateCollection.files.forEach((_file, fileId) => {
|
||||
selection.set(new ListFileItem(fileId), true);
|
||||
});
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk.forEach((_track, trackId) => {
|
||||
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
||||
selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(
|
||||
selection.set(
|
||||
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.wpt.forEach((_waypoint, waypointId) => {
|
||||
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
||||
selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
this._selection = selection;
|
||||
}
|
||||
|
||||
return $selection;
|
||||
});
|
||||
set(items: ListItem[]) {
|
||||
let selection = new SelectionTreeType(new ListRootItem());
|
||||
items.forEach((item) => {
|
||||
selection.set(item, true);
|
||||
});
|
||||
this._selection = selection;
|
||||
}
|
||||
|
||||
update(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
||||
// TODO do it the other way around: get all selected items, and check if they still exist?
|
||||
// let removedItems: ListItem[] = [];
|
||||
// applyToOrderedItemsFromFile(selection.value.getSelected(), (fileId, level, items) => {
|
||||
// let file = updatedFiles.find((file) => file._data.id === fileId);
|
||||
// if (file) {
|
||||
// items.forEach((item) => {
|
||||
// if (item instanceof ListTrackItem) {
|
||||
// let newTrackIndex = file.trk.findIndex(
|
||||
// (track) => track._data.trackIndex === item.getTrackIndex()
|
||||
// );
|
||||
// if (newTrackIndex === -1) {
|
||||
// removedItems.push(item);
|
||||
// }
|
||||
// } else if (item instanceof ListTrackSegmentItem) {
|
||||
// let newTrackIndex = file.trk.findIndex(
|
||||
// (track) => track._data.trackIndex === item.getTrackIndex()
|
||||
// );
|
||||
// if (newTrackIndex === -1) {
|
||||
// removedItems.push(item);
|
||||
// } else {
|
||||
// let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
|
||||
// (segment) => segment._data.segmentIndex === item.getSegmentIndex()
|
||||
// );
|
||||
// if (newSegmentIndex === -1) {
|
||||
// removedItems.push(item);
|
||||
// }
|
||||
// }
|
||||
// } else if (item instanceof ListWaypointItem) {
|
||||
// let newWaypointIndex = file.wpt.findIndex(
|
||||
// (wpt) => wpt._data.index === item.getWaypointIndex()
|
||||
// );
|
||||
// if (newWaypointIndex === -1) {
|
||||
// removedItems.push(item);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// } else if (deletedFileIds.includes(fileId)) {
|
||||
// items.forEach((item) => {
|
||||
// removedItems.push(item);
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// if (removedItems.length > 0) {
|
||||
// selection.update(($selection) => {
|
||||
// removedItems.forEach((item) => {
|
||||
// if (item instanceof ListFileItem) {
|
||||
// $selection.deleteChild(item.getFileId());
|
||||
// } else {
|
||||
// $selection.set(item, false);
|
||||
// }
|
||||
// });
|
||||
// return $selection;
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
let selected: ListItem[] = [];
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
selected.push(...items);
|
||||
}, reverse);
|
||||
return selected;
|
||||
}
|
||||
|
||||
copySelection(): boolean {
|
||||
let selected = this._selection.getSelected();
|
||||
if (selected.length > 0) {
|
||||
this._copied = selected;
|
||||
this._cut = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cutSelection() {
|
||||
if (this.copySelection()) {
|
||||
this._cut = true;
|
||||
}
|
||||
}
|
||||
|
||||
resetCopied() {
|
||||
this._copied = undefined;
|
||||
this._cut = false;
|
||||
}
|
||||
|
||||
get copied(): ListItem[] | undefined {
|
||||
return this._copied;
|
||||
}
|
||||
|
||||
get cut(): boolean {
|
||||
return this._cut;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
let selected: ListItem[] = [];
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
selected.push(...items);
|
||||
}, reverse);
|
||||
return selected;
|
||||
}
|
||||
export const selection = new Selection();
|
||||
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
@@ -261,115 +224,5 @@ export function applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
export const copied = writable<ListItem[] | undefined>(undefined);
|
||||
export const cut = writable(false);
|
||||
|
||||
export function copySelection(): boolean {
|
||||
let selected = get(selection).getSelected();
|
||||
if (selected.length > 0) {
|
||||
copied.set(selected);
|
||||
cut.set(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cutSelection() {
|
||||
if (copySelection()) {
|
||||
cut.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCopied() {
|
||||
copied.set(undefined);
|
||||
cut.set(false);
|
||||
}
|
||||
|
||||
export function pasteSelection() {
|
||||
let fromItems = get(copied);
|
||||
if (fromItems === undefined || fromItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected = get(selection).getSelected();
|
||||
if (selected.length === 0) {
|
||||
selected = [new ListRootItem()];
|
||||
}
|
||||
|
||||
let fromParent = fromItems[0].getParent();
|
||||
let toParent = selected[selected.length - 1];
|
||||
|
||||
let startIndex: number | undefined = undefined;
|
||||
|
||||
if (fromItems[0].level === toParent.level) {
|
||||
if (
|
||||
toParent instanceof ListTrackItem ||
|
||||
toParent instanceof ListTrackSegmentItem ||
|
||||
toParent instanceof ListWaypointItem
|
||||
) {
|
||||
startIndex = toParent.getId() + 1;
|
||||
}
|
||||
toParent = toParent.getParent();
|
||||
}
|
||||
|
||||
let toItems: ListItem[] = [];
|
||||
if (toParent.level === ListLevel.ROOT) {
|
||||
let fileIds = getFileIds(fromItems.length);
|
||||
fileIds.forEach((fileId) => {
|
||||
toItems.push(new ListFileItem(fileId));
|
||||
});
|
||||
} else {
|
||||
let toFile = getFile(toParent.getFileId());
|
||||
if (toFile) {
|
||||
fromItems.forEach((item, index) => {
|
||||
if (toParent instanceof ListFileItem) {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
toItems.push(
|
||||
new ListTrackItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.trk.length) + index
|
||||
)
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListTrackItem) {
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
let toTrackIndex = toParent.getTrackIndex();
|
||||
toItems.push(
|
||||
new ListTrackSegmentItem(
|
||||
toParent.getFileId(),
|
||||
toTrackIndex,
|
||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListWaypointsItem) {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fromItems.length === toItems.length) {
|
||||
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
||||
resetCopied();
|
||||
}
|
||||
applyToOrderedItemsFromFile(selection.value.getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
140
website/src/lib/logic/selection.ts
Normal file
140
website/src/lib/logic/selection.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ListItem } from '$lib/components/file-list/file-list';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
constructor(item: ListItem) {
|
||||
this.item = item;
|
||||
this.selected = false;
|
||||
this.children = {};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selected = false;
|
||||
for (let key in this.children) {
|
||||
this.children[key].clear();
|
||||
}
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
_setOrToggle(item: ListItem, value?: boolean) {
|
||||
if (item.level === this.item.level) {
|
||||
let newSelected = value === undefined ? !this.selected : value;
|
||||
if (this.selected !== newSelected) {
|
||||
this.selected = newSelected;
|
||||
this.size += this.selected ? 1 : -1;
|
||||
}
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (!this.children.hasOwnProperty(id)) {
|
||||
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
||||
}
|
||||
this.size -= this.children[id].size;
|
||||
this.children[id]._setOrToggle(item, value);
|
||||
this.size += this.children[id].size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(item: ListItem, value: boolean) {
|
||||
this._setOrToggle(item, value);
|
||||
}
|
||||
|
||||
toggle(item: ListItem) {
|
||||
this._setOrToggle(item);
|
||||
}
|
||||
|
||||
has(item: ListItem): boolean {
|
||||
if (item.level === this.item.level) {
|
||||
return this.selected;
|
||||
} else {
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].has(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyParent(item, self);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
if (id !== undefined) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let key in this.children) {
|
||||
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
||||
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getSelected(selection: ListItem[] = []): ListItem[] {
|
||||
if (this.selected) {
|
||||
selection.push(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].getSelected(selection);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
forEach(callback: (item: ListItem) => void) {
|
||||
if (this.selected) {
|
||||
callback(this.item);
|
||||
}
|
||||
for (let key in this.children) {
|
||||
this.children[key].forEach(callback);
|
||||
}
|
||||
}
|
||||
|
||||
getChild(id: string | number): SelectionTreeType | undefined {
|
||||
return this.children[id];
|
||||
}
|
||||
|
||||
deleteChild(id: string | number) {
|
||||
if (this.children.hasOwnProperty(id)) {
|
||||
this.size -= this.children[id].size;
|
||||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
46
website/src/lib/logic/statistics.ts
Normal file
46
website/src/lib/logic/statistics.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ListItem, ListLevel } from '$lib/components/file-list/file-list';
|
||||
import { GPXFile, GPXStatistics, type Track } from 'gpx';
|
||||
|
||||
export class GPXStatisticsTree {
|
||||
level: ListLevel;
|
||||
statistics: {
|
||||
[key: string]: GPXStatisticsTree | GPXStatistics;
|
||||
} = {};
|
||||
|
||||
constructor(element: GPXFile | Track) {
|
||||
if (element instanceof GPXFile) {
|
||||
this.level = ListLevel.FILE;
|
||||
element.children.forEach((child, index) => {
|
||||
this.statistics[index] = new GPXStatisticsTree(child);
|
||||
});
|
||||
} else {
|
||||
this.level = ListLevel.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 };
|
||||
Reference in New Issue
Block a user