From 9ac2a2b69c1407fbdd48b1a1bf2359b3c507835f Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 08:53:00 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/core/io/parse.rs | 4 +- .../src/engine/derived/file_structure.rs | 13 ++++ gpx-rs/engine/src/engine/engine.rs | 3 +- gpx-rs/wasm/src/lib.rs | 20 +++++- website/src/lib/engine.ts | 61 ++++++++++++++----- website/src/lib/file-colors.ts | 59 ++++++++++++++++++ 6 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 website/src/lib/file-colors.ts diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index 61613b812..671b1f4d6 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -291,7 +291,7 @@ pub fn parse(data: &[u8]) -> Result { Some(GPXElement::Color) => { stack.pop(); 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) => { @@ -572,7 +572,7 @@ mod tests { assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; 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.width.is_some_and(|w| w == 6.0)); } diff --git a/gpx-rs/engine/src/engine/derived/file_structure.rs b/gpx-rs/engine/src/engine/derived/file_structure.rs index ba17136b0..98ebd6426 100644 --- a/gpx-rs/engine/src/engine/derived/file_structure.rs +++ b/gpx-rs/engine/src/engine/derived/file_structure.rs @@ -22,6 +22,10 @@ pub struct FileStructure { pub struct TrackNode { pub id: TrackId, pub name: Option, + /// Style of the track, only when the file defines it. + pub color: Option, + pub opacity: Option, + pub width: Option, pub segments: Vec, } @@ -37,6 +41,7 @@ pub struct SegmentNode { pub struct WaypointNode { pub id: WaypointId, pub name: Option, + pub sym: Option, } impl FileStructure { @@ -50,6 +55,9 @@ impl FileStructure { .map(|trk| TrackNode { id: trk.id, name: trk.info.name.clone(), + color: trk.info.color.clone(), + opacity: trk.info.opacity, + width: trk.info.width, segments: trk .trkseg .iter() @@ -68,6 +76,7 @@ impl FileStructure { .map(|wpt| WaypointNode { id: wpt.id, name: wpt.name.clone(), + sym: wpt.sym.clone(), }) .collect(), wpt_rev_id: file.wpt_rev_id, @@ -94,6 +103,10 @@ mod tests { assert_eq!(node.tracks.len(), file.trk.len()); for (n, t) in node.tracks.iter().zip(&file.trk) { 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()); 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())); diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 593b3908e..b258924fa 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -2,7 +2,8 @@ use crate::{ 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)] diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index aab38bd18..c4d261816 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -375,6 +375,10 @@ export interface FileStatistics { export interface TrackNode { id: string; name?: string; + /** Style of the track, only present when the file defines it. */ + color?: string; + opacity?: number; + width?: number; segments: SegmentNode[]; } export interface SegmentNode { @@ -387,6 +391,7 @@ export interface SegmentNode { export interface WaypointNode { id: string; name?: string; + sym?: string; } "#; @@ -432,6 +437,15 @@ fn structure_object(file: &engine::FileStructure) -> Object { "tracks", array(&file.tracks, |trk| { 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( &node, "segments", @@ -449,7 +463,11 @@ fn structure_object(file: &engine::FileStructure) -> Object { &node, "waypoints", 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()); diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 0a7539154..852510e92 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -1,5 +1,6 @@ import { browser } from '$app/environment'; 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 { FileStatistics, FileStructure, Selection } from 'gpx-rs'; @@ -23,6 +24,11 @@ export type SegmentProperties = { segmentIndex: number; /** Changes when the trackpoints of the segment change. */ 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 = { @@ -30,11 +36,14 @@ export type WaypointProperties = { waypointId: string; index: number; name?: string; + sym?: string; }; /** Everything the UI knows about one file. */ export type FileState = { 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. */ statistics: FileStatistics; /** One LineString feature per track segment, in file order. */ @@ -79,6 +88,7 @@ class Engine { readonly ready: Promise; private _order = writable([]); + private _colors = new FileColorAllocator(); private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); @@ -188,7 +198,10 @@ class Engine { if (added.length > 0 || update.removed.length > 0) { this._files.update((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))); return next; }); @@ -212,18 +225,34 @@ class Engine { return null; } + const color = this._colors.resolve( + id, + structure.tracks.map((track) => track.color) + ); + const previousSegments = new Map( previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? [] ); const segments: Feature[] = []; structure.tracks.forEach((track, trackIndex) => { 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 unchanged = - old?.properties.rev === segment.rev && - old.properties.trackId === track.id && - old.properties.trackIndex === trackIndex && - old.properties.segmentIndex === segmentIndex; + old !== undefined && + (Object.keys(properties) as (keyof SegmentProperties)[]).every( + (key) => old.properties[key] === properties[key] + ); segments.push( unchanged ? old @@ -233,14 +262,7 @@ class Engine { type: 'LineString', coordinates: toPositions(wasm.segment_coordinates(segment.id)), }, - properties: { - fileId: id, - trackId: track.id, - segmentId: segment.id, - trackIndex, - segmentIndex, - rev: segment.rev, - }, + properties, } ); }); @@ -253,7 +275,9 @@ class Engine { previousStructure.waypoints.length === structure.waypoints.length && previousStructure.waypoints.every( (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; if (!waypoints || !waypointsUnchanged) { @@ -263,13 +287,20 @@ class Engine { features: structure.waypoints.map((waypoint, index) => ({ type: 'Feature', 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 { structure, + color, statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS, segments: { type: 'FeatureCollection', features: segments }, waypoints, diff --git a/website/src/lib/file-colors.ts b/website/src/lib/file-colors.ts new file mode 100644 index 000000000..cc0fac6e1 --- /dev/null +++ b/website/src/lib/file-colors.ts @@ -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(); + /** Color currently displayed for each file. */ + private _base = new Map(); + + /** 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)); + } +}