Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234)

- liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon,
  base, tourMs, kiosk and an f.<name> filter pass-through for #235;
  parse/serialise stay pure and round-trip; old inc/cam/tour links parse.
- MapView: overlays/basemap/viewport come from initialLayers/initialBase/
  initialView (defaults unchanged); onViewStateChange reports layer, base
  and region-view changes (debounced). Initially-checked overlays now start
  "shown" (react-leaflet adds a checked overlay before the control can fire
  overlayadd). Closing an incident flies back to the pre-drill view (#137).
- wallPrefs.ts: per-browser last layers/base/view in localStorage, every
  access wrapped; precedence hash > remembered > defaults.
- LiveView: one effect mirrors wall + map state into the hash; hash edits
  remount the map with the new view; Copy view link; configurable tour.
- Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first
  touch), Screen Wake Lock re-acquired on visibility, reconnect banner
  (offline / useCalls error) with backoff reload, 18-20 px type via a
  data-kiosk attribute and Tailwind arbitrary variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:58:22 -04:00
co-authored by Claude Opus 5.5
parent 7bee87b147
commit afd2bc463d
7 changed files with 717 additions and 61 deletions
+127 -5
View File
@@ -152,22 +152,144 @@ export function bboxAround(points: LatLon[], padKm: number) {
};
}
// ── Deep links: #inc=<id>&cam=<cameraId>&tour ───────────────────────────────
export interface WallLink { inc: string | null; cam: string | null; tour: boolean }
// ── Deep links / saved views ────────────────────────────────────────────────
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light
// &f.<name>=<value>
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
// only) still parses and a newer one degrades on an older build. Pure string
// in/out — no window access — so it can be unit-tested once a JS runner exists.
/** Map overlays, by short stable hash key → the LayersControl overlay name. */
export const LAYER_NAMES = {
nodes: "Nodes",
inc: "Active Incidents",
paths: "Incident Paths",
air: "Aircraft",
ais: "Vessels",
dotcam: "DOT Cameras",
vs: "Camera Viewsheds (approx.)",
traffic: "Traffic Events",
radar: "Weather Radar",
} as const;
export type LayerKey = keyof typeof LAYER_NAMES;
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
/** What the map shows with no hash and nothing remembered (unchanged from before #234). */
export const DEFAULT_LAYERS: LayerKey[] = ["nodes", "inc", "paths"];
export const BASE_NAMES = { dark: "Dark", light: "Light", streets: "Streets" } as const;
export type BaseKey = keyof typeof BASE_NAMES;
export const DEFAULT_BASE: BaseKey = "dark";
export function isLayerKey(k: string): k is LayerKey {
return Object.prototype.hasOwnProperty.call(LAYER_NAMES, k);
}
export function isBaseKey(k: string): k is BaseKey {
return Object.prototype.hasOwnProperty.call(BASE_NAMES, k);
}
export function layerKeyForName(name: string): LayerKey | null {
return LAYER_KEYS.find((k) => LAYER_NAMES[k] === name) ?? null;
}
export function baseKeyForName(name: string): BaseKey | null {
return (Object.keys(BASE_NAMES) as BaseKey[]).find((k) => BASE_NAMES[k] === name) ?? null;
}
export interface MapViewport { z: number; lat: number; lon: number }
/** What the map looks like — the part a viewer's browser also remembers (lib/wallPrefs.ts). */
export interface WallView {
/** Overlays switched on, in LAYER_KEYS order. [] = all off; absent = not specified. */
layers?: LayerKey[];
view?: MapViewport;
base?: BaseKey;
}
export interface WallLink extends WallView {
inc: string | null;
cam: string | null;
tour: boolean;
/** Auto-tour dwell per incident; absent = the default. */
tourMs?: number;
/** Wall/kiosk presentation (no nav, large type, wake lock). */
kiosk?: boolean;
/**
* Extension point for the filter bar (server-26#235): every `f.<name>=<v>`
* hash key round-trips here untouched, so filters can be added without
* reshaping WallLink or breaking older links.
*/
filter?: Record<string, string>;
}
export const TOUR_MS_MIN = 5_000;
export const TOUR_MS_MAX = 10 * 60_000;
function num(v: string | null): number | null {
if (v == null || v.trim() === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Normalises a layer list: known keys only, de-duplicated, in LAYER_KEYS order. */
export function normaliseLayers(keys: readonly string[]): LayerKey[] {
const set = new Set(keys);
return LAYER_KEYS.filter((k) => set.has(k));
}
export function parseViewport(z: unknown, lat: unknown, lon: unknown): MapViewport | undefined {
if (typeof z !== "number" || typeof lat !== "number" || typeof lon !== "number") return undefined;
if (!Number.isFinite(z) || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
if (z < 0 || z > 22 || lat < -90 || lat > 90 || lon < -180 || lon > 180) return undefined;
return { z, lat, lon };
}
/** Rounds a viewport for the URL: ~1 m position, 1/100 zoom. */
export function roundViewport(v: MapViewport): MapViewport {
return { z: Math.round(v.z * 100) / 100, lat: Math.round(v.lat * 1e5) / 1e5, lon: Math.round(v.lon * 1e5) / 1e5 };
}
export function parseWallHash(hash: string): WallLink {
const p = new URLSearchParams(hash.replace(/^#/, ""));
return { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
const link: WallLink = { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
const tourMs = num(p.get("tourMs"));
if (tourMs != null) link.tourMs = Math.min(TOUR_MS_MAX, Math.max(TOUR_MS_MIN, Math.round(tourMs)));
if (p.has("kiosk") && p.get("kiosk") !== "0") link.kiosk = true;
if (p.has("layers")) link.layers = normaliseLayers((p.get("layers") ?? "").split(","));
const view = parseViewport(num(p.get("z")), num(p.get("lat")), num(p.get("lon")));
if (view) link.view = view;
const base = p.get("base");
if (base && isBaseKey(base)) link.base = base;
const filter: Record<string, string> = {};
p.forEach((v, k) => { if (k.startsWith("f.") && k.length > 2) filter[k.slice(2)] = v; });
if (Object.keys(filter).length) link.filter = filter;
return link;
}
export function wallHash(link: WallLink): string {
const parts: string[] = [];
if (link.inc) parts.push(`inc=${encodeURIComponent(link.inc)}`);
if (link.cam) parts.push(`cam=${encodeURIComponent(link.cam)}`);
const enc = encodeURIComponent;
if (link.inc) parts.push(`inc=${enc(link.inc)}`);
if (link.cam) parts.push(`cam=${enc(link.cam)}`);
if (link.tour) parts.push("tour");
if (link.tourMs != null) parts.push(`tourMs=${Math.round(link.tourMs)}`);
if (link.kiosk) parts.push("kiosk");
// Commas stay literal (URLSearchParams reads them back fine) so the link is readable.
if (link.layers) parts.push(`layers=${normaliseLayers(link.layers).join(",")}`);
if (link.view) {
const v = roundViewport(link.view);
parts.push(`z=${v.z}`, `lat=${v.lat}`, `lon=${v.lon}`);
}
if (link.base) parts.push(`base=${link.base}`);
if (link.filter) {
for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k])}`);
}
return parts.length ? `#${parts.join("&")}` : "";
}
/** True when two views would serialise identically (for "did this actually change"). */
export function sameView(a: WallView, b: WallView): boolean {
return wallHash({ inc: null, cam: null, tour: false, ...a }) === wallHash({ inc: null, cam: null, tour: false, ...b });
}
/** "On air": a live call is on one of the incident's talkgroups right now. */
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));