Merge pull request #612 from trails-cool/feat/planner-route-segment-perf

perf(planner): group colored route into color runs (not one polyline per coordinate)
This commit is contained in:
Ullrich Schäfer 2026-07-16 08:37:03 +02:00 committed by GitHub
commit 0f371d868f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 132 additions and 158 deletions

View file

@ -12,6 +12,7 @@ import {
} from "@trails-cool/map-core";
import type { ColorMode } from "~/lib/route-data";
import { buildColorRuns } from "~/lib/color-runs";
// ColorMode lives in the routeData schema module; re-exported here for
// existing importers.
@ -31,174 +32,59 @@ interface ColoredRouteProps {
export function ColoredRoute({ coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes }: ColoredRouteProps) {
const segments = useMemo(() => {
if (colorMode === "plain" || coordinates.length < 2) {
return null;
}
const n = coordinates.length;
if (colorMode === "plain" || n < 2) return null;
// Build a per-segment color function (segment i spans coord i → i+1).
// Return null when the required data channel isn't available yet.
let colorAt: (i: number) => string;
if (colorMode === "elevation") {
const elevations = coordinates.map((c) => c[2]);
const minEle = Math.min(...elevations);
const maxEle = Math.max(...elevations);
const range = maxEle - minEle || 1;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const t = (elevations[i]! - minEle) / range;
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: elevationColor(t),
});
}
return result;
}
if (colorMode === "grade") {
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const eles = coordinates.map((c) => c[2]);
const minEle = Math.min(...eles);
const range = Math.max(...eles) - minEle || 1;
// Quantize the gradient into buckets so equal-color runs can merge —
// otherwise every segment is a distinct color (one polyline each).
const BUCKETS = 24;
colorAt = (i) => {
const t = (eles[i]! - minEle) / range;
return elevationColor(Math.round(t * BUCKETS) / BUCKETS);
};
} else if (colorMode === "grade") {
colorAt = (i) => {
const c0 = coordinates[i]!;
const c1 = coordinates[i + 1]!;
// Approximate distance in meters using lat/lon diff
const dLat = (c1[1] - c0[1]) * 111320;
const dLon = (c1[0] - c0[0]) * 111320 * Math.cos((c0[1] * Math.PI) / 180);
const dist = Math.sqrt(dLat * dLat + dLon * dLon);
const grade = dist > 0 ? ((c1[2] - c0[2]) / dist) * 100 : 0;
result.push({
positions: [
[c0[1], c0[0]],
[c1[1], c1[0]],
],
color: routeGradeColor(grade),
});
}
return result;
return routeGradeColor(grade);
};
} else if (colorMode === "highway") {
if (!highways || highways.length < n) return null;
colorAt = (i) => HIGHWAY_COLORS[highways[i] ?? "unknown"] ?? DEFAULT_HIGHWAY_COLOR;
} else if (colorMode === "maxspeed") {
if (!maxspeeds || maxspeeds.length < n) return null;
colorAt = (i) => maxspeedColor(maxspeeds[i] ?? "unknown");
} else if (colorMode === "smoothness") {
if (!smoothnesses || smoothnesses.length < n) return null;
colorAt = (i) => SMOOTHNESS_COLORS[smoothnesses[i] ?? "unknown"] ?? DEFAULT_SMOOTHNESS_COLOR;
} else if (colorMode === "tracktype") {
if (!tracktypes || tracktypes.length < n) return null;
colorAt = (i) => TRACKTYPE_COLORS[tracktypes[i] ?? "unknown"] ?? DEFAULT_TRACKTYPE_COLOR;
} else if (colorMode === "cycleway") {
if (!cycleways || cycleways.length < n) return null;
colorAt = (i) => CYCLEWAY_COLORS[cycleways[i] ?? "unknown"] ?? DEFAULT_CYCLEWAY_COLOR;
} else if (colorMode === "bikeroute") {
if (!bikeroutes || bikeroutes.length < n) return null;
colorAt = (i) => BIKEROUTE_COLORS[bikeroutes[i] ?? "none"] ?? DEFAULT_BIKEROUTE_COLOR;
} else {
// surface
if (!surfaces || surfaces.length < n) return null;
colorAt = (i) => SURFACE_COLORS[surfaces[i] ?? "unknown"] ?? DEFAULT_SURFACE_COLOR;
}
// highway mode
if (colorMode === "highway") {
if (!highways || highways.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const highway = highways[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR,
});
}
return result;
}
// maxspeed mode
if (colorMode === "maxspeed") {
if (!maxspeeds || maxspeeds.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const speed = maxspeeds[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: maxspeedColor(speed),
});
}
return result;
}
// smoothness mode
if (colorMode === "smoothness") {
if (!smoothnesses || smoothnesses.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const smoothness = smoothnesses[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR,
});
}
return result;
}
// tracktype mode
if (colorMode === "tracktype") {
if (!tracktypes || tracktypes.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const tracktype = tracktypes[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR,
});
}
return result;
}
// cycleway mode
if (colorMode === "cycleway") {
if (!cycleways || cycleways.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const cycleway = cycleways[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR,
});
}
return result;
}
// bikeroute mode
if (colorMode === "bikeroute") {
if (!bikeroutes || bikeroutes.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const bikeroute = bikeroutes[i] ?? "none";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR,
});
}
return result;
}
// surface mode
if (!surfaces || surfaces.length < coordinates.length) return null;
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
for (let i = 0; i < coordinates.length - 1; i++) {
const surface = surfaces[i] ?? "unknown";
result.push({
positions: [
[coordinates[i]![1], coordinates[i]![0]],
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
],
color: SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR,
});
}
return result;
return buildColorRuns(coordinates, colorAt);
}, [coordinates, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes]);
const plainPositions = useMemo(

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { buildColorRuns } from "./color-runs.ts";
// coord i = [lon=i, lat=100+i, ele=0] → emitted point = [lat, lon] = [100+i, i]
const coords = (n: number): [number, number, number][] =>
Array.from({ length: n }, (_, i) => [i, 100 + i, 0]);
describe("buildColorRuns", () => {
it("returns nothing for fewer than 2 coordinates", () => {
expect(buildColorRuns([], () => "a")).toEqual([]);
expect(buildColorRuns(coords(1), () => "a")).toEqual([]);
});
it("collapses a uniform-color route to a single polyline", () => {
const runs = buildColorRuns(coords(1000), () => "#abc");
expect(runs).toHaveLength(1);
expect(runs[0]!.color).toBe("#abc");
// one polyline through all 1000 points (not ~999 segments)
expect(runs[0]!.positions).toHaveLength(1000);
expect(runs[0]!.positions[0]).toEqual([100, 0]);
expect(runs[0]!.positions[999]).toEqual([1099, 999]);
});
it("splits into contiguous runs at color changes, sharing boundary points", () => {
// 5 coords → segments 0..3; colors: A A B A
const seg = ["A", "A", "B", "A"];
const runs = buildColorRuns(coords(5), (i) => seg[i]!);
expect(runs.map((r) => r.color)).toEqual(["A", "B", "A"]);
// run A: points 0,1,2 ; run B: points 2,3 ; run A: points 3,4
expect(runs[0]!.positions).toEqual([[100, 0], [101, 1], [102, 2]]);
expect(runs[1]!.positions).toEqual([[102, 2], [103, 3]]);
expect(runs[2]!.positions).toEqual([[103, 3], [104, 4]]);
// boundary points are shared so the drawn line stays continuous
expect(runs[0]!.positions.at(-1)).toEqual(runs[1]!.positions[0]);
expect(runs[1]!.positions.at(-1)).toEqual(runs[2]!.positions[0]);
});
it("every-segment-different still works (worst case)", () => {
const runs = buildColorRuns(coords(4), (i) => `c${i}`);
// segments 0,1,2 all distinct → 3 runs, each a 2-point polyline
expect(runs.map((r) => r.color)).toEqual(["c0", "c1", "c2"]);
expect(runs.every((r) => r.positions.length === 2)).toBe(true);
});
});

View file

@ -0,0 +1,44 @@
import type { LatLngExpression } from "leaflet";
export interface ColorRun {
positions: LatLngExpression[];
color: string;
}
/**
* Run-length group a route's segments into one polyline per contiguous run of
* the same color, instead of one polyline per coordinate pair. `colorAt(i)`
* returns the color of segment i (between coordinate i and i+1). Turns ~1
* polyline/coordinate (tens of thousands on a long route) into a handful, so
* Leaflet's SVG layer and event system aren't flooded. Rendering is otherwise
* identical: each run is a polyline through all its points in the run color.
*
* Coordinates are [lon, lat, ele]; positions are emitted as [lat, lon].
* Consecutive runs share their boundary point so the line stays continuous.
*/
export function buildColorRuns(
coordinates: [number, number, number][],
colorAt: (segmentIndex: number) => string,
): ColorRun[] {
const n = coordinates.length;
if (n < 2) return [];
const pt = (i: number): LatLngExpression => [coordinates[i]![1], coordinates[i]![0]];
const runs: ColorRun[] = [];
let start = 0;
let color = colorAt(0);
const pushRun = (from: number, toPoint: number, c: string) => {
const positions: LatLngExpression[] = [];
for (let j = from; j <= toPoint; j++) positions.push(pt(j));
runs.push({ positions, color: c });
};
for (let seg = 1; seg <= n - 2; seg++) {
const c = colorAt(seg);
if (c !== color) {
pushRun(start, seg, color); // segments [start..seg-1] → points [start..seg]
start = seg;
color = c;
}
}
pushRun(start, n - 1, color); // final run → last point
return runs;
}