diff --git a/web/src/map/map.ts b/web/src/map/map.ts index e16a171..2639c72 100644 --- a/web/src/map/map.ts +++ b/web/src/map/map.ts @@ -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 } }) => { diff --git a/web/src/route/route-layers.ts b/web/src/route/route-layers.ts new file mode 100644 index 0000000..400f12a --- /dev/null +++ b/web/src/route/route-layers.ts @@ -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 = { + "route-alt-casing": "route-alt", + "route-alt-line": "route-alt", + "route-casing": "route", + "route-line": "route", +}; + +const COLOR_OF: Record = { + "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 = { + "route-alt-casing": 5, + "route-alt-line": 3, + "route-casing": 9, + "route-line": 5, +}; + +export interface LineFeature { + type: "Feature"; + properties: Record; + 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: [] }; +} diff --git a/web/src/route/route-markers.ts b/web/src/route/route-markers.ts new file mode 100644 index 0000000..a257e58 --- /dev/null +++ b/web/src/route/route-markers.ts @@ -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[0]; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const endpointMarkers = new WeakMap(); + +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; +} diff --git a/web/src/shell.css b/web/src/shell.css index 7b685ce..b8f78cb 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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). */