This commit is contained in:
vcoppe
2026-10-04 16:11:44 +02:00
parent ec448b72e2
commit 7651d859c4
9 changed files with 325 additions and 341 deletions
@@ -7,7 +7,6 @@
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import {
allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions,
@@ -15,9 +14,7 @@
} from './embedding';
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { loadFiles } from '$lib/logic/file-actions';
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine';
@@ -30,6 +27,8 @@
hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
const { files } = engine;
let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
@@ -74,26 +73,14 @@
);
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
(url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url))
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
return fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url));
});
Promise.all(downloads)
.then(loadFiles)
.then(() => engine.selectAll());
}
$effect(() => {
@@ -105,7 +92,7 @@
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative">
<Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
class="h-full {$files.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key}
geocoder={false}
geolocate={true}
@@ -114,7 +101,7 @@
<OpenIn files={options.files} ids={options.ids} />
<LayerControl />
<GPXLayers />
{#if $fileStateCollection.size > 1}
{#if $files.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" />
</div>
+43
View File
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defaultFileName } from './default-file-name';
describe('default file name', () => {
it('is the default name when there is no other file with it', () => {
assert.equal(defaultFileName('New file', []), 'New file');
assert.equal(defaultFileName('New file', ['Morning ride', 'track']), 'New file');
});
it('is numbered when other files have the default name', () => {
assert.equal(defaultFileName('New file', ['New file']), 'New file 2');
assert.equal(defaultFileName('New file', ['New file', 'New file 2']), 'New file 3');
});
it('goes after the highest number', () => {
assert.equal(defaultFileName('New file', ['New file 4', 'New file']), 'New file 5');
// a number that was freed is not used again
assert.equal(defaultFileName('New file', ['New file', 'New file 3']), 'New file 4');
// the numbered ones count even if the first one was renamed
assert.equal(defaultFileName('New file', ['New file 2']), 'New file 3');
});
it('ignores the names that only look like it', () => {
assert.equal(defaultFileName('New file', ['New file copy', 'New files 3']), 'New file');
assert.equal(
defaultFileName('New file', ['New file 2b', 'New file 0', 'New file -3']),
'New file'
);
assert.equal(defaultFileName('New file', ['New file ', 'New file 2']), 'New file');
assert.equal(defaultFileName('New file', ['my New file']), 'New file');
});
it('works with any language', () => {
assert.equal(defaultFileName('Nouveau fichier', ['Nouveau fichier']), 'Nouveau fichier 2');
assert.equal(defaultFileName('新文件', ['新文件', '新文件 2']), '新文件 3');
// the default name of another language is just a name
assert.equal(
defaultFileName('Nouveau fichier', ['New file', 'New file 2']),
'Nouveau fichier'
);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* The name of a new file: the default name (translated), followed by a number when other files
* already have a default name (the name alone counts as number 1): "New file", "New file 2",
* "New file 3"...
*/
export function defaultFileName(defaultName: string, existingNames: string[]): string {
let highest = 0;
for (const name of existingNames) {
if (name === defaultName) {
highest = Math.max(highest, 1);
} else if (name.startsWith(`${defaultName} `)) {
const suffix = name.slice(defaultName.length + 1);
if (/^[1-9][0-9]*$/.test(suffix)) {
highest = Math.max(highest, parseInt(suffix));
}
}
}
return highest === 0 ? defaultName : `${defaultName} ${highest + 1}`;
}
+20 -3
View File
@@ -242,9 +242,26 @@ class Engine {
return this.run((w) => w.new_file(name));
}
/** `name` is the name of the file when the data has none (the name on disk, without extension). */
loadFile(data: Uint8Array, name: string) {
return this.run((w) => w.load_file(data, name));
/**
* Loads files as a single command (one undo step). `name` is the name of a file when its data
* has none (the name on disk, without extension). The files that cannot be read are skipped,
* the first one that was read is selected.
*/
loadFiles(files: { data: Uint8Array; name: string }[]) {
const lengths = Uint32Array.from(files.map((file) => file.data.length));
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
return this.run((w) =>
w.load_files(
data,
lengths,
files.map((file) => file.name)
)
);
}
duplicate() {
+32 -303
View File
@@ -10,15 +10,12 @@ import {
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
sortItems,
type ListItem,
} from '$lib/components/file-list/file-list';
import { i18n } from '$lib/i18n.svelte';
import { freeze, type WritableDraft } from 'immer';
import { freeze } from 'immer';
import {
GPXFile,
parseGPX,
Track,
TrackPoint,
TrackSegment,
@@ -32,6 +29,8 @@ import { settings } from '$lib/logic/settings';
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
import { gpxStatistics } from '$lib/logic/statistics';
import { boundsManager } from './bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/default-file-name';
// Generate unique file ids, different from the ones in the database
export function getFileIds(n: number) {
@@ -45,31 +44,21 @@ export function getFileIds(n: number) {
return ids;
}
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function newGPXFile() {
const newFileName = i18n._('menu.new_file');
let file = new GPXFile();
let maxNewFileNumber = 0;
fileStateCollection.forEach((fileId, file) => {
if (file.metadata.name && file.metadata.name.startsWith(newFileName)) {
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
if (!isNaN(number) && number > maxNewFileNumber) {
maxNewFileNumber = number;
}
}
});
file.metadata.name = `${newFileName} ${maxNewFileNumber + 1}`;
file.metadata.name = newFileName();
return file;
}
export function createFile() {
let file = newGPXFile();
fileActions.add(file);
selection.selectFileWhenLoaded(file._data.id);
// the engine selects the new file
engine.newFile(newFileName());
currentTool.set(Tool.ROUTING);
}
@@ -87,41 +76,27 @@ export function triggerFileInput() {
input.click();
}
export async function loadFiles(list: FileList | File[]) {
let files: GPXFile[] = [];
for (let i = 0; i < list.length; i++) {
let file = await loadFile(list[i]);
if (file) {
files.push(file);
}
/**
* Loads GPX files in the engine, as a single command: one undo step, the first file that could be
* read is selected, and the map fits the files. A file without name is named like the file on
* disk. Returns the ids of the files that were added.
*/
export async function loadFiles(list: FileList | File[]): Promise<string[]> {
const files = await Promise.all(
Array.from(list).map(async (file) => ({
data: new Uint8Array(await file.arrayBuffer()),
name: file.name.split('.').slice(0, -1).join('.'),
}))
);
const before = new Set(get(engine.order));
await engine.loadFiles(files);
const ids = get(engine.order).filter((id) => !before.has(id));
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
let ids = fileActions.addMultiple(files);
selection.selectFileWhenLoaded(ids[0]);
boundsManager.fitBoundsOnLoad(ids);
}
export async function loadFile(file: File): Promise<GPXFile | null> {
let result = await new Promise<GPXFile | null>((resolve) => {
const reader = new FileReader();
reader.onload = () => {
let data = reader.result?.toString() ?? null;
if (data) {
let gpx = parseGPX(data);
if (gpx.metadata === undefined) {
gpx.metadata = {};
}
if (gpx.metadata.name === undefined || gpx.metadata.name.trim() === '') {
gpx.metadata.name = file.name.split('.').slice(0, -1).join('.');
}
resolve(gpx);
} else {
resolve(null);
}
};
reader.readAsText(file);
});
return result;
return ids;
}
// Helper functions for file operations
@@ -891,249 +866,3 @@ export const fileActions = {
});
},
};
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 = fileStateCollection.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));
selection.resetCopied();
}
}
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, false);
sortItems(toItems, false);
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
fromItems.forEach((item) => {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
if (remove && !(fromParent instanceof ListRootItem)) {
sortItems(fromItems, true);
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
}
});
},
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
},
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
fileActionManager.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
},
context
);
selection.set(toItems);
}