diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index 70ac44d..d67b0d7 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -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( diff --git a/apps/planner/app/lib/color-runs.test.ts b/apps/planner/app/lib/color-runs.test.ts new file mode 100644 index 0000000..1571e7c --- /dev/null +++ b/apps/planner/app/lib/color-runs.test.ts @@ -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); + }); +}); diff --git a/apps/planner/app/lib/color-runs.ts b/apps/planner/app/lib/color-runs.ts new file mode 100644 index 0000000..1ac5eea --- /dev/null +++ b/apps/planner/app/lib/color-runs.ts @@ -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; +}