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
+11 -1
View File
@@ -22,7 +22,7 @@ import { KL_CENTER } from "../location/types.ts";
setWorkerUrl(workerUrl); // required for ALL bundlers in v6
export function bootMap(container: HTMLElement): MapLibreMap | null {
export function bootMap(container: HTMLElement, opts: { onLoad?: (map: MapLibreMap) => void } = {}): MapLibreMap | null {
try {
const map = new MapLibreMap({
container,
@@ -63,6 +63,16 @@ export function bootMap(container: HTMLElement): MapLibreMap | null {
} catch {
// extrusion is enhancement, never load-bearing — flat map still boots
}
try {
// Route layers install HERE — after building-3d, before the ready
// log (the capture race implies route sources exist by READY), with
// no beforeId: additions only, inside this ONE load handler — the
// style is never re-set (T-05-09). A route-layer failure degrades
// to "no route drawn", never a broken boot.
opts.onLoad?.(map);
} catch {
// route rendering is enhancement; the basemap boot is not
}
console.log("RAMS-BASEMAP-READY layers=" + map.getStyle().layers.map((l) => l.id).length);
});
map.on("error", (e: { error?: { message?: string } }) => {
+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;
}
+34 -1
View File
@@ -4,7 +4,11 @@
* Every value is a token reference (var(--color-*) / var(--spacing-*) /
* var(--type-*)); the only literals are the machined-border geometry the
* UI-SPEC enumerates (1px/2px rules, 8px lamp/gantry/notch, 40x40 plate,
-2px hit slop, 320px card max-width, 20px dot as SVG r=10). Zero
-2px hit slop, 320px card max-width, 20px dot as SVG r=10; Phase 5 adds:
chip plate 28px tall + -8px hit slop (44px target), origin ring 24px/2px,
destination pin 12px square + 3px×8px tail (20px glyph), route line
widths 9/5/3 map-space paint, the 64px control-clearance composite,
160px name-chip max-width, 3px focus bracket). Zero
* roundedness (the one `border-radius: 0` reset pins MapLibre's chrome),
* zero transition/animation — state changes are instant hard swaps.
*
@@ -102,5 +106,34 @@ main { position: fixed; inset: 0; }
.pos-cone { fill: var(--color-signal-orange); }
.pos-group[data-stale="true"] { opacity: 0.4; }
/* route state stack (second register holder — UI-SPEC §State Coexistence).
The Phase 3 .card-stack logic stays byte-identical; the route surface gets
its OWN stack so the pending card (center) and the signal-lost card
(bottom, Phase 3 stack) can coexist in different registers. */
.route-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); max-width: 320px; margin-inline: auto; }
.route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); }
.route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); }
/* route card body rows — token rhythm composing with the existing .card child
rules (NOT overrides; UI-SPEC post-approval padding addendum) */
.route-telemetry { display: flex; align-items: baseline; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-diff { padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-chips { display: flex; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-sm); }
.route-chips:last-child { padding-bottom: var(--spacing-space-sm); }
/* chips (UI-SPEC chip contract): 28px plate, 44px hit target, instant invert.
No persistent selected-chip state — the selected route lives in the card. */
.route-chip { position: relative; border: 0; border-radius: 0; padding: var(--spacing-space-xs) var(--spacing-space-sm); background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; white-space: nowrap; }
.route-chip::after { content: ""; position: absolute; inset: -8px; }
.route-chip:active { background: var(--color-signal-orange); color: var(--color-matte-black); }
.route-chip:focus-visible { outline: 3px solid var(--color-signal-orange); outline-offset: 2px; }
/* endpoint markers (map furniture — SVG chrome, machined geometry) */
.route-origin-ring circle { fill: none; stroke: var(--color-matte-black); stroke-width: 2; }
.route-dest-pin-glyph rect { fill: var(--color-matte-black); }
.route-dest-pin-glyph polygon { fill: var(--color-matte-black); }
.route-dest-pin-glyph .inset-rule { fill: none; stroke: var(--color-pure-white); stroke-width: 1; }
.route-dest-namechip { position: absolute; bottom: calc(100% + var(--spacing-space-sm)); left: 50%; transform: translateX(-50%); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; padding: var(--spacing-space-xs) var(--spacing-space-sm); }
/* No CSS transition or animation anywhere — state changes are instant hard swaps. */
/* DEBUG-ROUND probe styles removed at the device-matrix exit (2026-10-23). */