Merge pull request 'Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234)' (#265) from feat/234-saved-views into main
This commit was merged in pull request #265.
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Kiosk / wall-display support for the live wall (server-26#234).
|
||||
*
|
||||
* Kiosk is URL-driven only — `?kiosk=1` or `#…&kiosk` — never remembered in
|
||||
* storage, so a normal viewer can't get stuck without the nav. A wall PC's
|
||||
* startup URL carries it, which is what makes it survive a browser restart.
|
||||
*
|
||||
* Everything here degrades silently: no Fullscreen API, no Screen Wake Lock,
|
||||
* blocked sessionStorage — the wall still renders and still updates.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Fired on window whenever kiosk is switched in-page (replaceState fires no hashchange). */
|
||||
export const KIOSK_EVENT = "drb:kiosk-change";
|
||||
|
||||
function queryKiosk(search: string): boolean {
|
||||
const v = new URLSearchParams(search).get("kiosk");
|
||||
return v != null && v !== "0" && v !== "false";
|
||||
}
|
||||
|
||||
function hashKiosk(hash: string): boolean {
|
||||
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
||||
return p.has("kiosk") && p.get("kiosk") !== "0";
|
||||
}
|
||||
|
||||
/** True when the current URL asks for kiosk mode (query or hash). Client-side only. */
|
||||
export function readKioskFlag(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return queryKiosk(window.location.search) || hashKiosk(window.location.hash);
|
||||
}
|
||||
|
||||
/** Whether `?kiosk=` is in the query (the hash part is LiveView's to manage). */
|
||||
export function kioskInQuery(): boolean {
|
||||
return typeof window !== "undefined" && queryKiosk(window.location.search);
|
||||
}
|
||||
|
||||
/** Drops `?kiosk` from the query string in place; the caller clears the hash flag. */
|
||||
export function stripKioskQuery(): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("kiosk");
|
||||
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
|
||||
} catch { /* nothing to strip */ }
|
||||
}
|
||||
|
||||
export function announceKioskChange(): void {
|
||||
window.dispatchEvent(new Event(KIOSK_EVENT));
|
||||
}
|
||||
|
||||
/** Live view of the URL's kiosk flag, for chrome outside LiveView (ChromeSwitcher). */
|
||||
export function useKioskFlag(): boolean {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => {
|
||||
const read = () => setOn(readKioskFlag());
|
||||
read();
|
||||
window.addEventListener("hashchange", read);
|
||||
window.addEventListener("popstate", read);
|
||||
window.addEventListener(KIOSK_EVENT, read);
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", read);
|
||||
window.removeEventListener("popstate", read);
|
||||
window.removeEventListener(KIOSK_EVENT, read);
|
||||
};
|
||||
}, []);
|
||||
return on;
|
||||
}
|
||||
|
||||
// ── Screen Wake Lock (typed locally; not in every TS lib.dom) ────────────────
|
||||
interface WakeLockSentinelLike extends EventTarget { released: boolean; release(): Promise<void> }
|
||||
interface WakeLockLike { request(type: "screen"): Promise<WakeLockSentinelLike> }
|
||||
|
||||
export type WakeState = "on" | "off" | "unsupported";
|
||||
|
||||
const RETRY_KEY = "drb.wall.retryAttempts";
|
||||
const RETRY_BASE_MS = 30_000;
|
||||
const RETRY_MAX_MS = 10 * 60_000;
|
||||
/** Healthy this long after a load → the retry backoff resets. */
|
||||
const HEALTHY_RESET_MS = 2 * 60_000;
|
||||
/** Offline at least this long → a kiosk reloads on reconnect for fresh listeners. */
|
||||
const OFFLINE_RELOAD_MS = 60_000;
|
||||
|
||||
function readAttempts(): number {
|
||||
try { return Math.max(0, Number(window.sessionStorage.getItem(RETRY_KEY)) || 0); } catch { return 0; }
|
||||
}
|
||||
function writeAttempts(n: number): void {
|
||||
try {
|
||||
if (n > 0) window.sessionStorage.setItem(RETRY_KEY, String(n));
|
||||
else window.sessionStorage.removeItem(RETRY_KEY);
|
||||
} catch { /* storage blocked — backoff restarts at the base delay */ }
|
||||
}
|
||||
|
||||
export interface KioskBanner { tone: "offline" | "error"; message: string; retryAt: number | null }
|
||||
|
||||
export interface KioskState {
|
||||
fullscreen: boolean;
|
||||
canFullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
wake: WakeState;
|
||||
online: boolean;
|
||||
banner: KioskBanner | null;
|
||||
retryNow: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param kiosk kiosk presentation is on (wake lock, auto-fullscreen, auto-reload)
|
||||
* @param dataError the live-data listener's error (e.g. useCalls().error) — a
|
||||
* Firestore snapshot error is terminal for that listener, so
|
||||
* the recovery is a reload; the hooks themselves are untouched.
|
||||
*/
|
||||
export function useKiosk(kiosk: boolean, dataError: string | null): KioskState {
|
||||
// ── Fullscreen ──────────────────────────────────────────────────────────
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [canFullscreen, setCanFullscreen] = useState(false);
|
||||
useEffect(() => {
|
||||
setCanFullscreen(typeof document !== "undefined" && !!document.fullscreenEnabled);
|
||||
const on = () => setFullscreen(!!document.fullscreenElement);
|
||||
on();
|
||||
document.addEventListener("fullscreenchange", on);
|
||||
return () => document.removeEventListener("fullscreenchange", on);
|
||||
}, []);
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
try {
|
||||
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
|
||||
else void document.documentElement.requestFullscreen().catch(() => undefined);
|
||||
} catch { /* unsupported */ }
|
||||
}, []);
|
||||
// Browsers only allow fullscreen from a user gesture, so in kiosk the first
|
||||
// touch/key anywhere enters it. A wall PC launched with the browser's own
|
||||
// kiosk flag is already fullscreen and needs no click at all.
|
||||
useEffect(() => {
|
||||
if (!kiosk || !document.fullscreenEnabled) return;
|
||||
const arm = () => {
|
||||
if (!document.fullscreenElement) void document.documentElement.requestFullscreen().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("pointerdown", arm, { once: true });
|
||||
window.addEventListener("keydown", arm, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", arm);
|
||||
window.removeEventListener("keydown", arm);
|
||||
};
|
||||
}, [kiosk]);
|
||||
|
||||
// ── Screen Wake Lock — held while kiosk is on, re-acquired when visible again ──
|
||||
const [wake, setWake] = useState<WakeState>("off");
|
||||
useEffect(() => {
|
||||
const wl = (navigator as Navigator & { wakeLock?: WakeLockLike }).wakeLock;
|
||||
if (!kiosk) { setWake("off"); return; }
|
||||
if (!wl) { setWake("unsupported"); return; }
|
||||
let sentinel: WakeLockSentinelLike | null = null;
|
||||
let cancelled = false;
|
||||
const onRelease = () => { if (!cancelled) setWake("off"); };
|
||||
const acquire = async () => {
|
||||
if (cancelled || document.visibilityState !== "visible" || (sentinel && !sentinel.released)) return;
|
||||
try {
|
||||
const s = await wl.request("screen");
|
||||
if (cancelled) { void s.release().catch(() => undefined); return; }
|
||||
sentinel = s;
|
||||
s.addEventListener("release", onRelease);
|
||||
setWake("on");
|
||||
} catch {
|
||||
setWake("off"); // denied (battery saver, policy) — the wall still works
|
||||
}
|
||||
};
|
||||
void acquire();
|
||||
const onVis = () => { void acquire(); };
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
if (sentinel) {
|
||||
sentinel.removeEventListener("release", onRelease);
|
||||
void sentinel.release().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
}, [kiosk]);
|
||||
|
||||
// ── Reconnect watchdog ─────────────────────────────────────────────────
|
||||
const [online, setOnline] = useState(true);
|
||||
const offlineSince = useRef<number | null>(null);
|
||||
const kioskRef = useRef(kiosk);
|
||||
kioskRef.current = kiosk;
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const now = navigator.onLine;
|
||||
setOnline(now);
|
||||
if (!now) {
|
||||
if (offlineSince.current == null) offlineSince.current = Date.now();
|
||||
return;
|
||||
}
|
||||
const since = offlineSince.current;
|
||||
offlineSince.current = null;
|
||||
// Firestore resumes its listeners on its own after a blip; after a long
|
||||
// outage an unattended wall reloads rather than trust that it did.
|
||||
if (kioskRef.current && since != null && Date.now() - since >= OFFLINE_RELOAD_MS) window.location.reload();
|
||||
};
|
||||
sync();
|
||||
window.addEventListener("online", sync);
|
||||
window.addEventListener("offline", sync);
|
||||
return () => {
|
||||
window.removeEventListener("online", sync);
|
||||
window.removeEventListener("offline", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const retryNow = useCallback(() => {
|
||||
writeAttempts(readAttempts() + 1);
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
// Healthy for a while → forget earlier failures.
|
||||
useEffect(() => {
|
||||
if (dataError) return;
|
||||
const id = setTimeout(() => writeAttempts(0), HEALTHY_RESET_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [dataError]);
|
||||
|
||||
// Data error in kiosk → reload after an exponential backoff (30 s → 10 min).
|
||||
const [retryAt, setRetryAt] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dataError || !kiosk || !online) { setRetryAt(null); return; }
|
||||
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** readAttempts());
|
||||
setRetryAt(Date.now() + delay);
|
||||
const id = setTimeout(retryNow, delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [dataError, kiosk, online, retryNow]);
|
||||
|
||||
let banner: KioskBanner | null = null;
|
||||
if (!online) {
|
||||
banner = { tone: "offline", message: "Offline — showing the last data received. Reconnects automatically.", retryAt: null };
|
||||
} else if (dataError) {
|
||||
banner = { tone: "error", message: "Live data stopped updating.", retryAt };
|
||||
}
|
||||
|
||||
return { fullscreen, canFullscreen, toggleFullscreen, wake, online, banner, retryNow };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Per-viewer memory of the live wall's map view (server-26#234): the last
|
||||
* overlays, basemap and viewport this browser used. A convenience only —
|
||||
* every access is wrapped, and a blocked/empty store (private window, cleared
|
||||
* site data) simply yields {} so the wall falls back to its defaults.
|
||||
*
|
||||
* Precedence when the wall opens: URL hash > this store > defaults
|
||||
* (resolveWallView below). Nothing here is shared with other viewers.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_BASE, DEFAULT_LAYERS, isBaseKey, normaliseLayers, parseViewport,
|
||||
type WallView,
|
||||
} from "@/lib/liveWall";
|
||||
|
||||
const KEY = "drb.wall.view.v1";
|
||||
|
||||
export function loadWallPrefs(): WallView {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return {};
|
||||
const v: unknown = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object") return {};
|
||||
const o = v as Record<string, unknown>;
|
||||
const out: WallView = {};
|
||||
if (Array.isArray(o.layers)) out.layers = normaliseLayers(o.layers.filter((x): x is string => typeof x === "string"));
|
||||
if (typeof o.base === "string" && isBaseKey(o.base)) out.base = o.base;
|
||||
const view = o.view && typeof o.view === "object" ? (o.view as Record<string, unknown>) : null;
|
||||
const vp = view ? parseViewport(view.z, view.lat, view.lon) : undefined;
|
||||
if (vp) out.view = vp;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWallPrefs(v: WallView): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ layers: v.layers, base: v.base, view: v.view }));
|
||||
} catch {
|
||||
/* storage blocked or full — the URL hash still carries the view */
|
||||
}
|
||||
}
|
||||
|
||||
/** Field-by-field: hash wins, then what this browser remembers, then defaults. */
|
||||
export function resolveWallView(hash: WallView, stored: WallView): Required<Pick<WallView, "layers" | "base">> & Pick<WallView, "view"> {
|
||||
return {
|
||||
layers: hash.layers ?? stored.layers ?? DEFAULT_LAYERS,
|
||||
base: hash.base ?? stored.base ?? DEFAULT_BASE,
|
||||
view: hash.view ?? stored.view,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user