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
|
||||
|
||||
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 } }) => {
|
||||
|
||||
@@ -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-*) /
|
||||
* 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). */
|
||||
|
||||
Reference in New Issue
Block a user