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:
+11
-1
@@ -22,7 +22,7 @@ import { KL_CENTER } from "../location/types.ts";
|
|||||||
|
|
||||||
setWorkerUrl(workerUrl); // required for ALL bundlers in v6
|
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 {
|
try {
|
||||||
const map = new MapLibreMap({
|
const map = new MapLibreMap({
|
||||||
container,
|
container,
|
||||||
@@ -63,6 +63,16 @@ export function bootMap(container: HTMLElement): MapLibreMap | null {
|
|||||||
} catch {
|
} catch {
|
||||||
// extrusion is enhancement, never load-bearing — flat map still boots
|
// 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);
|
console.log("RAMS-BASEMAP-READY layers=" + map.getStyle().layers.map((l) => l.id).length);
|
||||||
});
|
});
|
||||||
map.on("error", (e: { error?: { message?: string } }) => {
|
map.on("error", (e: { error?: { message?: string } }) => {
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
}
|
||||||
@@ -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
@@ -4,7 +4,11 @@
|
|||||||
* Every value is a token reference (var(--color-*) / var(--spacing-*) /
|
* Every value is a token reference (var(--color-*) / var(--spacing-*) /
|
||||||
* var(--type-*)); the only literals are the machined-border geometry the
|
* var(--type-*)); the only literals are the machined-border geometry the
|
||||||
* UI-SPEC enumerates (1px/2px rules, 8px lamp/gantry/notch, 40x40 plate,
|
* 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),
|
* roundedness (the one `border-radius: 0` reset pins MapLibre's chrome),
|
||||||
* zero transition/animation — state changes are instant hard swaps.
|
* 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-cone { fill: var(--color-signal-orange); }
|
||||||
.pos-group[data-stale="true"] { opacity: 0.4; }
|
.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. */
|
/* 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). */
|
/* DEBUG-ROUND probe styles removed at the device-matrix exit (2026-10-23). */
|
||||||
|
|||||||
Reference in New Issue
Block a user