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: [] };
}
+114
View File
@@ -0,0 +1,114 @@
/*
* route-markers.ts — the D-09 endpoint furniture: origin ring + destination pin.
* -------------------------------------------------------------------------------
* Origin ring: 24px diameter circle, 2px matte-black stroke, NO fill,
* anchored CENTER on the route geometry's FIRST vertex — the route's actual
* origin, which may sit meters from the live fix (fixture world). The
* position marker (dot + cone + accuracy circle) renders unchanged beneath
* it. Decorative, aria-hidden.
*
* Destination pin: matte-black filled square (12×12) + narrow triangular
* tail pointing DOWN at the destination coordinate — 3px wide, 8px tall,
* 20px total glyph height (UI-SPEC §Spacing Scale machined geometry).
* NEVER orange — the pin is venue semantics, orange is route-only (D-09).
* A 1px pure-white inset rule keeps it readable on dark map tones. The
* glyph is aria-hidden; the NAME CHIP above it is real text (the card is
* the semantic statement — map furniture never is).
*
* Name chip: matte-black plate above the pin (offset space-sm), pure-white
* label-sm uppercase, venue name VERBATIM, single line, max-width 160px,
* ellipsis overflow — the attribution-chip instrument-plate precedent.
*
* Endpoint join: the route line terminates UNDER the pin; the origin line
* starts INSIDE the ring — the sources' geometry and these anchors are the
* join (markers paint above all canvas layers; MapLibre DOM law).
*
* Attached markers are tracked per-map in a WeakMap so
* removeRouteEndpoints removes exactly what attach created (selection
* re-attach never stacks duplicates).
*/
import { Marker } from "maplibre-gl";
import type { RoutePoint } from "@araharah/routing/src/index.ts";
/** The minimal map surface the markers need — Marker.addTo's own parameter
* (structural; MapLibreMap satisfies it at every call site). */
type MarkerMap = Parameters<Marker["addTo"]>[0];
const SVG_NS = "http://www.w3.org/2000/svg";
const endpointMarkers = new WeakMap<MarkerMap, Marker[]>();
export function attachRouteEndpoints(
map: MarkerMap,
firstPoint: RoutePoint,
lastPoint: RoutePoint,
destinationName: string,
): void {
removeRouteEndpoints(map); // idempotent — a selection re-attach never stacks
const ring = new Marker({ element: buildOriginRing(), anchor: "center" })
.setLngLat([firstPoint.lng, firstPoint.lat])
.addTo(map);
const pin = new Marker({ element: buildDestinationPin(destinationName), anchor: "bottom" })
.setLngLat([lastPoint.lng, lastPoint.lat])
.addTo(map);
endpointMarkers.set(map, [ring, pin]);
}
export function removeRouteEndpoints(map: MarkerMap): void {
for (const marker of endpointMarkers.get(map) ?? []) marker.remove();
endpointMarkers.delete(map);
}
function buildOriginRing(): HTMLElement {
const group = document.createElement("div");
group.className = "route-origin-ring";
group.setAttribute("aria-hidden", "true");
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("width", "24");
svg.setAttribute("height", "24");
svg.setAttribute("viewBox", "-12 -12 24 24");
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", "0");
circle.setAttribute("cy", "0");
circle.setAttribute("r", "11"); // +2px stroke = the 24px machined diameter
svg.appendChild(circle);
group.appendChild(svg);
return group;
}
function buildDestinationPin(destinationName: string): HTMLElement {
const group = document.createElement("div");
group.className = "route-dest-pin";
const glyph = document.createElement("div");
glyph.className = "route-dest-pin-glyph";
glyph.setAttribute("aria-hidden", "true");
// viewBox spans the 12px square (-6..6) plus the 8px tail (y 6..14):
// a 20px-tall glyph. Square + needle tail, orthogonal family.
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("width", "14");
svg.setAttribute("height", "22");
svg.setAttribute("viewBox", "-7 -7 14 22");
const square = document.createElementNS(SVG_NS, "rect");
square.setAttribute("x", "-6");
square.setAttribute("y", "-6");
square.setAttribute("width", "12");
square.setAttribute("height", "12");
const inset = document.createElementNS(SVG_NS, "rect");
inset.setAttribute("class", "inset-rule");
inset.setAttribute("x", "-5");
inset.setAttribute("y", "-5");
inset.setAttribute("width", "10");
inset.setAttribute("height", "10");
const tail = document.createElementNS(SVG_NS, "polygon");
tail.setAttribute("points", "-1.5,6 1.5,6 0,14"); // 3px wide, 8px tall
svg.append(square, inset, tail);
glyph.appendChild(svg);
const nameChip = document.createElement("div");
nameChip.className = "route-dest-namechip";
nameChip.textContent = destinationName; // verbatim venue name — textContent only (T-05-10)
group.append(glyph, nameChip);
return group;
}