This commit is contained in:
vcoppe
2026-10-04 08:53:00 +02:00
parent 86aba8f4b1
commit 9ac2a2b69c
6 changed files with 141 additions and 19 deletions
+2 -2
View File
@@ -291,7 +291,7 @@ pub fn parse(data: &[u8]) -> Result<File, Error> {
Some(GPXElement::Color) => { Some(GPXElement::Color) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() { if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(format!("#{}", &*e)); trk.info.color = Some(e.to_string());
} }
} }
Some(GPXElement::Opacity) => { Some(GPXElement::Opacity) => {
@@ -572,7 +572,7 @@ mod tests {
assert_eq!(gpx.trk.len(), 1); assert_eq!(gpx.trk.len(), 1);
let trk = &gpx.trk[0]; let trk = &gpx.trk[0];
assert_eq!(trk.trkseg.len(), 1); assert_eq!(trk.trkseg.len(), 1);
assert!(trk.info.color.as_ref().is_some_and(|c| c == "#2d3ee9")); assert!(trk.info.color.as_ref().is_some_and(|c| c == "2d3ee9"));
assert!(trk.info.opacity.is_some_and(|o| o == 0.5)); assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
assert!(trk.info.width.is_some_and(|w| w == 6.0)); assert!(trk.info.width.is_some_and(|w| w == 6.0));
} }
@@ -22,6 +22,10 @@ pub struct FileStructure {
pub struct TrackNode { pub struct TrackNode {
pub id: TrackId, pub id: TrackId,
pub name: Option<String>, pub name: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub segments: Vec<SegmentNode>, pub segments: Vec<SegmentNode>,
} }
@@ -37,6 +41,7 @@ pub struct SegmentNode {
pub struct WaypointNode { pub struct WaypointNode {
pub id: WaypointId, pub id: WaypointId,
pub name: Option<String>, pub name: Option<String>,
pub sym: Option<String>,
} }
impl FileStructure { impl FileStructure {
@@ -50,6 +55,9 @@ impl FileStructure {
.map(|trk| TrackNode { .map(|trk| TrackNode {
id: trk.id, id: trk.id,
name: trk.info.name.clone(), name: trk.info.name.clone(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk segments: trk
.trkseg .trkseg
.iter() .iter()
@@ -68,6 +76,7 @@ impl FileStructure {
.map(|wpt| WaypointNode { .map(|wpt| WaypointNode {
id: wpt.id, id: wpt.id,
name: wpt.name.clone(), name: wpt.name.clone(),
sym: wpt.sym.clone(),
}) })
.collect(), .collect(),
wpt_rev_id: file.wpt_rev_id, wpt_rev_id: file.wpt_rev_id,
@@ -94,6 +103,10 @@ mod tests {
assert_eq!(node.tracks.len(), file.trk.len()); assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) { for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name), (t.id, &t.info.name)); assert_eq!((n.id, &n.name), (t.id, &t.info.name));
assert_eq!(
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.segments.len(), t.trkseg.len()); assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) { for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len())); assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
+2 -1
View File
@@ -2,7 +2,8 @@
use crate::{ use crate::{
Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure,
FileStructureCache, GlobalStatistics, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, FileStructureCache, GlobalStatistics, Selection, Stack, State, StatisticsBuffer,
StatisticsCache, TrackSegmentId,
}; };
#[derive(Debug, Default)] #[derive(Debug, Default)]
+19 -1
View File
@@ -375,6 +375,10 @@ export interface FileStatistics {
export interface TrackNode { export interface TrackNode {
id: string; id: string;
name?: string; name?: string;
/** Style of the track, only present when the file defines it. */
color?: string;
opacity?: number;
width?: number;
segments: SegmentNode[]; segments: SegmentNode[];
} }
export interface SegmentNode { export interface SegmentNode {
@@ -387,6 +391,7 @@ export interface SegmentNode {
export interface WaypointNode { export interface WaypointNode {
id: string; id: string;
name?: string; name?: string;
sym?: string;
} }
"#; "#;
@@ -432,6 +437,15 @@ fn structure_object(file: &engine::FileStructure) -> Object {
"tracks", "tracks",
array(&file.tracks, |trk| { array(&file.tracks, |trk| {
let node = named_node(trk.id.0, trk.name.as_deref()); let node = named_node(trk.id.0, trk.name.as_deref());
if let Some(color) = &trk.color {
set(&node, "color", color.as_str());
}
if let Some(opacity) = trk.opacity {
set(&node, "opacity", opacity);
}
if let Some(width) = trk.width {
set(&node, "width", width);
}
set( set(
&node, &node,
"segments", "segments",
@@ -449,7 +463,11 @@ fn structure_object(file: &engine::FileStructure) -> Object {
&node, &node,
"waypoints", "waypoints",
array(&file.waypoints, |wpt| { array(&file.waypoints, |wpt| {
named_node(wpt.id.0, wpt.name.as_deref()).into() let node = named_node(wpt.id.0, wpt.name.as_deref());
if let Some(sym) = &wpt.sym {
set(&node, "sym", sym.as_str());
}
node.into()
}), }),
); );
set(&node, "waypointsRev", file.wpt_rev_id.0.to_string()); set(&node, "waypointsRev", file.wpt_rev_id.0.to_string());
+46 -15
View File
@@ -1,5 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { get, writable, type Readable, type Writable } from 'svelte/store'; import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs'; import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
@@ -23,6 +24,11 @@ export type SegmentProperties = {
segmentIndex: number; segmentIndex: number;
/** Changes when the trackpoints of the segment change. */ /** Changes when the trackpoints of the segment change. */
rev: string; rev: string;
/** Color of the track, or else the base color of the file. */
color: string;
/** Only when the track defines it. */
opacity?: number;
width?: number;
}; };
export type WaypointProperties = { export type WaypointProperties = {
@@ -30,11 +36,14 @@ export type WaypointProperties = {
waypointId: string; waypointId: string;
index: number; index: number;
name?: string; name?: string;
sym?: string;
}; };
/** Everything the UI knows about one file. */ /** Everything the UI knows about one file. */
export type FileState = { export type FileState = {
structure: FileStructure; structure: FileStructure;
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */ /** Global statistics of the file. */
statistics: FileStatistics; statistics: FileStatistics;
/** One LineString feature per track segment, in file order. */ /** One LineString feature per track segment, in file order. */
@@ -79,6 +88,7 @@ class Engine {
readonly ready: Promise<void>; readonly ready: Promise<void>;
private _order = writable<string[]>([]); private _order = writable<string[]>([]);
private _colors = new FileColorAllocator();
private _files = writable<Map<string, Writable<FileState>>>(new Map()); private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' }); private _selection = writable<Selection>({ type: 'empty' });
@@ -188,7 +198,10 @@ class Engine {
if (added.length > 0 || update.removed.length > 0) { if (added.length > 0 || update.removed.length > 0) {
this._files.update((files) => { this._files.update((files) => {
const next = new Map(files); const next = new Map(files);
update.removed.forEach((id) => next.delete(id)); update.removed.forEach((id) => {
next.delete(id);
this._colors.release(id);
});
added.forEach(([id, state]) => next.set(id, writable(state))); added.forEach(([id, state]) => next.set(id, writable(state)));
return next; return next;
}); });
@@ -212,18 +225,34 @@ class Engine {
return null; return null;
} }
const color = this._colors.resolve(
id,
structure.tracks.map((track) => track.color)
);
const previousSegments = new Map( const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? [] previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
); );
const segments: Feature<LineString, SegmentProperties>[] = []; const segments: Feature<LineString, SegmentProperties>[] = [];
structure.tracks.forEach((track, trackIndex) => { structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => { track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
};
const old = previousSegments.get(segment.id); const old = previousSegments.get(segment.id);
const unchanged = const unchanged =
old?.properties.rev === segment.rev && old !== undefined &&
old.properties.trackId === track.id && (Object.keys(properties) as (keyof SegmentProperties)[]).every(
old.properties.trackIndex === trackIndex && (key) => old.properties[key] === properties[key]
old.properties.segmentIndex === segmentIndex; );
segments.push( segments.push(
unchanged unchanged
? old ? old
@@ -233,14 +262,7 @@ class Engine {
type: 'LineString', type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)), coordinates: toPositions(wasm.segment_coordinates(segment.id)),
}, },
properties: { properties,
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
},
} }
); );
}); });
@@ -253,7 +275,9 @@ class Engine {
previousStructure.waypoints.length === structure.waypoints.length && previousStructure.waypoints.length === structure.waypoints.length &&
previousStructure.waypoints.every( previousStructure.waypoints.every(
(w, i) => (w, i) =>
w.id === structure.waypoints[i].id && w.name === structure.waypoints[i].name w.id === structure.waypoints[i].id &&
w.name === structure.waypoints[i].name &&
w.sym === structure.waypoints[i].sym
); );
let waypoints = previous?.waypoints; let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) { if (!waypoints || !waypointsUnchanged) {
@@ -263,13 +287,20 @@ class Engine {
features: structure.waypoints.map((waypoint, index) => ({ features: structure.waypoints.map((waypoint, index) => ({
type: 'Feature', type: 'Feature',
geometry: { type: 'Point', coordinates: coordinates[index] }, geometry: { type: 'Point', coordinates: coordinates[index] },
properties: { fileId: id, waypointId: waypoint.id, index, name: waypoint.name }, properties: {
fileId: id,
waypointId: waypoint.id,
index,
name: waypoint.name,
sym: waypoint.sym,
},
})), })),
}; };
} }
return { return {
structure, structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS, statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments }, segments: { type: 'FeatureCollection', features: segments },
waypoints, waypoints,
+59
View File
@@ -0,0 +1,59 @@
const PALETTE = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a',
];
/** GPX style colors are hex digits, with or without the leading `#`. */
export function normalizeColor(color: string): string {
return /^[0-9a-f]{6}$/i.test(color) ? `#${color}` : color;
}
/**
* Picks the base color of each file: the color defined by the file itself if any, otherwise a
* palette color, the least used one when the file first shows up. A file keeps its palette color
* for its whole lifetime, so that it does not change when style information comes and goes
* (edits, undo, redo).
*/
export class FileColorAllocator {
/** Palette color reserved for each file. */
private _reserved = new Map<string, string>();
/** Color currently displayed for each file. */
private _base = new Map<string, string>();
/** Updates (and returns) the base color of a file, given the colors defined by its tracks. */
resolve(fileId: string, trackColors: (string | undefined)[]): string {
const explicit = trackColors.find((color) => color !== undefined);
if (explicit === undefined && !this._reserved.has(fileId)) {
this._reserved.set(fileId, this.leastUsed());
}
const color =
explicit !== undefined ? normalizeColor(explicit) : this._reserved.get(fileId)!;
this._base.set(fileId, color);
return color;
}
release(fileId: string) {
this._reserved.delete(fileId);
this._base.delete(fileId);
}
private leastUsed(): string {
const usage = new Map(PALETTE.map((color) => [color, 0]));
this._base.forEach((color) => {
const count = usage.get(color);
if (count !== undefined) {
usage.set(color, count + 1);
}
});
return PALETTE.reduce((a, b) => (usage.get(a)! <= usage.get(b)! ? a : b));
}
}