feat(05-04): route paint stack + endpoint markers + load-hook install + shell chrome

- 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
This commit is contained in:
araharah-executor
2026-10-24 05:29:25 +08:00
parent d0751c1a33
commit 01a75f1fbe
4 changed files with 294 additions and 2 deletions
+135
View File
@@ -0,0 +1,135 @@
/*
* 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: [] };
}