- route-layers.ts: 2 empty GeoJSON sources + 4 line layers (route-alt-casing 5px matte-black / route-alt-line 3px soft-beige / route-casing 9px matte-black / route-line 5px signal-orange), opacity 1.0, cap square, join bevel, no beforeId — idempotent install, setData-only updates, zero setStyle (T-05-09); verbatim tokens.css hex (DSGN-01) - route-markers.ts: origin ring (24px, 2px matte-black, aria-hidden) + destination pin (12px square + 3px×8px tail, white inset rule, never orange) + 160px ellipsis name chip; WeakMap-tracked removal - map.ts: bootMap gains opts.onLoad — invoked inside the existing load handler AFTER building-3d, BEFORE the ready log, its own try/catch - shell.css: .route-stack registers (64px control-clearance composite), telemetry/diff/chips rows composing the .card child rules (no padding overrides), 28px chips with 44px ::after hit slop + instant :active invert + 3px focus bracket, marker chrome — token references only
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
/*
|
|
* 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<RouteLayerId, "route" | "route-alt"> = {
|
|
"route-alt-casing": "route-alt",
|
|
"route-alt-line": "route-alt",
|
|
"route-casing": "route",
|
|
"route-line": "route",
|
|
};
|
|
|
|
const COLOR_OF: Record<RouteLayerId, string> = {
|
|
"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<RouteLayerId, number> = {
|
|
"route-alt-casing": 5,
|
|
"route-alt-line": 3,
|
|
"route-casing": 9,
|
|
"route-line": 5,
|
|
};
|
|
|
|
export interface LineFeature {
|
|
type: "Feature";
|
|
properties: Record<string, string>;
|
|
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: [] };
|
|
}
|