perf(planner): group colored route into color runs, not 1 polyline/coord
ColoredRoute rendered one interactive-registered <Polyline> per coordinate pair — ~14.6k on a 485 km route — flooding Leaflet's SVG layer and event-target map. Run-length group consecutive same-color segments into a single polyline each (new pure buildColorRuns helper, unit-tested). Discrete modes (surface/highway/…) collapse to a handful; the elevation gradient is quantized into 24 buckets so its runs merge too. Rendering is otherwise identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
75b28919c4
commit
413c46623e
3 changed files with 132 additions and 158 deletions
|
|
@ -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(
|
||||
|
|
|
|||
44
apps/planner/app/lib/color-runs.test.ts
Normal file
44
apps/planner/app/lib/color-runs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
apps/planner/app/lib/color-runs.ts
Normal file
44
apps/planner/app/lib/color-runs.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue