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:
@@ -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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user