This commit is contained in:
vcoppe
2024-04-25 13:48:31 +02:00
parent b5990e2d36
commit 20af7c4e45
12 changed files with 299 additions and 311 deletions

7
gpx/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"jest": "^29.7.0", "jest": "^29.7.0",
@@ -1038,6 +1039,12 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.14",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz",
"integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==",
"dev": true
},
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",

View File

@@ -18,6 +18,7 @@
"test": "jest" "test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@@ -9,23 +9,21 @@ function cloneJSON<T>(obj: T): T {
// An abstract class that groups functions that need to be computed recursively in the GPX file hierarchy // An abstract class that groups functions that need to be computed recursively in the GPX file hierarchy
abstract class GPXTreeElement<T extends GPXTreeElement<any>> { abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
statistics: GPXStatistics; _data: { [key: string]: any } = {};
abstract isLeaf(): boolean; abstract isLeaf(): boolean;
abstract getChildren(): T[]; abstract getChildren(): T[];
abstract computeStatistics(): GPXStatistics;
abstract refreshStatistics(): void;
abstract append(points: TrackPoint[]): void; abstract append(points: TrackPoint[]): void;
abstract reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void; abstract reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void;
abstract getStartTimestamp(): Date; abstract getStartTimestamp(): Date;
abstract getEndTimestamp(): Date; abstract getEndTimestamp(): Date;
abstract getStatistics(): GPXStatistics;
abstract getTrackPoints(): TrackPoint[]; abstract getTrackPoints(): TrackPoint[];
abstract getTrackPointsAndStatistics(): { points: TrackPoint[], statistics: TrackPointStatistics }; abstract getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics };
abstract toGeoJSON(): any; abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[];
} }
// An abstract class that can be extended to facilitate functions working similarly with Tracks and TrackSegments // An abstract class that can be extended to facilitate functions working similarly with Tracks and TrackSegments
@@ -34,22 +32,6 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return false; return false;
} }
computeStatistics(): GPXStatistics {
for (let child of this.getChildren()) {
child.computeStatistics();
}
this.refreshStatistics();
return this.statistics;
}
refreshStatistics(): void {
this.statistics = new GPXStatistics();
for (let child of this.getChildren()) {
child.refreshStatistics();
this.statistics.mergeWith(child.statistics);
}
}
append(points: TrackPoint[]): void { append(points: TrackPoint[]): void {
let children = this.getChildren(); let children = this.getChildren();
@@ -58,8 +40,6 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
} }
children[children.length - 1].append(points); children[children.length - 1].append(points);
this.refreshStatistics();
} }
reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void { reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void {
@@ -80,8 +60,6 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
originalNextTimestamp = originalStartTimestamp; originalNextTimestamp = originalStartTimestamp;
newPreviousTimestamp = children[i].getEndTimestamp(); newPreviousTimestamp = children[i].getEndTimestamp();
} }
this.refreshStatistics();
} }
getStartTimestamp(): Date { getStartTimestamp(): Date {
@@ -96,9 +74,17 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
return this.getChildren().flatMap((child) => child.getTrackPoints()); return this.getChildren().flatMap((child) => child.getTrackPoints());
} }
getTrackPointsAndStatistics(): { points: TrackPoint[]; statistics: TrackPointStatistics; } { getStatistics(): GPXStatistics {
let statistics = new GPXStatistics();
for (let child of this.getChildren()) {
statistics.mergeWith(child.getStatistics());
}
return statistics;
}
getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics } {
let points: TrackPoint[] = []; let points: TrackPoint[] = [];
let statistics: TrackPointStatistics = { let point_statistics: TrackPointStatistics = {
distance: [], distance: [],
time: [], time: [],
speed: [], speed: [],
@@ -109,25 +95,25 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
}, },
slope: [], slope: [],
}; };
let statistics = new GPXStatistics();
let current = new GPXStatistics();
for (let child of this.getChildren()) { for (let child of this.getChildren()) {
let childData = child.getTrackPointsAndStatistics(); let childData = child.getTrackPointsAndStatistics();
points = points.concat(childData.points); points = points.concat(childData.points);
statistics.distance = statistics.distance.concat(childData.statistics.distance.map((distance) => distance + current.distance.total)); point_statistics.distance = point_statistics.distance.concat(childData.point_statistics.distance.map((distance) => distance + statistics.distance.total));
statistics.time = statistics.time.concat(childData.statistics.time.map((time) => time + current.time.total)); point_statistics.time = point_statistics.time.concat(childData.point_statistics.time.map((time) => time + statistics.time.total));
statistics.elevation.gain = statistics.elevation.gain.concat(childData.statistics.elevation.gain.map((gain) => gain + current.elevation.gain)); point_statistics.elevation.gain = point_statistics.elevation.gain.concat(childData.point_statistics.elevation.gain.map((gain) => gain + statistics.elevation.gain));
statistics.elevation.loss = statistics.elevation.loss.concat(childData.statistics.elevation.loss.map((loss) => loss + current.elevation.loss)); point_statistics.elevation.loss = point_statistics.elevation.loss.concat(childData.point_statistics.elevation.loss.map((loss) => loss + statistics.elevation.loss));
statistics.speed = statistics.speed.concat(childData.statistics.speed); point_statistics.speed = point_statistics.speed.concat(childData.point_statistics.speed);
statistics.elevation.smoothed = statistics.elevation.smoothed.concat(childData.statistics.elevation.smoothed); point_statistics.elevation.smoothed = point_statistics.elevation.smoothed.concat(childData.point_statistics.elevation.smoothed);
statistics.slope = statistics.slope.concat(childData.statistics.slope); point_statistics.slope = point_statistics.slope.concat(childData.point_statistics.slope);
current.mergeWith(child.statistics); statistics.mergeWith(childData.statistics);
} }
return { points, statistics }; return { points, point_statistics, statistics };
} }
} }
@@ -149,18 +135,13 @@ export class GPXFiles extends GPXTreeNode<GPXFile> {
constructor(files: GPXFile[]) { constructor(files: GPXFile[]) {
super(); super();
this.files = files; this.files = files;
this.statistics = new GPXStatistics();
for (let file of files) {
this.statistics.mergeWith(file.statistics);
}
} }
getChildren(): GPXFile[] { getChildren(): GPXFile[] {
return this.files; return this.files;
} }
toGeoJSON(): any { toGeoJSON(): GeoJSON.FeatureCollection[] {
return this.getChildren().map((child) => child.toGeoJSON()); return this.getChildren().map((child) => child.toGeoJSON());
} }
} }
@@ -178,8 +159,6 @@ export class GPXFile extends GPXTreeNode<Track>{
this.metadata = cloneJSON(gpx.metadata); this.metadata = cloneJSON(gpx.metadata);
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : []; this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : []; this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : [];
this.computeStatistics();
} }
getChildren(): Track[] { getChildren(): Track[] {
@@ -190,7 +169,7 @@ export class GPXFile extends GPXTreeNode<Track>{
return new GPXFile(structuredClone(this)); return new GPXFile(structuredClone(this));
} }
toGeoJSON(): any { toGeoJSON(): GeoJSON.FeatureCollection {
return { return {
type: "FeatureCollection", type: "FeatureCollection",
features: this.getChildren().flatMap((child) => child.toGeoJSON()) features: this.getChildren().flatMap((child) => child.toGeoJSON())
@@ -234,7 +213,7 @@ export class Track extends GPXTreeNode<TrackSegment> {
return this.trkseg; return this.trkseg;
} }
toGeoJSON(): any { toGeoJSON(): GeoJSON.Feature[] {
return this.getChildren().map((child) => { return this.getChildren().map((child) => {
let geoJSON = child.toGeoJSON(); let geoJSON = child.toGeoJSON();
if (this.extensions && this.extensions['gpx_style:line']) { if (this.extensions && this.extensions['gpx_style:line']) {
@@ -274,13 +253,15 @@ export class Track extends GPXTreeNode<TrackSegment> {
export class TrackSegment extends GPXTreeLeaf { export class TrackSegment extends GPXTreeLeaf {
trkpt: TrackPoint[]; trkpt: TrackPoint[];
trkptStatistics: TrackPointStatistics; trkptStatistics: TrackPointStatistics;
statistics: GPXStatistics;
constructor(segment: TrackSegmentType | TrackSegment) { constructor(segment: TrackSegmentType | TrackSegment) {
super(); super();
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point)); this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
this._computeStatistics();
} }
computeStatistics(): GPXStatistics { _computeStatistics(): void {
let statistics = new GPXStatistics(); let statistics = new GPXStatistics();
let trkptStatistics: TrackPointStatistics = { let trkptStatistics: TrackPointStatistics = {
distance: [], distance: [],
@@ -294,8 +275,8 @@ export class TrackSegment extends GPXTreeLeaf {
slope: [], slope: [],
}; };
trkptStatistics.elevation.smoothed = this.computeSmoothedElevation(); trkptStatistics.elevation.smoothed = this._computeSmoothedElevation();
trkptStatistics.slope = this.computeSlope(); trkptStatistics.slope = this._computeSlope();
const points = this.trkpt; const points = this.trkpt;
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
@@ -357,14 +338,9 @@ export class TrackSegment extends GPXTreeLeaf {
this.statistics = statistics; this.statistics = statistics;
this.trkptStatistics = trkptStatistics; this.trkptStatistics = trkptStatistics;
return statistics;
} }
// Do nothing, recompute statistics after modifying the segment only _computeSmoothedElevation(): number[] {
refreshStatistics(): void { }
computeSmoothedElevation(): number[] {
const points = this.trkpt; const points = this.trkpt;
let smoothed = distanceWindowSmoothing(points, 100, (index) => points[index].ele, (accumulated, start, end) => accumulated / (end - start + 1)); let smoothed = distanceWindowSmoothing(points, 100, (index) => points[index].ele, (accumulated, start, end) => accumulated / (end - start + 1));
@@ -377,7 +353,7 @@ export class TrackSegment extends GPXTreeLeaf {
return smoothed; return smoothed;
} }
computeSlope(): number[] { _computeSlope(): number[] {
const points = this.trkpt; const points = this.trkpt;
return distanceWindowSmoothingWithDistanceAccumulator(points, 50, (accumulated, start, end) => 100 * (points[end].ele - points[start].ele) / accumulated); return distanceWindowSmoothingWithDistanceAccumulator(points, 50, (accumulated, start, end) => 100 * (points[end].ele - points[start].ele) / accumulated);
@@ -385,7 +361,7 @@ export class TrackSegment extends GPXTreeLeaf {
append(points: TrackPoint[]): void { append(points: TrackPoint[]): void {
this.trkpt = this.trkpt.concat(points); this.trkpt = this.trkpt.concat(points);
this.computeStatistics(); this._computeStatistics();
} }
reverse(originalNextTimestamp: Date | undefined, newPreviousTimestamp: Date | undefined): void { reverse(originalNextTimestamp: Date | undefined, newPreviousTimestamp: Date | undefined): void {
@@ -405,7 +381,7 @@ export class TrackSegment extends GPXTreeLeaf {
} else { } else {
this.trkpt.reverse(); this.trkpt.reverse();
} }
this.computeStatistics(); this._computeStatistics();
} }
getStartTimestamp(): Date { getStartTimestamp(): Date {
@@ -420,14 +396,19 @@ export class TrackSegment extends GPXTreeLeaf {
return this.trkpt; return this.trkpt;
} }
getTrackPointsAndStatistics(): { points: TrackPoint[], statistics: TrackPointStatistics } { getStatistics(): GPXStatistics {
return this.statistics;
}
getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics } {
return { return {
points: this.trkpt, points: this.trkpt,
statistics: this.trkptStatistics point_statistics: this.trkptStatistics,
statistics: this.statistics,
}; };
} }
toGeoJSON(): any { toGeoJSON(): GeoJSON.Feature {
return { return {
type: "Feature", type: "Feature",
geometry: { geometry: {
@@ -454,6 +435,7 @@ export class TrackPoint {
ele?: number; ele?: number;
time?: Date; time?: Date;
extensions?: TrackPointExtensions; extensions?: TrackPointExtensions;
_data: {} = {};
constructor(point: TrackPointType | TrackPoint) { constructor(point: TrackPointType | TrackPoint) {
this.attributes = cloneJSON(point.attributes); this.attributes = cloneJSON(point.attributes);

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import GPXMapLayers from '$lib/components/GPXMapLayers.svelte'; import GPXMapLayers from '$lib/components/gpx-layer/GPXMapLayers.svelte';
import ElevationProfile from '$lib/components/ElevationProfile.svelte'; import ElevationProfile from '$lib/components/ElevationProfile.svelte';
import FileList from '$lib/components/FileList.svelte'; import FileList from '$lib/components/FileList.svelte';
import GPXData from '$lib/components/GPXData.svelte'; import GPXData from '$lib/components/GPXData.svelte';

View File

@@ -231,16 +231,16 @@
$: if (chart && $settings) { $: if (chart && $settings) {
let gpxFiles = new GPXFiles(get(fileOrder).filter((f) => $selectedFiles.has(f))); let gpxFiles = new GPXFiles(get(fileOrder).filter((f) => $selectedFiles.has(f)));
let data = gpxFiles.getTrackPointsAndStatistics();
// update data // update data
let trackPointsAndStatistics = gpxFiles.getTrackPointsAndStatistics();
chart.data.datasets[0] = { chart.data.datasets[0] = {
label: $_('quantities.elevation'), label: $_('quantities.elevation'),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: point.ele ? getConvertedElevation(point.ele) : 0, y: point.ele ? getConvertedElevation(point.ele) : 0,
slope: trackPointsAndStatistics.statistics.slope[index], slope: data.point_statistics.slope[index],
surface: point.getSurface(), surface: point.getSurface(),
coordinates: point.getCoordinates() coordinates: point.getCoordinates()
}; };
@@ -251,10 +251,10 @@
}; };
chart.data.datasets[1] = { chart.data.datasets[1] = {
label: datasets.speed.getLabel(), label: datasets.speed.getLabel(),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: getConvertedVelocity(trackPointsAndStatistics.statistics.speed[index]) y: getConvertedVelocity(data.point_statistics.speed[index])
}; };
}), }),
normalized: true, normalized: true,
@@ -263,9 +263,9 @@
}; };
chart.data.datasets[2] = { chart.data.datasets[2] = {
label: datasets.hr.getLabel(), label: datasets.hr.getLabel(),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: point.getHeartRate() y: point.getHeartRate()
}; };
}), }),
@@ -275,9 +275,9 @@
}; };
chart.data.datasets[3] = { chart.data.datasets[3] = {
label: datasets.cad.getLabel(), label: datasets.cad.getLabel(),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: point.getCadence() y: point.getCadence()
}; };
}), }),
@@ -287,9 +287,9 @@
}; };
chart.data.datasets[4] = { chart.data.datasets[4] = {
label: datasets.atemp.getLabel(), label: datasets.atemp.getLabel(),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: getConvertedTemperature(point.getTemperature()) y: getConvertedTemperature(point.getTemperature())
}; };
}), }),
@@ -299,9 +299,9 @@
}; };
chart.data.datasets[5] = { chart.data.datasets[5] = {
label: datasets.power.getLabel(), label: datasets.power.getLabel(),
data: trackPointsAndStatistics.points.map((point, index) => { data: data.points.map((point, index) => {
return { return {
x: getConvertedDistance(trackPointsAndStatistics.statistics.distance[index]), x: getConvertedDistance(data.point_statistics.distance[index]),
y: point.getPower() y: point.getPower()
}; };
}), }),
@@ -310,7 +310,7 @@
hidden: true hidden: true
}; };
chart.options.scales.x['min'] = 0; chart.options.scales.x['min'] = 0;
chart.options.scales.x['max'] = getConvertedDistance(gpxFiles.statistics.distance.total); chart.options.scales.x['max'] = getConvertedDistance(data.statistics.distance.total);
// update units // update units
chart.options.scales.x.title.text = `${$_('quantities.distance')} (${getDistanceUnits()})`; chart.options.scales.x.title.text = `${$_('quantities.distance')} (${getDistanceUnits()})`;

View File

@@ -3,9 +3,9 @@
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import WithUnits from '$lib/components/WithUnits.svelte'; import WithUnits from '$lib/components/WithUnits.svelte';
import { GPXStatistics } from 'gpx'; import { GPXFiles, GPXStatistics } from 'gpx';
import { getFileStore, selectedFiles, settings } from '$lib/stores'; import { selectedFiles, settings } from '$lib/stores';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
@@ -15,10 +15,7 @@
let gpxData: GPXStatistics = new GPXStatistics(); let gpxData: GPXStatistics = new GPXStatistics();
function updateGPXData() { function updateGPXData() {
gpxData = new GPXStatistics(); gpxData = new GPXFiles(Array.from(get(selectedFiles))).getStatistics();
$selectedFiles.forEach((file) => {
gpxData.mergeWith(file.statistics);
});
} }
$: if ($selectedFiles) { $: if ($selectedFiles) {
@@ -47,24 +44,25 @@
<Tooltip> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Zap size="18" class="mr-1" /> <Zap size="18" class="mr-1" />
<WithUnits value={gpxData.speed.moving} type="speed" showUnits={false} /> / <WithUnits value={gpxData.speed.total} type="speed" showUnits={false} />
<WithUnits value={gpxData.speed.total} type="speed" /> <span class="mx-1">/</span>
<WithUnits value={gpxData.speed.moving} type="speed" />
</span> </span>
<span slot="tooltip" <span slot="tooltip"
>{$settings.velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_( >{$settings.velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
'quantities.moving' 'quantities.total'
)} / {$_('quantities.total')})</span )} / {$_('quantities.moving')})</span
> >
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<span slot="data" class="flex flex-row items-center"> <span slot="data" class="flex flex-row items-center">
<Timer size="18" class="mr-1" /> <Timer size="18" class="mr-1" />
<WithUnits value={gpxData.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={gpxData.time.total} type="time" /> <WithUnits value={gpxData.time.total} type="time" />
<span class="mx-1">/</span>
<WithUnits value={gpxData.time.moving} type="time" />
</span> </span>
<span slot="tooltip" <span slot="tooltip"
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span >{$_('quantities.time')} ({$_('quantities.total')} / {$_('quantities.moving')})</span
> >
</Tooltip> </Tooltip>
</Card.Content> </Card.Content>

View File

@@ -1,201 +0,0 @@
<script context="module" lang="ts">
let id = 0;
function getLayerId() {
return `gpx-${id++}`;
}
let defaultWeight = 6;
let defaultOpacity = 1;
const colors = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a'
];
const colorCount: { [key: string]: number } = {};
for (let color of colors) {
colorCount[color] = 0;
}
// Get the color with the least amount of uses
function getColor() {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
return color;
}
function decrementColor(color: string) {
colorCount[color]--;
}
</script>
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { GPXFile } from 'gpx';
import { map, selectedFiles, selectFiles, files } from '$lib/stores';
import { get, type Writable } from 'svelte/store';
export let file: Writable<GPXFile>;
let layerId = getLayerId();
let layerColor = getColor();
file.update((f) => {
Object.defineProperty(f, 'layerId', {
value: layerId,
writable: false
});
return f;
});
function selectOnClick(e: any) {
if (e.originalEvent.shiftKey) {
get(selectFiles).addSelect(get(file));
} else {
get(selectFiles).select(get(file));
}
}
function toPointerCursor() {
if ($map) {
$map.getCanvas().style.cursor = 'pointer';
}
}
function toDefaultCursor() {
if ($map) {
$map.getCanvas().style.cursor = '';
}
}
function extendGeoJSON(data: any) {
for (let feature of data.features) {
if (!feature.properties.color) {
feature.properties.color = layerColor;
}
if (!feature.properties.weight) {
feature.properties.weight = defaultWeight;
}
if (!feature.properties.opacity) {
feature.properties.opacity = defaultOpacity;
}
}
return data;
}
function addGPXLayer() {
if ($map) {
if (!$map.getSource(layerId)) {
let data = extendGeoJSON($file.toGeoJSON());
$map.addSource(layerId, {
type: 'geojson',
data
});
}
if (!$map.getLayer(layerId)) {
$map.addLayer({
id: layerId,
type: 'line',
source: layerId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'weight'],
'line-opacity': ['get', 'opacity']
}
});
$map.on('click', layerId, selectOnClick);
$map.on('mouseenter', layerId, toPointerCursor);
$map.on('mouseleave', layerId, toDefaultCursor);
}
}
}
$: if ($selectedFiles.has(get(file))) {
if ($map) {
$map.moveLayer(layerId);
}
}
$: if ($map) {
let source = $map.getSource(layerId);
if (source) {
source.setData(extendGeoJSON($file.toGeoJSON()));
}
}
onMount(() => {
addGPXLayer();
if ($map) {
if ($files.length == 1) {
$map.fitBounds(
[get(file).statistics.bounds.southWest, get(file).statistics.bounds.northEast],
{
padding: 60,
linear: true,
easing: () => 1
}
);
} else {
let mapBounds = $map.getBounds();
if (
mapBounds.contains(get(file).statistics.bounds.southWest) &&
mapBounds.contains(get(file).statistics.bounds.northEast) &&
mapBounds.contains([
get(file).statistics.bounds.southWest.lon,
get(file).statistics.bounds.northEast.lat
]) &&
mapBounds.contains([
get(file).statistics.bounds.northEast.lon,
get(file).statistics.bounds.southWest.lat
])
) {
return;
}
$map.fitBounds(
$map
.getBounds()
.extend([
get(file).statistics.bounds.southWest.lon,
get(file).statistics.bounds.southWest.lat,
get(file).statistics.bounds.northEast.lon,
get(file).statistics.bounds.northEast.lat
]),
{
padding: 60
}
);
}
$map.on('style.load', addGPXLayer);
}
});
onDestroy(() => {
if ($map) {
$map.off('click', layerId, selectOnClick);
$map.off('mouseenter', layerId, toPointerCursor);
$map.off('mouseleave', layerId, toDefaultCursor);
$map.off('style.load', addGPXLayer);
$map.removeLayer(layerId);
$map.removeSource(layerId);
}
decrementColor(layerColor);
});
</script>

View File

@@ -1,9 +0,0 @@
<script lang="ts">
import GPXMapLayer from './GPXMapLayer.svelte';
import { files } from '$lib/stores';
</script>
{#each $files as file}
<GPXMapLayer {file} />
{/each}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { map, files, selectedFiles, getFileStore } from '$lib/stores';
import type { GPXFile } from 'gpx';
import { GPXMapLayer } from './GPXMapLayers';
import { get, type Writable } from 'svelte/store';
let gpxLayers: Map<Writable<GPXFile>, GPXMapLayer> = new Map();
$: if ($map) {
gpxLayers.forEach((layer, file) => {
if (!get(files).includes(file)) {
layer.remove();
gpxLayers.delete(file);
}
});
$files.forEach((file) => {
if (!gpxLayers.has(file)) {
gpxLayers.set(file, new GPXMapLayer(get(map), file));
}
});
}
$: $selectedFiles.forEach((file) => {
let fileStore = getFileStore(file);
if (gpxLayers.has(fileStore)) {
gpxLayers.get(fileStore)?.moveToFront();
}
});
</script>

View File

@@ -0,0 +1,158 @@
import type { GPXFile } from "gpx";
import { map, selectFiles } from "$lib/stores";
import { get, type Writable } from "svelte/store";
import type mapboxgl from "mapbox-gl";
let id = 0;
function getLayerId() {
return `gpx-${id++}`;
}
let defaultWeight = 6;
let defaultOpacity = 1;
const colors = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a'
];
const colorCount: { [key: string]: number } = {};
for (let color of colors) {
colorCount[color] = 0;
}
// Get the color with the least amount of uses
function getColor() {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
return color;
}
function decrementColor(color: string) {
colorCount[color]--;
}
export class GPXMapLayer {
map: mapboxgl.Map;
file: Writable<GPXFile>;
layerId: string;
layerColor: string;
unsubscribe: () => void;
constructor(map: mapboxgl.Map, file: Writable<GPXFile>) {
this.map = map;
this.file = file;
this.layerId = getLayerId();
this.layerColor = getColor();
this.unsubscribe = file.subscribe(this.updateData.bind(this));
get(this.file)._data = {
layerId: this.layerId,
layerColor: this.layerColor
};
this.add();
this.map.on('style.load', this.add.bind(this));
}
add() {
if (!this.map.getSource(this.layerId)) {
let data = this.getGeoJSON();
this.map.addSource(this.layerId, {
type: 'geojson',
data
});
}
if (!this.map.getLayer(this.layerId)) {
this.map.addLayer({
id: this.layerId,
type: 'line',
source: this.layerId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'weight'],
'line-opacity': ['get', 'opacity']
}
});
this.map.on('click', this.layerId, this.selectOnClick.bind(this));
this.map.on('mouseenter', this.layerId, toPointerCursor);
this.map.on('mouseleave', this.layerId, toDefaultCursor);
}
}
updateData() {
let source = this.map.getSource(this.layerId);
if (source) {
source.setData(this.getGeoJSON());
}
}
remove() {
this.map.off('click', this.layerId, this.selectOnClick.bind(this));
this.map.off('mouseenter', this.layerId, toPointerCursor);
this.map.off('mouseleave', this.layerId, toDefaultCursor);
this.map.off('style.load', this.add.bind(this));
this.map.removeLayer(this.layerId);
this.map.removeSource(this.layerId);
this.unsubscribe();
decrementColor(this.layerColor);
}
moveToFront() {
this.map.moveLayer(this.layerId);
}
selectOnClick(e: any) {
if (e.originalEvent.shiftKey) {
get(selectFiles).addSelect(get(this.file));
} else {
get(selectFiles).select(get(this.file));
}
}
getGeoJSON(): GeoJSON.FeatureCollection {
let data = get(this.file).toGeoJSON();
for (let feature of data.features) {
if (!feature.properties) {
feature.properties = {};
}
if (!feature.properties.color) {
feature.properties.color = this.layerColor;
}
if (!feature.properties.weight) {
feature.properties.weight = defaultWeight;
}
if (!feature.properties.opacity) {
feature.properties.opacity = defaultOpacity;
}
}
return data;
}
}
function toPointerCursor() {
get(map).getCanvas().style.cursor = 'pointer';
}
function toDefaultCursor() {
get(map).getCanvas().style.cursor = '';
}

View File

@@ -106,7 +106,7 @@
$map.off('move', toggleMarkersForZoomLevelAndBounds); $map.off('move', toggleMarkersForZoomLevelAndBounds);
$map.off('click', extendFile); $map.off('click', extendFile);
if (file) { if (file) {
$map.off('mouseover', file.layerId, showInsertableMarker); $map.off('mouseover', file._data.layerId, showInsertableMarker);
} }
if (insertableMarker) { if (insertableMarker) {
insertableMarker.remove(); insertableMarker.remove();
@@ -115,7 +115,7 @@
kdbush = null; kdbush = null;
} }
$: if ($selectedFiles.size == 1 && $map) { $: if ($selectedFiles.size == 1) {
let selectedFile = $selectedFiles.values().next().value; let selectedFile = $selectedFiles.values().next().value;
if (selectedFile !== file) { if (selectedFile !== file) {
@@ -124,6 +124,9 @@
} else { } else {
// update markers // update markers
} }
} else {
clean();
file = null;
} }
$: if ($map && file) { $: if ($map && file) {
@@ -140,7 +143,7 @@
$map.on('zoom', toggleMarkersForZoomLevelAndBounds); $map.on('zoom', toggleMarkersForZoomLevelAndBounds);
$map.on('move', toggleMarkersForZoomLevelAndBounds); $map.on('move', toggleMarkersForZoomLevelAndBounds);
$map.on('click', extendFile); $map.on('click', extendFile);
$map.on('mouseover', file.layerId, showInsertableMarker); $map.on('mouseover', file._data.layerId, showInsertableMarker);
let points = file.getTrackPoints(); let points = file.getTrackPoints();
@@ -152,8 +155,6 @@
kdbush.finish(); kdbush.finish();
end = performance.now(); end = performance.now();
console.log('Time to create kdbush: ' + (end - start) + 'ms'); console.log('Time to create kdbush: ' + (end - start) + 'ms');
} else {
clean();
} }
onDestroy(() => { onDestroy(() => {

View File

@@ -68,10 +68,32 @@ export function triggerFileInput() {
} }
export async function loadFiles(list: FileList) { export async function loadFiles(list: FileList) {
let bounds = new mapboxgl.LngLatBounds();
let mapBounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]);
if (get(files).length > 0) {
mapBounds = get(map)?.getBounds() ?? mapBounds;
bounds.extend(mapBounds);
}
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
let file = await loadFile(list[i]); let file = await loadFile(list[i]);
if (i == 0 && file) { if (file) {
get(selectFiles).select(get(file)); if (i == 0) {
get(selectFiles).select(get(file));
}
let fileBounds = get(file).getStatistics().bounds;
bounds.extend(fileBounds.southWest);
bounds.extend(fileBounds.northEast);
bounds.extend([fileBounds.southWest.lon, fileBounds.northEast.lat]);
bounds.extend([fileBounds.northEast.lon, fileBounds.southWest.lat]);
if (!mapBounds.contains(bounds.getSouthWest()) || !mapBounds.contains(bounds.getNorthEast()) || !mapBounds.contains(bounds.getSouthEast()) || !mapBounds.contains(bounds.getNorthWest())) {
get(map)?.fitBounds(bounds, {
padding: 80,
linear: true,
easing: () => 1
});
}
} }
} }
} }