@7365admin1/layer-common 3.2.4 → 3.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,926 @@
1
+ /**
2
+ * THE CAMERA WALL'S RULES, WITH NO MARKUP IN THEM.
3
+ *
4
+ * `CameraWall.vue` draws the wall; this file decides what it is allowed to draw.
5
+ * Keeping the two apart is not tidiness — every rule below is one somebody can
6
+ * get wrong in a way nobody notices until a supervisor is looking at the wrong
7
+ * picture, so each one is stated once, here, and pinned by `camera-wall.test.ts`.
8
+ *
9
+ * ## The four rules
10
+ *
11
+ * 1. **Only `type: "ip"` cameras go on a wall.** An ANPR unit belongs to visitor
12
+ * and vehicle management: it is pointed at a number plate, it is wired to a
13
+ * barrier, and its record carries a real device address and a real credential.
14
+ * Putting one on a monitoring wall is a defect, not a feature. The request
15
+ * already asks the server for `type=ip`; `wallCameras()` filters again on the
16
+ * way in, because "the caller passed the right query" is an assumption and
17
+ * this is the rule.
18
+ * 2. **A capability we have not been told about is NOT available.** An absent or
19
+ * malformed descriptor reads as `unknown`, and `unknown` counts as no — so a
20
+ * browser talking to an older API cannot draw a control that would do nothing.
21
+ * 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
22
+ * sentence shown is the SERVER'S own sentence, never rewritten here, so a
23
+ * camera cannot explain itself two different ways on two screens.
24
+ * 4. **"Live" means a picture is arriving**, not "we asked for one", and not
25
+ * "the page that would show one loaded". Those are three different claims
26
+ * and the tile used to make the strongest one on the evidence for the
27
+ * weakest. See the long note above `tileView` — this rule was being broken
28
+ * in production and a supervisor was being told a blank tile was live.
29
+ * 5. **A switched-off control also says what would switch it on, and who to
30
+ * ask.** The server's sentence states the fact; it deliberately does not name
31
+ * a next step, because a server has no idea who the reader can go and talk
32
+ * to. So the next step is a SECOND line, owned here and keyed by the
33
+ * server's own reason CODE — rule 3 is intact, nothing is rewritten.
34
+ * 6. **The recorder's health and the player page's state are two different
35
+ * facts and never share one indicator.** "The page is up" and "the recorder
36
+ * answered" can disagree, and a supervisor needs to know which they are
37
+ * looking at. Merging them is the same defect as a still tile saying "Live".
38
+ *
39
+ * These match the iSecure365 mobile monitoring build one for one, deliberately:
40
+ * the same product should not answer the same question differently on a phone
41
+ * and on a laptop.
42
+ */
43
+
44
+ /** One capability's answer, as the API returns it (core's `CameraCapabilityEntry`). */
45
+ export type TWallCapability = {
46
+ state?: "supported" | "unsupported" | "unknown";
47
+ transport?: string | null;
48
+ reason?: string | null;
49
+ /** The server's own sentence. Displayed verbatim; never rewritten here. */
50
+ detail?: string | null;
51
+ };
52
+
53
+ /** A camera as the wall needs it. Extra fields on the record are ignored. */
54
+ export type TWallCamera = {
55
+ _id: string;
56
+ name?: string;
57
+ /**
58
+ * The stored address. For a `type: "ip"` camera this is a complete, directly
59
+ * renderable live-video page — it is the picture, not a fragment to resolve.
60
+ */
61
+ host?: string;
62
+ type?: string;
63
+ status?: string;
64
+ capabilities?: Record<string, TWallCapability>;
65
+ /**
66
+ * The server's own sentence for why this camera cannot show a picture, or
67
+ * `null`/absent when it can. Answered from the record before anything is
68
+ * requested, so it costs no device traffic.
69
+ *
70
+ * **Preferred over anything worked out here.** The server knows things the
71
+ * browser cannot — that no recorder is configured for this camera's relay, for
72
+ * instance, which is the true answer for every camera until the API host is
73
+ * configured. Replacing it with our own wording hides that.
74
+ */
75
+ unavailableReason?: string | null;
76
+ };
77
+
78
+ /* -------------------------------------------------------------------------- */
79
+ /* Layouts */
80
+ /* -------------------------------------------------------------------------- */
81
+
82
+ /**
83
+ * The three wall sizes.
84
+ *
85
+ * Square only, and only three of them. A VMS layout picker exists so a
86
+ * supervisor can change what they are watching in one click; a list of eleven
87
+ * arrangements is a settings screen wearing a toolbar's clothes.
88
+ */
89
+ export const WALL_LAYOUTS = [
90
+ { key: "1x1", columns: 1, tiles: 1, label: "Single" },
91
+ { key: "2x2", columns: 2, tiles: 4, label: "Four" },
92
+ { key: "3x3", columns: 3, tiles: 9, label: "Nine" },
93
+ ] as const;
94
+
95
+ export type TWallLayoutKey = (typeof WALL_LAYOUTS)[number]["key"];
96
+
97
+ /** Four tiles: enough to be a wall, small enough to still be readable. */
98
+ export const DEFAULT_WALL_LAYOUT: TWallLayoutKey = "2x2";
99
+
100
+ export function wallLayout(key: string | undefined) {
101
+ return WALL_LAYOUTS.find((l) => l.key === key) ?? WALL_LAYOUTS[1];
102
+ }
103
+
104
+ /**
105
+ * At three columns a tile is small enough that the chrome starts eating the
106
+ * picture, so the type shrinks and the recorder line loses its timestamp.
107
+ *
108
+ * **It no longer removes the camera's name or its status.** It used to drop the
109
+ * status WORD at nine tiles and leave a bare 7 px dot — a mobile-first call
110
+ * that is wrong on a desktop wall, which is the layout a supervisor watches
111
+ * from furthest away and the one where a camera is most likely to be missed.
112
+ * Whether a camera is alive is the tile's whole job; it is not the thing that
113
+ * gets cut for room.
114
+ */
115
+ export function isDenseLayout(key: string | undefined) {
116
+ return wallLayout(key).columns >= 3;
117
+ }
118
+
119
+ /* -------------------------------------------------------------------------- */
120
+ /* Which cameras, and in what order */
121
+ /* -------------------------------------------------------------------------- */
122
+
123
+ /** Rule 1. Only `type: "ip"`. See the note at the top of this file. */
124
+ export function wallCameras(items: unknown): TWallCamera[] {
125
+ if (!Array.isArray(items)) return [];
126
+ return items.filter(
127
+ (c): c is TWallCamera => !!c && typeof c === "object" && (c as TWallCamera).type === "ip"
128
+ );
129
+ }
130
+
131
+ /**
132
+ * The cameras a wall would show, before the layout cuts them down.
133
+ *
134
+ * An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
135
+ * and the wall falls back to the site's own order. A supervisor who clears the
136
+ * picker gets their site back, not a black screen.
137
+ */
138
+ function wallChosen(
139
+ cameras: TWallCamera[],
140
+ selectedIds: string[] | undefined
141
+ ): TWallCamera[] {
142
+ if (!selectedIds || !selectedIds.length) return cameras;
143
+ const byId = new Map(cameras.map((c) => [c._id, c]));
144
+ return selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c);
145
+ }
146
+
147
+ /**
148
+ * How many pages the wall needs — never fewer than one.
149
+ *
150
+ * **This exists because it was missing, and a camera was going missing with
151
+ * it.** The wall took the first `tiles` cameras and dropped the rest on the
152
+ * floor: a site with five cameras on a 2x2 wall showed four, said "Cameras 5"
153
+ * in the toolbar, and gave nobody any way to reach the fifth. It did not read
154
+ * as a missing feature, it read as a broken count. The mobile wall has paged
155
+ * since it was built; this is the same rule, on the web.
156
+ */
157
+ export function wallPageCount(
158
+ cameras: TWallCamera[],
159
+ selectedIds: string[] | undefined,
160
+ layoutKey: string | undefined
161
+ ): number {
162
+ const total = wallChosen(cameras, selectedIds).length;
163
+ return Math.max(1, Math.ceil(total / wallLayout(layoutKey).tiles));
164
+ }
165
+
166
+ /** Keeps a page number inside the wall, whatever the layout just did to it. */
167
+ export function clampPage(page: number, pages: number): number {
168
+ if (!Number.isFinite(page)) return 0;
169
+ return Math.min(Math.max(0, Math.floor(page)), Math.max(0, pages - 1));
170
+ }
171
+
172
+ /**
173
+ * The cameras on one page, in order. **Never padded.**
174
+ *
175
+ * It used to pad to the layout's tile count with `null`, and the wall drew each
176
+ * one as a dashed box reading "No camera in this position." Five cameras on a
177
+ * nine-tile wall meant four dead grey rectangles, which is not what a video
178
+ * wall does and not information: nobody looking at that screen was unaware
179
+ * they had five cameras, and there is nothing to drop into an empty cell
180
+ * because cameras are added in Site Settings, not here.
181
+ *
182
+ * So the grid packs instead — see `wallRows`. The layout picker still means
183
+ * "at most this many, at this size"; it stops meaning "always exactly this
184
+ * many holes".
185
+ */
186
+ export function wallTiles(
187
+ cameras: TWallCamera[],
188
+ selectedIds: string[] | undefined,
189
+ layoutKey: string | undefined,
190
+ page = 0
191
+ ): TWallCamera[] {
192
+ const { tiles } = wallLayout(layoutKey);
193
+ const chosen = wallChosen(cameras, selectedIds);
194
+ const start = clampPage(page, wallPageCount(cameras, selectedIds, layoutKey)) * tiles;
195
+
196
+ return chosen.slice(start, start + tiles);
197
+ }
198
+
199
+ /**
200
+ * WHICH TILE THE DETAIL PANEL IS ABOUT, and the guarantee that makes the panel
201
+ * able to stop naming it.
202
+ *
203
+ * The panel used to print the camera's name so the reader could tell which of
204
+ * up to nine tiles it was describing. The name is gone — the selected tile wears
205
+ * an accent ring instead — and that only works because **this can never return a
206
+ * camera that is not on the page being looked at.** It resolves out of the
207
+ * CURRENT page's tiles, so a selection left behind on page 1 does not leave the
208
+ * panel describing an invisible camera from page 2; it falls back to the first
209
+ * tile of the page you are actually on, which the ring then marks.
210
+ *
211
+ * The caller keeps the raw id, so paging back restores the original selection.
212
+ *
213
+ * Extracted from the component purely so this guarantee is a tested rule rather
214
+ * than a line of template nobody would think to check.
215
+ */
216
+ export function focusedCamera(
217
+ tiles: TWallCamera[],
218
+ focusedId: string | undefined
219
+ ): TWallCamera | null {
220
+ return tiles.find((c) => c._id === focusedId) || tiles[0] || null;
221
+ }
222
+
223
+ /**
224
+ * How many grid rows this page actually needs — the other half of packing.
225
+ *
226
+ * Without it a 3x3 wall holding five cameras still reserves three rows, so the
227
+ * five tiles are drawn two-thirds height with a band of empty screen under
228
+ * them. Sizing the grid to the content gives that screen back to the pictures,
229
+ * which is the entire reason a supervisor chose a bigger layout.
230
+ *
231
+ * Never more rows than the layout would have had, and never fewer than one.
232
+ */
233
+ export function wallRows(count: number, layoutKey: string | undefined): number {
234
+ const { columns, tiles } = wallLayout(layoutKey);
235
+ const capped = Math.min(Math.max(0, Math.floor(count)), tiles);
236
+ return Math.min(Math.ceil(tiles / columns), Math.max(1, Math.ceil(capped / columns)));
237
+ }
238
+
239
+ /* -------------------------------------------------------------------------- */
240
+ /* Capabilities */
241
+ /* -------------------------------------------------------------------------- */
242
+
243
+ /**
244
+ * The two words a switched-off control wears.
245
+ *
246
+ * Lifted unchanged from the mobile build, and the "yet" is the whole point: it
247
+ * is the difference between a feature that is broken and a feature that is
248
+ * waiting on something somebody has to switch on.
249
+ */
250
+ export const CAPABILITY_TAGS = {
251
+ supported: "Ready",
252
+ unsupported: "Not set up yet",
253
+ unknown: "Not checked yet",
254
+ } as const;
255
+
256
+ /**
257
+ * What a client with no descriptor at all should say. This is the server's own
258
+ * `device-not-probed` sentence, reused rather than reinvented, so an older API
259
+ * that sends no descriptor still explains itself in the product's own words.
260
+ */
261
+ const NOT_PROBED = "This camera has not been asked what it can do yet.";
262
+
263
+ /**
264
+ * RULE 5 — the second line, and why the server cannot write it.
265
+ *
266
+ * The server's sentence is a statement of fact: *"No direct connection to this
267
+ * camera's recorder is configured on this server."* That is correct, it is the
268
+ * server's to own, and it is **not** an answer to the only question a reader
269
+ * actually has, which is "so how do I set it up?" — a real question, asked of
270
+ * this exact screen. A server cannot answer it: it knows the configuration is
271
+ * absent, it has no idea who the person reading has to go and ask.
272
+ *
273
+ * So the next step lives here, keyed by the server's own reason CODE rather
274
+ * than by matching its words. Rule 3 is untouched — the server's sentence is
275
+ * still shown verbatim; this is a second line under it.
276
+ *
277
+ * **Every one of these says the same true and unwelcome thing in a different
278
+ * way: none of it is switched on from this page.** Saying so plainly is the
279
+ * point. An empty settings screen that quietly does nothing would be worse.
280
+ *
281
+ * ## "Ask your Seven365 administrator" was a dead end, and it has been replaced
282
+ *
283
+ * That was the whole of the advice, and the owner — who IS the administrator —
284
+ * read it on the live wall and pointed out that it tells him nothing. It tells a
285
+ * property manager nothing either: they raise a ticket and wait, because the
286
+ * screen never said what the ticket should ASK FOR.
287
+ *
288
+ * The real answer is two changes, and they are two different jobs for two
289
+ * different people:
290
+ *
291
+ * 1. **a network change** — the site's network has to let our server reach the
292
+ * camera recorder. Today it does not: the recorder answers video only.
293
+ * 2. **a configuration change on our server** — that recorder then has to be
294
+ * added to the server's configuration.
295
+ *
296
+ * So each line below names the change that is missing and who does it, in that
297
+ * order. **No environment variable is named**, deliberately: the names belong in
298
+ * the deployment documentation (`iservice365-core/docs/camera-integration-config.md`),
299
+ * not on a property manager's screen, and printing them would make the sentence
300
+ * unreadable to the person it is written for without helping the person who
301
+ * needs them, who has the document.
302
+ *
303
+ * Nor does any line point at the camera setup guide. That guide covers ADDING a
304
+ * camera in Site Settings; it does not cover recorder connectivity, so sending
305
+ * the reader there would be sending them somewhere that does not answer the
306
+ * question. Naming the two changes IS the actionable content.
307
+ */
308
+ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
309
+ "device-http-not-configured":
310
+ "Not switchable from this page. It needs the site's network opened so our server can reach the camera recorder, and that recorder then added to the server's configuration — your Seven365 administrator arranges both with whoever installed the cameras.",
311
+ "device-http-disabled":
312
+ "Not switchable from this page. The connection to the recorder exists but is turned off in the server's configuration — your Seven365 administrator turns it on.",
313
+ "device-http-unreachable":
314
+ "Our server cannot reach this camera's recorder. Usually the site's network is blocking it or the recorder is down — your Seven365 administrator and whoever installed the cameras check it between them.",
315
+ "device-http-locked-out":
316
+ "Sign-ins to the recorder are being held back so its account is not locked out. It clears on its own; if it keeps happening the password held for the recorder is wrong and your Seven365 administrator corrects it in the server's configuration.",
317
+ "control-not-enabled":
318
+ "Not switchable from this page. This one moves real hardware, so a Seven365 backend lead has to approve it before it is turned on in the server's configuration.",
319
+ "device-no-ptz":
320
+ "This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
321
+ "no-recorder-configured":
322
+ "Not switchable from this page. This camera's video service has no recorder linked to it in the server's configuration — your Seven365 administrator adds it.",
323
+ "no-channel-in-address":
324
+ "This camera's address does not say which stream it is. Correct it in Site Settings, under CCTV.",
325
+ "not-a-relay-player-url":
326
+ "This camera's address is not a live-video page address. Correct it in Site Settings, under CCTV.",
327
+ "no-address":
328
+ "This camera has no address. Add it in Site Settings, under CCTV.",
329
+ "invalid-address":
330
+ "This camera's address is not valid. Correct it in Site Settings, under CCTV.",
331
+ "camera-inactive":
332
+ "This camera is switched off. Set it back to active in Site Settings, under CCTV.",
333
+ "device-not-probed":
334
+ "The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
335
+ "no-transport":
336
+ "Not built yet. Nothing on this server can do it.",
337
+ };
338
+
339
+ /**
340
+ * For a reason code this build has never heard of — a newer server, a code
341
+ * added after this layer was published. Deliberately vague about the cause and
342
+ * exact about the next step, which is the half that matters: it is still not a
343
+ * setting on this page, and there is still somebody to ask.
344
+ */
345
+ const NEXT_STEP_FALLBACK =
346
+ "Not switchable from this page — it needs a change to the server's configuration, which your Seven365 administrator makes.";
347
+
348
+ /**
349
+ * The capabilities the wall draws a control for, and their plain names.
350
+ *
351
+ * `ready` is what the panel says when the server reports the capability
352
+ * WORKING, and it describes **this screen** rather than the product. Only
353
+ * `digitalZoom` is actually built here; the other four are real on the camera
354
+ * and not yet drawn on the web, and saying so is better than a panel that
355
+ * points at a timeline nobody has built. Today the server reports all four
356
+ * unsupported, so none of those sentences is on screen — they are there so
357
+ * that the day one flips, the screen does not start lying.
358
+ */
359
+ export const WALL_CAPABILITY_CONTROLS = [
360
+ {
361
+ key: "digitalZoom",
362
+ title: "Zoom",
363
+ ready:
364
+ "Scroll or pinch on a tile to zoom in, then drag to move around. Reset returns to the whole picture. This crops and enlarges the picture on your screen — it does not move the camera.",
365
+ },
366
+ {
367
+ key: "playback",
368
+ title: "Recorded footage",
369
+ ready: "This camera can serve recorded footage. The playback controls are not on this screen yet.",
370
+ },
371
+ {
372
+ key: "events",
373
+ title: "Events and alarms",
374
+ ready: "This camera reports events. The event list is not on this screen yet.",
375
+ },
376
+ {
377
+ key: "ptz",
378
+ title: "Move camera",
379
+ ready: "This camera can be moved. The movement controls are not on this screen yet.",
380
+ },
381
+ {
382
+ key: "presets",
383
+ title: "Stored positions",
384
+ ready: "This camera has stored positions. Recalling them is not on this screen yet.",
385
+ },
386
+ ] as const;
387
+
388
+ export type TCapabilityView = {
389
+ state: "supported" | "unsupported" | "unknown";
390
+ available: boolean;
391
+ /** Always set: "Ready" when it works, the reason wording when it does not. */
392
+ tag: string;
393
+ /**
394
+ * The server's sentence, or the not-probed one — and `null` when the
395
+ * capability WORKS, because the server sends no sentence for a working
396
+ * capability and falling back to the not-probed one made a working control
397
+ * claim it had never been checked.
398
+ */
399
+ detail: string | null;
400
+ /** Rule 5: what would switch it on and who to ask. `null` when it works. */
401
+ nextStep: string | null;
402
+ };
403
+
404
+ /** Rules 2, 3 and 5, in one place. */
405
+ export function capabilityView(
406
+ camera: TWallCamera | null | undefined,
407
+ key: string
408
+ ): TCapabilityView {
409
+ const entry = camera?.capabilities?.[key];
410
+ const state =
411
+ entry?.state === "supported" || entry?.state === "unsupported"
412
+ ? entry.state
413
+ : "unknown";
414
+
415
+ if (state === "supported") {
416
+ return {
417
+ state,
418
+ available: true,
419
+ tag: CAPABILITY_TAGS.supported,
420
+ detail: null,
421
+ nextStep: null,
422
+ };
423
+ }
424
+
425
+ const reason = (entry?.reason || "").trim();
426
+
427
+ return {
428
+ state,
429
+ available: false,
430
+ tag: CAPABILITY_TAGS[state],
431
+ detail: (entry?.detail || "").trim() || NOT_PROBED,
432
+ nextStep:
433
+ CAPABILITY_NEXT_STEPS[reason] ??
434
+ (state === "unknown"
435
+ ? CAPABILITY_NEXT_STEPS["device-not-probed"]
436
+ : NEXT_STEP_FALLBACK),
437
+ };
438
+ }
439
+
440
+ /**
441
+ * RULE 7 — SAY IT ONCE FOR THE GROUP WHEN IT IS ONE FACT, NOT FOUR.
442
+ *
443
+ * On this estate all four of the switched-off controls are off for the SAME
444
+ * reason: there is no direct connection to the recorder. So the panel drew four
445
+ * cards, each carrying an identical sentence and an identical next step —
446
+ * eight lines of repetition saying one thing. Four copies of a sentence do not
447
+ * make it four facts; they make the reader skim, and skimming is how the one
448
+ * line that mattered got missed.
449
+ *
450
+ * So when every unavailable control shares the same reason, this returns it and
451
+ * the panel prints it once above the row. Each card keeps its own title and its
452
+ * own tag, so the individual state is never lost — only the echo is.
453
+ *
454
+ * Returns `null` the moment the reasons DIFFER, which is the case that must not
455
+ * be flattened: "the recorder is unreachable" and "this camera cannot move" are
456
+ * two different problems with two different people to ask, and merging them
457
+ * would be a lie of convenience. Also `null` when nothing is off (nothing to
458
+ * explain) or when only one control is off (a group note for a group of one is
459
+ * just the card, moved).
460
+ */
461
+ export function sharedCapabilityNote(
462
+ camera: TWallCamera | null | undefined,
463
+ keys: readonly string[]
464
+ ): { detail: string; nextStep: string | null } | null {
465
+ const off = keys
466
+ .map((key) => capabilityView(camera, key))
467
+ .filter((view) => !view.available);
468
+
469
+ if (off.length < 2) return null;
470
+
471
+ const first = off[0];
472
+ const same = off.every(
473
+ (view) => view.detail === first.detail && view.nextStep === first.nextStep
474
+ );
475
+
476
+ return same && first.detail
477
+ ? { detail: first.detail, nextStep: first.nextStep }
478
+ : null;
479
+ }
480
+
481
+ /* -------------------------------------------------------------------------- */
482
+ /* Tile state */
483
+ /* -------------------------------------------------------------------------- */
484
+
485
+ /**
486
+ * How long a player page may take to load before the tile stops claiming it is
487
+ * coming. Deliberately generous — lifted from the mobile build, where the
488
+ * argument is that reporting a working video service as dead is the worse of
489
+ * the two mistakes.
490
+ */
491
+ export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
492
+
493
+ /**
494
+ * How long a tile that WAS confirmed playing may go without another frame
495
+ * before it stops saying "Live".
496
+ *
497
+ * Only reachable once the video service reports frames at all (see
498
+ * `playerSignal`). It exists because the opposite mistake — a tile that went
499
+ * green once and stays green over a frozen picture — is the same defect as the
500
+ * mobile app's "Live" badge over a nine-second-old still, and is exactly what
501
+ * this whole rewrite is fixing.
502
+ */
503
+ export const LIVE_FRAME_TIMEOUT_MS = 12_000;
504
+
505
+ export type TTileState =
506
+ | "live"
507
+ | "unverified"
508
+ | "connecting"
509
+ | "no-signal"
510
+ | "offline"
511
+ | "unavailable";
512
+
513
+ /**
514
+ * The words a tile may say about itself.
515
+ *
516
+ * `offline` and `no-signal` are two different facts and used to share one word:
517
+ * "Offline" on every tile at once means the BROWSER lost the network; "No
518
+ * signal" on one tile means that camera is not answering. A supervisor needs to
519
+ * know which of those they are looking at.
520
+ *
521
+ * `unverified` is the state this wall is in for every camera on this estate
522
+ * today, and saying so is the point — see the note above `tileView`.
523
+ *
524
+ * It used to read "Not verified", which is a word from our side of the fence:
525
+ * it describes a check we did not manage to make. "Not confirmed" describes the
526
+ * PICTURE, which is the only thing the reader cares about, and it is the same
527
+ * claim. The instruction that goes with it — check the tile — is in
528
+ * `UNVERIFIED_DETAIL`, on the badge's tooltip and once on the panel.
529
+ */
530
+ export const TILE_LABELS = {
531
+ live: "Live",
532
+ unverified: "Not confirmed",
533
+ connecting: "Connecting…",
534
+ "no-signal": "No signal",
535
+ offline: "No connection",
536
+ unavailable: "Not set up yet",
537
+ } as const;
538
+
539
+ /**
540
+ * What the browser knows about the embedded player page.
541
+ *
542
+ * - `loading` — nothing yet; the document request has not finished.
543
+ * - `page-up` — the document loaded. **This says nothing about pictures.**
544
+ * - `playing` — the video service reported a decoded frame (see `playerSignal`).
545
+ * - `stalled` — it reported frames and then stopped, or said it stalled.
546
+ * - `failed` — the document never loaded, or the browser reported an error.
547
+ */
548
+ export type TPlayerState = "loading" | "page-up" | "playing" | "stalled" | "failed";
549
+
550
+ /* ------------------------------------------------------------------------- */
551
+ /* What the video service can tell us, and what it cannot */
552
+ /* ------------------------------------------------------------------------- */
553
+
554
+ /**
555
+ * EVERYTHING THIS BROWSER CAN HONESTLY KNOW ABOUT A CROSS-ORIGIN PLAYER.
556
+ *
557
+ * The player is another origin's page in an `<iframe>`, so the list is short
558
+ * and it is worth writing down, because the badge is built only from this:
559
+ *
560
+ * 1. **`load` fires** → that origin returned a document. Nothing more. The
561
+ * video service answers `GET /<channel>` with the same HTML for a channel
562
+ * that has no camera behind it, so `load` on a dead camera is normal.
563
+ * 2. **`load` never fires** → DNS, TLS or the connection failed, or the service
564
+ * is down. Only observable as *absence*, hence the timeout.
565
+ * 3. **`error` fires** → rare in practice for cross-origin frames, but free.
566
+ * 4. **`postMessage` from that origin** → the only channel that could carry
567
+ * "a frame decoded". The service does not send one today; `playerSignal`
568
+ * below is the listener, so the day it does, the wall goes green with no
569
+ * further frontend release.
570
+ *
571
+ * NOT available, and we must stop pretending otherwise: the frame rate, the
572
+ * canvas pixels, the WebSocket's state, whether ffmpeg upstream is producing
573
+ * anything. All of those live inside the other origin's document and the
574
+ * browser blocks every route to them — correctly.
575
+ *
576
+ * Opening our own WebSocket to the service to check would start a SECOND
577
+ * decode of the same camera per tile, which is a real load on the recorder for
578
+ * a status dot. Rejected.
579
+ */
580
+ export type TPlayerSignal = "playing" | "stalled" | "failed" | null;
581
+
582
+ /**
583
+ * Reads a `postMessage` payload from the video service.
584
+ *
585
+ * Deliberately forgiving about shape and deliberately strict about origin —
586
+ * the origin check belongs to the caller, which has the camera's address; this
587
+ * only decides what a message MEANS. Anything unrecognised is `null`, which
588
+ * leaves the tile exactly where it was: an unknown message must never be able
589
+ * to turn a badge green.
590
+ */
591
+ export function playerSignal(data: unknown): TPlayerSignal {
592
+ const event =
593
+ typeof data === "string"
594
+ ? data
595
+ : data && typeof data === "object"
596
+ ? String((data as Record<string, unknown>).event ?? (data as Record<string, unknown>).type ?? "")
597
+ : "";
598
+
599
+ switch (event.trim().toLowerCase()) {
600
+ case "frame":
601
+ case "videodecode":
602
+ case "playing":
603
+ return "playing";
604
+ case "stalled":
605
+ case "paused":
606
+ return "stalled";
607
+ case "error":
608
+ case "sourceclosed":
609
+ return "failed";
610
+ default:
611
+ return null;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * The origin a message must come from to be believed, or `""` for an address
617
+ * that is not a URL at all. Kept here so the rule is tested rather than
618
+ * inlined in a component.
619
+ */
620
+ export function playerOrigin(host: string | null | undefined): string {
621
+ if (!host) return "";
622
+ try {
623
+ return new URL(host).origin;
624
+ } catch {
625
+ return "";
626
+ }
627
+ }
628
+
629
+ export type TTileView = {
630
+ state: TTileState;
631
+ label: string;
632
+ /**
633
+ * A sentence that COVERS THE TILE. Only for states where there is nothing
634
+ * worth looking at underneath it — never over a picture that may be fine.
635
+ */
636
+ detail: string | null;
637
+ /**
638
+ * A quieter sentence for the badge's tooltip and the focused camera's panel.
639
+ * `unverified` uses this rather than `detail` for exactly one reason: there
640
+ * may well be a good picture under that badge, and a scrim over a working
641
+ * camera would be a worse lie than the one being fixed.
642
+ */
643
+ note: string | null;
644
+ /** Whether the player page may be embedded at all. */
645
+ showPlayer: boolean;
646
+ };
647
+
648
+ /**
649
+ * The sentence behind "Not confirmed", said once on the focused camera's panel
650
+ * rather than nine times across the wall.
651
+ *
652
+ * **Written for a guard or a property manager, not for us.** The first version
653
+ * of this line explained cross-origin isolation — "the player belongs to the
654
+ * video service and a browser cannot look inside it" — which is true, is the
655
+ * reason, and is none of the reader's business. The owner read it on the live
656
+ * wall and said so. A reader of this screen has exactly three questions: is
657
+ * this camera working, what do I do if it is not, and what can I do here. WHY
658
+ * the software cannot answer the first one is our problem, not theirs.
659
+ *
660
+ * So: what we cannot do, then what they should do. Nothing else. The claim is
661
+ * every bit as honest as the paragraph it replaces — it just stops making the
662
+ * reader sit through an engineering lesson to get to the instruction.
663
+ */
664
+ export const UNVERIFIED_DETAIL =
665
+ "This page cannot confirm the picture is arriving, so check the tile yourself. If it is blank or frozen, report the camera.";
666
+
667
+ /**
668
+ * Rule 4, plus every reason a tile may have nothing to show.
669
+ *
670
+ * Order matters: the browser being offline beats everything (it explains every
671
+ * tile at once), then the record's own problems, then the server's verdict on
672
+ * live video, and only then what the embedded page is actually doing.
673
+ *
674
+ * ## WHY THIS NO LONGER SAYS "Live" WHEN THE PAGE LOADS
675
+ *
676
+ * It used to, and it was wrong in production: on a live wall, one camera
677
+ * rendered pure white and another pure black, and both carried a green "Live".
678
+ * A third, on a nine-tile layout, was a black rectangle with a green dot. In
679
+ * every case the player page had loaded and no picture was arriving.
680
+ *
681
+ * The badge was asserting a fact nobody had checked. The video service returns
682
+ * its player HTML for any channel — including one with no camera behind it —
683
+ * so the `load` event proves the SERVICE is up and proves nothing at all about
684
+ * the CAMERA. Building "Live" on it is the same class of defect as the mobile
685
+ * app's "Live" badge over a nine-second-old still: a guard is told he is
686
+ * watching something he is not.
687
+ *
688
+ * So the page loading now earns `unverified`, and `live` is reserved for a
689
+ * frame the video service actually reported (`playerSignal`). Today no camera
690
+ * on this estate will reach `live`, and that is the honest answer rather than
691
+ * a comfortable one. The two-line change on the video service's side that
692
+ * would light these up is described in the PR; it is not ours to make.
693
+ *
694
+ * A tile deliberately does NOT decay from `unverified` to `no-signal` on a
695
+ * timer. Asserting death without evidence is the same mistake as asserting
696
+ * life without evidence, pointed the other way.
697
+ */
698
+ export function tileView(
699
+ camera: TWallCamera | null | undefined,
700
+ opts: { online?: boolean; player?: TPlayerState } = {}
701
+ ): TTileView {
702
+ const online = opts.online !== false;
703
+ const dead = (state: TTileState, detail: string | null): TTileView => ({
704
+ state,
705
+ label: TILE_LABELS[state],
706
+ detail,
707
+ note: detail,
708
+ showPlayer: false,
709
+ });
710
+
711
+ if (!camera) return dead("unavailable", "No camera in this position.");
712
+ if (!online)
713
+ return dead("offline", "This browser has no internet connection.");
714
+ // The server's refusal wins over anything reconstructed here. It covers the
715
+ // same two cases below AND the ones only it can know — chiefly "no recorder is
716
+ // configured for this camera's relay", which is the honest answer for every
717
+ // camera until the API host is configured, and which used to be replaced by
718
+ // our own guess.
719
+ const reason = camera.unavailableReason?.trim();
720
+ if (reason) return dead("unavailable", reason);
721
+ if (camera.status && camera.status !== "active")
722
+ return dead("unavailable", "This camera is not active.");
723
+ if (!camera.host) return dead("unavailable", "This camera has no address configured.");
724
+
725
+ const live = capabilityView(camera, "liveVideo");
726
+ if (live.state === "unsupported") return dead("unavailable", live.detail);
727
+
728
+ const player = opts.player ?? "loading";
729
+ const showing = (state: TTileState, detail: string | null): TTileView => ({
730
+ state,
731
+ label: TILE_LABELS[state],
732
+ detail,
733
+ note: detail,
734
+ showPlayer: true,
735
+ });
736
+
737
+ switch (player) {
738
+ case "failed":
739
+ // Invented: only the browser can know the page never loaded, so the
740
+ // server has no reason code for it. Worded as a fact, not a diagnosis —
741
+ // we genuinely cannot tell a dead camera from a dead video service.
742
+ return showing(
743
+ "no-signal",
744
+ "No picture. The camera or the video service may be down. Report it if it stays like this."
745
+ );
746
+ case "stalled":
747
+ // It WAS playing and stopped. That is the one case where we can say a
748
+ // picture has genuinely gone away, because we saw one arrive first.
749
+ return showing(
750
+ "no-signal",
751
+ "The picture stopped arriving. The camera or the video service may have dropped. Report it if it does not come back."
752
+ );
753
+ case "playing":
754
+ return showing("live", null);
755
+ case "page-up":
756
+ return { ...showing("unverified", null), note: UNVERIFIED_DETAIL };
757
+ default:
758
+ return showing("connecting", null);
759
+ }
760
+ }
761
+
762
+ /* -------------------------------------------------------------------------- */
763
+ /* Health — RULE 6 */
764
+ /* -------------------------------------------------------------------------- */
765
+
766
+ /**
767
+ * One camera's health, as `GET /site-cameras/site/:siteId/health` returns it.
768
+ *
769
+ * **Reachability is a per-RECORDER answer**, cached per recorder on the server:
770
+ * twelve cameras hanging off one device is one TCP connect shared between the
771
+ * tiles, not twelve. That is why a wall may ask for this at all.
772
+ *
773
+ * **`firmwareVersion` and `deviceTime` are null on this estate and say why.**
774
+ * They are facts you get over the recorder's HTTP interface, which is not
775
+ * reachable — so the server sends `detailUnavailableReason` rather than leaving
776
+ * two empty fields to read as "checked, and fine".
777
+ */
778
+ export type TWallCameraHealth = {
779
+ health?: "ok" | "drifted" | "unreachable" | "unsupported" | null;
780
+ reachable?: boolean | null;
781
+ reason?: string | null;
782
+ /** ISO timestamp of the last picture this server actually got. */
783
+ lastFrameAt?: string | null;
784
+ driftSeconds?: number | null;
785
+ firmwareVersion?: string | null;
786
+ deviceTime?: string | null;
787
+ detailUnavailableReason?: string | null;
788
+ };
789
+
790
+ export type THealthTone = "ok" | "warn" | "down" | "unknown";
791
+
792
+ export type THealthView = {
793
+ tone: THealthTone;
794
+ /** Always a whole short phrase — never a bare word beside a dot. */
795
+ label: string;
796
+ /** The server's own sentence when it sent one. */
797
+ detail: string | null;
798
+ /** "4 min ago", or `null` when this server has never had a picture. */
799
+ lastFrame: string | null;
800
+ };
801
+
802
+ /*
803
+ THE FIRMWARE-AND-CLOCK LINE IS GONE, AND IT IS NOT COMING BACK.
804
+
805
+ The server sends `detailUnavailableReason` — "Firmware and device clock are
806
+ not available: this recorder is reachable over video only." — so that two
807
+ blank fields do not read as "checked, and fine". That is a good reason to
808
+ send it and a bad reason to DRAW it: this screen never showed firmware or a
809
+ device clock in the first place, so the sentence was explaining the absence
810
+ of something the reader had not been offered and could not use. A guard
811
+ cannot act on a firmware version. Softening the wording would have kept the
812
+ noise, so the line is removed instead. The field is still on
813
+ `TWallCameraHealth` because the API sends it; nothing on the wall draws it.
814
+ */
815
+
816
+ const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
817
+ ok: { tone: "ok", label: "Recorder responding" },
818
+ drifted: { tone: "warn", label: "Recorder clock is out" },
819
+ unreachable: { tone: "down", label: "Recorder not responding" },
820
+ unsupported: { tone: "unknown", label: "Recorder not checked" },
821
+ };
822
+
823
+ /**
824
+ * Rule 6. This is about the RECORDER, and the tile's own badge is about the
825
+ * player page — the two are reported separately and never merged, because they
826
+ * genuinely disagree: a working relay in front of a dead recorder shows a live
827
+ * page and an unreachable device, and a supervisor who is told only "Live"
828
+ * learns the wrong thing.
829
+ */
830
+ export function healthView(
831
+ health: TWallCameraHealth | null | undefined,
832
+ now: number = Date.now()
833
+ ): THealthView {
834
+ const known = health?.health ? HEALTH_LABELS[health.health] : undefined;
835
+ const fallback: { tone: THealthTone; label: string } =
836
+ health?.reachable === true
837
+ ? HEALTH_LABELS.ok
838
+ : health?.reachable === false
839
+ ? HEALTH_LABELS.unreachable
840
+ : { tone: "unknown", label: "Recorder not checked yet" };
841
+
842
+ const { tone, label } = known ?? fallback;
843
+
844
+ return {
845
+ tone,
846
+ label,
847
+ detail: (health?.reason || "").trim() || null,
848
+ lastFrame: agoLabel(health?.lastFrameAt, now),
849
+ };
850
+ }
851
+
852
+ /**
853
+ * "just now" / "40s ago" / "6 min ago" / "3 h ago".
854
+ *
855
+ * Coarse on purpose above a minute: a supervisor is deciding whether a picture
856
+ * is CURRENT, and "6 min ago" answers that where "371s ago" makes them do
857
+ * arithmetic to reach the same answer.
858
+ */
859
+ export function agoLabel(iso: string | null | undefined, now: number = Date.now()): string | null {
860
+ if (!iso) return null;
861
+ const at = Date.parse(iso);
862
+ if (!Number.isFinite(at)) return null;
863
+
864
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
865
+ if (seconds < 5) return "just now";
866
+ if (seconds < 60) return `${seconds}s ago`;
867
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
868
+ return `${Math.round(seconds / 3600)} h ago`;
869
+ }
870
+
871
+ /* -------------------------------------------------------------------------- */
872
+ /* Digital zoom */
873
+ /* -------------------------------------------------------------------------- */
874
+
875
+ /**
876
+ * DIGITAL ZOOM IS CROPPING, NOT PTZ, AND THE DIFFERENCE IS THE WHOLE POINT.
877
+ *
878
+ * This magnifies the picture already on the screen. **No request leaves the
879
+ * browser and no hardware moves** — which is exactly why it works today on
880
+ * every camera while "Move camera" does not, and why the two must never look
881
+ * like the same control.
882
+ *
883
+ * The arithmetic is identical to the mobile build's (`zoomable.tsx`), including
884
+ * the 4x ceiling, so the same gesture gives the same result on a phone and on a
885
+ * laptop.
886
+ */
887
+ export const MAX_DIGITAL_ZOOM = 4;
888
+
889
+ /** Beyond 4x a substream frame is enlarged pixels — a magnification that shows
890
+ * less than you started with is a control that lies. */
891
+ export function clampZoom(scale: number): number {
892
+ if (!Number.isFinite(scale)) return 1;
893
+ return Math.min(MAX_DIGITAL_ZOOM, Math.max(1, scale));
894
+ }
895
+
896
+ /**
897
+ * Keeps the magnified picture covering the tile.
898
+ *
899
+ * At scale `s` the picture is `s` times the frame, so it may be moved by half
900
+ * the overflow each way and no further. Without this a drag walks the picture
901
+ * off the tile and leaves a supervisor looking at the background — arriving at
902
+ * the blank-rectangle failure this whole surface exists to prevent, by gesture.
903
+ */
904
+ export function clampPan(
905
+ offset: { x: number; y: number },
906
+ scale: number,
907
+ frame: { width: number; height: number }
908
+ ): { x: number; y: number } {
909
+ const limitX = Math.max(0, (frame.width * (scale - 1)) / 2);
910
+ const limitY = Math.max(0, (frame.height * (scale - 1)) / 2);
911
+
912
+ return {
913
+ x: Math.min(limitX, Math.max(-limitX, offset.x || 0)),
914
+ y: Math.min(limitY, Math.max(-limitY, offset.y || 0)),
915
+ };
916
+ }
917
+
918
+ /**
919
+ * One wheel notch. `deltaY` varies wildly between a mouse, a trackpad and a
920
+ * browser's delta mode, so the DIRECTION is used and the magnitude is not: a
921
+ * fixed step is predictable everywhere, and predictability is worth more here
922
+ * than proportionality.
923
+ */
924
+ export function zoomStep(scale: number, deltaY: number): number {
925
+ return clampZoom(deltaY < 0 ? scale * 1.25 : scale / 1.25);
926
+ }