diff --git a/web/src/map/follow.ts b/web/src/map/follow.ts new file mode 100644 index 0000000..f39a024 --- /dev/null +++ b/web/src/map/follow.ts @@ -0,0 +1,43 @@ +/* + * follow.ts — the following/free camera controller (UI-SPEC §Follow / Recenter Control). + * -------------------------------------------------------------------------------------- + * The camera pads 96px (12 × space-sm) so the position marker never hides + * under the strip or the recenter control. Recenter is a 400ms flyTo (the + * MapLibre viewport behavior, permitted; duration ≤ 400ms per UI-SPEC + * Micro-Behaviors). A USER gesture (movestart carrying originalEvent) flips + * to free — a programmatic move never does (T-03-16: the fix update must + * never read as the user having panned). + */ +import type { Map as MapLibreMap } from "maplibre-gl"; +import type { Fix } from "../location/types.ts"; + +export const CAMERA_PADDING_PX = 96; // 12 x space-sm — marker never hides under strip or controls +export const RECENTER_DURATION_MS = 400; + +export type FollowMode = "following" | "free"; + +export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) { + let mode: FollowMode = "following"; + let lock = false; + const set = (m: FollowMode): void => { + if (m !== mode) { + mode = m; + onModeChange(m); + } + }; + map.on("movestart", (e: { originalEvent?: unknown }) => { + if (lock) return; + if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move + }); + return { + get mode(): FollowMode { + return mode; + }, + follow(fix: Fix): void { + lock = true; + map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS }); + set("following"); + lock = false; + }, + }; +} diff --git a/web/src/map/map.ts b/web/src/map/map.ts new file mode 100644 index 0000000..07b6741 --- /dev/null +++ b/web/src/map/map.ts @@ -0,0 +1,49 @@ +/* + * map.ts — the MapLibre v6 bootstrap (D-14, RESEARCH §Code Examples). + * --------------------------------------------------------------------- + * The style is the build-time-inlined Rams basemap JSON (liberty-rams.json + * via @araharah/theme/basemap — D-14, the style object is never re-set at + * runtime, D-10). v6 is ESM-only and WebGL2-only: `setWorkerUrl` is + * mandatory in bundlers (Vite's `?worker&url` emits a hashed asset), + * and the Map constructor throws `GPUInitializationError` on devices + * without WebGL2 — caught here and returned as null (RESEARCH Pitfall 5), + * so the shell can render its deliberate absent-map state instead of + * crashing (Open Question 1, answered in main.ts + the UI-SPEC amendment). + * + * The `RAMS-BASEMAP-READY` console prefix is load-bearing — the theme + * screenshot toolchain (theme/scripts/screenshot.mjs) and plan 03-04's + * capture race both wait on exactly that prefix. + */ +import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError, type StyleSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; +import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"; +import { KL_CENTER } from "../location/types.ts"; + +setWorkerUrl(workerUrl); // required for ALL bundlers in v6 + +export function bootMap(container: HTMLElement): MapLibreMap | null { + try { + const map = new MapLibreMap({ + container, + // resolveJsonModule infers `version: number`; the style spec needs the + // version literal — a Typed cast (never `any`, no shim) satisfies the + // StyleSpecification contract while keeping the build-time import. + style: ramsStyle as StyleSpecification, // inlined at build time (D-14) — NEVER re-set + center: KL_CENTER, + zoom: 13, + attributionControl: { compact: true }, + }); + map.on("error", (e: { error?: { message?: string } }) => { + const inner = e && e.error; + console.error("RAMS-BASEMAP-ERROR", inner && inner.message ? inner.message : inner); + }); + map.on("load", () => { + console.log("RAMS-BASEMAP-READY layers=" + map.getStyle().layers.map((l) => l.id).length); + }); + return map; + } catch (err) { + if (err instanceof GPUInitializationError) return null; // Pitfall 5: shell still renders + throw err; + } +} diff --git a/web/src/map/marker.ts b/web/src/map/marker.ts new file mode 100644 index 0000000..059ea4c --- /dev/null +++ b/web/src/map/marker.ts @@ -0,0 +1,75 @@ +/* + * marker.ts — the position marker (UI-SPEC §Position Marker + Accuracy Geometry). + * ------------------------------------------------------------------------------- + * One DOM Marker carrying: the accuracy circle (radius = projected metres, + * display-capped at 100m — the truth stays in the strip telemetry), the 20px + * matte-black dot, and the heading cone rotated to `heading` only when a + * heading exists — hidden entirely on null (no default orientation guess, + * no half-open cone; T-03-16 honesty). + * + * Marker colors are Rams-constant under both Telegram themes (map-constant + * rule, D-10). Signal-lost dims the WHOLE group to 40% via data-stale. + */ +import { Marker, type Map as MapLibreMap } from "maplibre-gl"; +import type { Fix } from "../location/types.ts"; + +const DISPLAY_ACCURACY_CAP_M = 100; + +export function accuracyRadiusPx(map: MapLibreMap, lat: number, metres: number): number { + const sw = map.getCanvas().width; + const mPerPx = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (sw * Math.pow(2, map.getZoom())); + return Math.min(metres, DISPLAY_ACCURACY_CAP_M) / mPerPx; +} + +export function createPositionMarker(): { + marker: Marker; + update(map: MapLibreMap, fix: Fix): void; + setStale(v: boolean): void; +} { + const group = document.createElement("div"); + group.className = "pos-group"; + const svgNs = "http://www.w3.org/2000/svg"; + // The SVG carries EXPLICIT width/height/viewBox and every child carries explicit + // cx/cy. An with no width/height renders at 300x150 (or collapses) and a + // with no cx/cy lands at (0,0) — both make the marker invisible. + const svg = document.createElementNS(svgNs, "svg"); + svg.setAttribute("width", "240"); + svg.setAttribute("height", "240"); + svg.setAttribute("viewBox", "-120 -120 240 240"); + const circle = document.createElementNS(svgNs, "circle"); + circle.setAttribute("class", "pos-accuracy"); + circle.setAttribute("cx", "0"); + circle.setAttribute("cy", "0"); + const cone = document.createElementNS(svgNs, "polygon"); + cone.setAttribute("class", "pos-cone"); + cone.setAttribute("points", "0,-8 6,5 0,2 -6,5"); + const dot = document.createElementNS(svgNs, "circle"); + dot.setAttribute("class", "pos-dot"); + dot.setAttribute("cx", "0"); + dot.setAttribute("cy", "0"); + dot.setAttribute("r", "10"); + // cone + dot are SVG-NAMESPACE nodes: they MUST be children of the , never + // of the HTML
— an SVG element directly under a
is not rendered. + svg.append(circle, cone, dot); + // MapLibre owns this group's DOM once addTo(map) runs — it parents it into the + // map canvas wrapper. DO NOT document.body.appendChild(group): an un-parented + // body child has no positioning CSS and paints UNDER the `inset: 0` map, so the + // marker would be invisible even though it exists. + group.appendChild(svg); + const marker = new Marker({ element: group }).setLngLat([0, 0]); + return { + marker, + update(map: MapLibreMap, fix: Fix) { + marker.setLngLat([fix.lng, fix.lat]); + circle.setAttribute("r", String(accuracyRadiusPx(map, fix.lat, fix.accuracyM ?? 0))); + if (fix.headingDeg === null) cone.style.display = "none"; // hidden when unknown — never a half-open guess + else { + cone.style.display = ""; + cone.style.transform = `rotate(${fix.headingDeg}deg)`; + } + }, + setStale(v: boolean) { + group.dataset.stale = String(v); + }, + }; +} diff --git a/web/src/shell.css b/web/src/shell.css new file mode 100644 index 0000000..4f3ccfd --- /dev/null +++ b/web/src/shell.css @@ -0,0 +1,86 @@ +/* + * shell.css — the Rams chrome (UI-SPEC §Shell Layout & Register Bands). + * ----------------------------------------------------------------------- + * 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 + * roundedness (the one `border-radius: 0` reset pins MapLibre's chrome), + * zero transition/animation — state changes are instant hard swaps. + * + * D-10 / PX-02: NO MapLibre rule participates in theme slots. The map + * style is Rams-constant; only --shell-bg / --shell-on-surface re-slot + * behind [data-tg-theme]. + */ + +/* tokens.css wins; mockups lose. Value discipline per theme/src/components.css lines 9-15. */ +@import "@araharah/theme/dist/rams.css"; + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + background: var(--shell-bg); + color: var(--shell-on-surface); + font: var(--type-body-md); + overflow: hidden; +} + +/* PX-02 dark-slot activation (extends theme.css's media-query default) */ +[data-tg-theme="light"] { --shell-bg: var(--color-surface); --shell-on-surface: var(--color-on-surface); } +[data-tg-theme="dark"] { --shell-bg: var(--color-surface-dark); --shell-on-surface: var(--color-inverse-on-surface); } +[data-tg-theme="dark"] .card { background: var(--color-inverse-surface); color: var(--color-inverse-on-surface); border-color: var(--color-inverse-on-surface); } +[data-tg-theme="dark"] .card-header { border-bottom-color: var(--color-inverse-on-surface); color: var(--color-inverse-on-surface); } +/* NO MapLibre rule here, by design (D-10 / RESEARCH Pitfall 7). */ + +/* Research Open Question 1 — WebGL2 absent: shell renders over a Rams-tinted field */ +body[data-map="absent"] main { background: var(--color-surface-dim); } + +/* map register: full-bleed, the dominant surface */ +main { position: fixed; inset: 0; } +#map { position: absolute; inset: 0; } +.maplibregl-ctrl-attrib { border-radius: 0; 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; } + +/* top register: status strip */ +.strip { + position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0; + display: flex; align-items: center; justify-content: space-between; + padding: var(--spacing-space-sm) var(--spacing-space-md); + min-height: 32px; + background: var(--color-matte-black); color: var(--color-inverse-on-surface); + font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; +} +.strip .lamp { width: 8px; height: 8px; border: 1px solid var(--color-pure-white); } +.strip .lamp[data-state="acquiring"], .strip .lamp[data-state="validating"], .strip .lamp[data-state="awaiting"] { background: var(--color-signal-orange); } +.strip .lamp[data-state="live"] { background: var(--color-olive-green); } +.strip .lamp[data-state="lost"], .strip .lamp[data-state="denied"] { background: var(--color-functional-red); } +.strip .telemetry { font: var(--type-label-sm); } + +/* center register: boot / denied / lost card */ +.card-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); top: 50%; transform: translateY(-50%); max-width: 320px; margin-inline: auto; } +.card .gantry { width: 8px; height: 8px; border: 1px solid var(--color-matte-black); } +.card .gantry[data-fill="on"] { background: var(--color-signal-orange); } +.card-actions { display: flex; gap: var(--spacing-space-sm); padding-top: var(--spacing-space-sm); } + +/* bottom register: recenter control (40x40 plate, >=44x44 hit target) */ +.recenter { + position: absolute; right: var(--spacing-margin); bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm)); + width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0; + background: var(--color-matte-black); color: var(--color-pure-white); + display: flex; align-items: center; justify-content: center; +} +.recenter::after { content: ""; position: absolute; inset: -2px; } +.recenter .notch { width: 8px; height: 8px; background: var(--color-signal-orange); } +.recenter[data-follow="free"] .notch { display: none; } +/* UI-SPEC §Follow/Recenter Control, third notch state: live-but-LOST (no fix). + soft-beige, NOT red — red is reserved for the strip/degraded registers; the notch + reads "unlocked, not error" (RESEARCH Pitfall 8 orange discipline). */ +.recenter[data-fix="lost"] .notch { background: var(--color-soft-beige); } +.recenter:active { background: var(--color-signal-orange); color: var(--color-matte-black); } + +/* position marker (DOM Marker children) */ +.pos-accuracy { fill: var(--color-matte-black); fill-opacity: 0.06; stroke: var(--color-matte-black); stroke-opacity: 0.30; stroke-width: 1; } +.pos-dot { fill: var(--color-matte-black); } +.pos-cone { fill: var(--color-signal-orange); } +.pos-group[data-stale="true"] { opacity: 0.4; } + +/* No CSS transition or animation anywhere — state changes are instant hard swaps. */