/* * route-layers.ts — the D-09 route paint stack (05-UI-SPEC §Color). * --------------------------------------------------------------------- * FOUR line layers over the grayscale basemap, bottom→top: the unselected * mini-stack (route-alt-casing matte-black 5px / route-alt-line soft-beige * 3px) under the selected stack (route-casing matte-black 9px / route-line * signal-orange 5px). ALL opacity 1.0 — physical planarity: a translucent * route is haze, not an instrument. line-cap square + line-join bevel * (square caps match Rams glyph caps; bevel: no miter spikes on sharp OSM * angles). Solid centerline — the mockup's dash was scrubbed residue. * * DSGN-01 audit note: MapLibre paint cannot consume CSS variables — the * three hex values are copied VERBATIM from tokens.css as build-time * constants (the building-3d precedent: zero invented colors); the Phase 1 * off-token sweep pattern covers them. * * THE STYLE IS NEVER RE-SET (T-05-09): sources "route" + "route-alt" are * added EMPTY once at load and filled/cleared via setData — sources are * data, not style. Selection swaps are setData on existing sources; the * layers themselves are static. Added with NO beforeId, inside the ONE * map.on("load") handler, AFTER the building-3d block — so the route paints * above every basemap layer including building-3d. * * Node-testable: parameterized on the structural MapLike (METHOD syntax — * parameter bivariance) so tests drive a recording fake; MapLibreMap * satisfies it at the call sites (a typed alias at the call site — never * `any`). */ import type { RoutePoint } from "@araharah/routing/src/index.ts"; export interface MapLike { addSource(id: string, data: unknown): void; addLayer(layer: unknown): void; getSource(id: string): { setData(data: unknown): void } | undefined; fitBounds(bounds: unknown, options?: unknown): void; getBearing(): number; } /** Verbatim hex from theme tokens.css (DSGN-01 — zero invented colors). */ export const ROUTE_PAINT = { casingColor: "#1A1A1A", // matte-black lineColor: "#EB8008", // signal-orange — the route is THE accent altLineColor: "#D9D2C6", // soft-beige } as const; export const ROUTE_LAYER_IDS = ["route-alt-casing", "route-alt-line", "route-casing", "route-line"] as const; export type RouteLayerId = (typeof ROUTE_LAYER_IDS)[number]; const SOURCE_OF: Record = { "route-alt-casing": "route-alt", "route-alt-line": "route-alt", "route-casing": "route", "route-line": "route", }; const COLOR_OF: Record = { "route-alt-casing": ROUTE_PAINT.casingColor, "route-alt-line": ROUTE_PAINT.altLineColor, "route-casing": ROUTE_PAINT.casingColor, "route-line": ROUTE_PAINT.lineColor, }; const WIDTH_OF: Record = { "route-alt-casing": 5, "route-alt-line": 3, "route-casing": 9, "route-line": 5, }; export interface LineFeature { type: "Feature"; properties: Record; geometry: { type: "LineString"; coordinates: [number, number][] }; } export interface FeatureCollection { type: "FeatureCollection"; features: LineFeature[]; } /** Add the two EMPTY GeoJSON sources + the four line layers, in order, no * beforeId. Idempotent: a map that already carries the "route" source is * left untouched (the load handler can fire once — this guard is the * belt to that suspenders). */ export function installRouteLayers(map: MapLike): void { if (map.getSource("route") !== undefined) return; // idempotence guard (T-05-09) map.addSource("route", { type: "geojson", data: emptyCollection() }); map.addSource("route-alt", { type: "geojson", data: emptyCollection() }); for (const id of ROUTE_LAYER_IDS) map.addLayer(lineLayer(id)); } /** setData on BOTH sources: "route" ← the selected LineString collection, * "route-alt" ← one Feature per alternate (0..2 features). Instant swap — * no transition, no crossfade (UI-SPEC Micro-Behaviors). */ export function setRouteDrawn( map: MapLike, selected: { points: RoutePoint[] }, alts: readonly { points: RoutePoint[] }[], ): void { map.getSource("route")?.setData(featureCollection([selected.points])); map.getSource("route-alt")?.setData(featureCollection(alts.map((alt) => alt.points))); } /** Both sources ← empty FeatureCollections (the SEARCH AGAIN / walk-back clear). */ export function clearRouteLayers(map: MapLike): void { map.getSource("route")?.setData(emptyCollection()); map.getSource("route-alt")?.setData(emptyCollection()); } function lineLayer(id: RouteLayerId): unknown { return { id, type: "line", source: SOURCE_OF[id], layout: { "line-cap": "square", "line-join": "bevel" }, paint: { "line-color": COLOR_OF[id], "line-width": WIDTH_OF[id], "line-opacity": 1 }, }; } function featureCollection(pointLists: readonly RoutePoint[][]): FeatureCollection { return { type: "FeatureCollection", features: pointLists.map(lineFeature) }; } function lineFeature(points: readonly RoutePoint[]): LineFeature { return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: points.map((p): [number, number] => [p.lng, p.lat]) }, }; } function emptyCollection(): FeatureCollection { return { type: "FeatureCollection", features: [] }; }