mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +00:00
progress
This commit is contained in:
@@ -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,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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user