@7365admin1/layer-common 4.57.3 → 4.58.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.58.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e186caf: Robot live view: no more silent reload every ~6 s. PadBot's video URL expiry comes without a time zone ("2026-09-26 16:26:02"); read as local time it was already past, so the panel fetched a new URL every 5 s forever. An expiry that has no zone, or is under a minute away, no longer schedules a re-fetch (the stream's own failure still reconnects, with capped backoff, then Retry). The no-picture and stall watchdogs no longer count while the page is hidden.
8
+ - c4adb97: Robots: a robot's name tag stays inside the plan. Near the right edge it sits to the robot's left, and near the top it goes below: on robot detail (name + status) and on the building map, as the mini-map already did. Before, the S2 docked at the top-right showed only "HERO-OT".
9
+
10
+ ## 4.58.0
11
+
12
+ ### Minor Changes
13
+
14
+ - fd4c0c9: Robots: when a robot is online but its position isn't arriving, robot detail, the site mini-map and the building map say so in a calm banner ("<Robot> is online, but we're not receiving its position right now.") and detail keeps the last position seen faded, tagged "last known · HH:MM". The live-view segment fix now also catches a stray H.264 delimiter split across two TS packets. New `scripts/sync-robot-live.mjs` copies the web live-view player into the mobile apps.
15
+
3
16
  ## 4.57.3
4
17
 
5
18
  ### Patch Changes
@@ -1611,11 +1624,11 @@
1611
1624
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1612
1625
  own rule from two endpoints the console already calls, unprojected:
1613
1626
 
1614
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1615
- GET /api/roles/id/:role that membership's role document
1627
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1628
+ GET /api/roles/id/:role that membership's role document
1616
1629
 
1617
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1618
- staff = member.type === "admin" && role.type === "admin"
1630
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1631
+ staff = member.type === "admin" && role.type === "admin"
1619
1632
 
1620
1633
  `role.default` is the marker because it is the only property of a platform
1621
1634
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -829,6 +829,8 @@
829
829
  .hero-me { position: absolute; width: 0; height: 0; }
830
830
  .hero-me__tag { position: absolute; left: 14px; top: -26px; white-space: nowrap; font-size: 11px; font-weight: 800; padding: 2px 6px; background: var(--hero-text); color: var(--hero-bg); line-height: 1.45; }
831
831
  .hero-me__tag > span { font-weight: 400; opacity: 0.75; }
832
+ .hero-me__tag--left { left: auto; right: 14px; }
833
+ .hero-me__tag--below { top: 14px; }
832
834
  .hero-me__radar {
833
835
  position: absolute;
834
836
  left: -36px;
@@ -1009,6 +1011,8 @@
1009
1011
  box-shadow: var(--hero-shadow-sm);
1010
1012
  cursor: pointer;
1011
1013
  }
1014
+ /* Near the plan's right edge the name sits to the pin's left (nameTagSide) - never clipped. */
1015
+ .hero .hero-bmap__name--left { left: auto; right: 6px; transform-origin: 100% 0; border-left: 0; border-right: 3px solid var(--hero-bot); }
1012
1016
  .hero-bmap__floorlabel { position: absolute; left: -14px; top: 50%; transform-origin: 0 0; font-size: 12px; font-weight: 800; padding: 1px 6px; background: var(--hero-text); color: var(--hero-bg); white-space: nowrap; }
1013
1017
  .hero-bmap__shaft { position: absolute; width: 0; height: 0; transform-style: preserve-3d; transition: opacity 0.6s ease; }
1014
1018
  .hero-bmap__shaftwall { position: absolute; left: 0; top: 0; box-sizing: border-box; transform-origin: top; transform: rotateX(90deg); background: color-mix(in srgb, var(--hero-accent) 28%, transparent); border: 1px solid var(--hero-accent); }
@@ -172,7 +172,14 @@
172
172
  <span class="hero-bmap__body" aria-hidden="true" />
173
173
  <span class="hero-bmap__pin" :style="{ height: `${look.pinH}px` }" aria-hidden="true" />
174
174
  <span class="hero-bmap__pin hero-bmap__pin--y" :style="{ height: `${look.pinH}px` }" aria-hidden="true" />
175
- <button type="button" class="hero-bmap__name" :style="{ transform: `translateZ(${look.pinZ}px) ${unskew}` }" @click.stop="emit('open', r.id)" @mousedown.stop>
175
+ <button
176
+ type="button"
177
+ class="hero-bmap__name"
178
+ :class="{ 'hero-bmap__name--left': nameTagSide(r.left, r.top, r.name, plane.w, plane.h, BUILDING_TAG).flipX }"
179
+ :style="{ transform: `translateZ(${look.pinZ}px) ${unskew}` }"
180
+ @click.stop="emit('open', r.id)"
181
+ @mousedown.stop
182
+ >
176
183
  {{ r.name }}
177
184
  </button>
178
185
  </div>
@@ -219,6 +226,7 @@
219
226
  </div>
220
227
  <div class="hero-bmap__traytext">{{ unplacedLine }}</div>
221
228
  </div>
229
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
222
230
  </aside>
223
231
  </div>
224
232
  </template>
@@ -235,6 +243,8 @@ import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
235
243
  import { openDoorNames } from "../../utils/robot-fleet-core";
236
244
  import { robotMapViews, robotMapsByFloor, type TRobotMapView } from "../../utils/robot-maps";
237
245
  import {
246
+ BUILDING_TAG,
247
+ nameTagSide,
238
248
  brandName,
239
249
  floorName,
240
250
  mapFloors,
@@ -246,6 +256,8 @@ import {
246
256
  routeTags,
247
257
  rowStatus,
248
258
  unplacedRobots,
259
+ positionGap,
260
+ positionGapText,
249
261
  unplacedText,
250
262
  type TMapRobot,
251
263
  type TPatrolRouteView,
@@ -430,6 +442,11 @@ const shafts = computed(() =>
430
442
  );
431
443
 
432
444
  const unplaced = computed(() => unplacedRobots(rows.value, states.value));
445
+ /** Online robots whose position isn't arriving: not drawn, named instead. */
446
+ const gapText = computed(() => {
447
+ const names = rows.value.filter((r) => positionGap(states.value[String(r._id)], states.value[String(r._id)]?.capabilities)).map((r) => String(r.name ?? ""));
448
+ return names.length ? positionGapText(names) : "";
449
+ });
433
450
  const unplacedLine = computed(() => unplacedText(unplaced.value.map((r) => brandName(r.brand, brands.value))) ?? "");
434
451
 
435
452
  const emptyNote = computed(() => {
@@ -74,6 +74,7 @@
74
74
  <div v-if="!noInterface" class="hero-detail__grid">
75
75
  <div>
76
76
  <div class="hero-section__title">Location</div>
77
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
77
78
  <div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
78
79
  <div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
79
80
  <div class="hero-fact__label">{{ f.label }}</div>
@@ -95,12 +96,12 @@
95
96
  </div>
96
97
  <RobotLivePlan
97
98
  :name="record?.name"
98
- :status-label="statusLabel"
99
- :point="posLive && robotOnShownMap ? point : null"
100
- :stale="shown?.stale === true"
101
- :heading="headingDeg(shown)"
102
- :trail="trail"
103
- :readout="readout"
99
+ :status-label="showLastKnown ? lastKnownLabel(lastKnown!.at, now) : statusLabel"
100
+ :point="posLive && robotOnShownMap ? point : showLastKnown ? lastKnown!.point : null"
101
+ :stale="shown?.stale === true || showLastKnown"
102
+ :heading="showLastKnown ? lastKnown!.heading : headingDeg(shown)"
103
+ :trail="showLastKnown ? [] : trail"
104
+ :readout="showLastKnown ? '' : readout"
104
105
  :lost="posLost"
105
106
  :route="route"
106
107
  :routes="selectedMap ? mapRoutes : null"
@@ -242,6 +243,7 @@ import {
242
243
  faultBadge,
243
244
  headingDeg,
244
245
  isMoving,
246
+ lastKnownLabel,
245
247
  mergePose,
246
248
  optimisticMode,
247
249
  patrolRunHint,
@@ -250,6 +252,8 @@ import {
250
252
  savedPointGroups,
251
253
  unplacedStopsText,
252
254
  planPoint,
255
+ positionGap,
256
+ positionGapText,
253
257
  positionText,
254
258
  robotAbilities,
255
259
  robotErrorMessage,
@@ -379,6 +383,16 @@ const point = computed(() => planPoint(shown.value as any));
379
383
  const posLive = computed(() => reportsPosition.value && online.value && !streamLost.value && !!point.value);
380
384
  const posLost = computed(() => reportsPosition.value && !!live.value && (!live.value.ok || live.value.online === false || streamLost.value));
381
385
  const trail = ref<Array<{ x: number; y: number }>>([]);
386
+ /* Online, but no coordinates arriving (a gap on our side, not "the robot is lost"): a calm banner, and the last position seen on this screen drawn faded. */
387
+ const gap = computed(() => positionGap(live.value, caps.value));
388
+ const lastKnown = ref<{ point: { left: string; top: string }; heading: number; at: number; mapId: string | null } | null>(null);
389
+ watch(point, (p) => {
390
+ if (p && posLive.value) lastKnown.value = { point: p, heading: headingDeg(shown.value), at: live.value?.positionAt ?? Date.now(), mapId: live.value?.mapId ?? null };
391
+ });
392
+ const showLastKnown = computed(
393
+ () => gap.value && !!lastKnown.value && (!selectedMap.value || !lastKnown.value.mapId || lastKnown.value.mapId === selectedMap.value.mapId),
394
+ );
395
+ const gapText = computed(() => (gap.value ? positionGapText(record.value?.name ?? "", showLastKnown.value) : ""));
382
396
  watch(
383
397
  () => shown.value?.planPosition,
384
398
  (p) => {
@@ -455,7 +469,9 @@ watch(
455
469
  /** Without robot maps the placeholder plan shows the robot as before; with them, only on its own map. */
456
470
  const robotOnShownMap = computed(() => !selectedMap.value || selectedMap.value.current);
457
471
  const currentMapLabel = computed(() => mapViews.value.find((m) => m.current)?.label ?? "");
458
- const hereText = computed(() => (currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : "The robot has not said which map it is on"));
472
+ const hereText = computed(() =>
473
+ currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : gap.value ? "" : "We're not receiving which map the robot is on right now",
474
+ );
459
475
  /** The routes saved on the map shown (a route without a map id goes with the robot's current map). */
460
476
  const mapRoutes = computed(() =>
461
477
  (patrol.value?.routes ?? []).filter((r) => (r.mapId ? r.mapId === selectedMap.value?.mapId : selectedMap.value?.current)),
@@ -18,9 +18,9 @@
18
18
  <span v-for="t in trailDots(trail)" :key="t.key" class="hero-trail" :style="{ left: t.left, top: t.top, opacity: t.op }" aria-hidden="true" />
19
19
  <span class="hero-cross hero-cross--h" :style="{ top: point.top, transition: `top ${tickMs}ms linear` }" aria-hidden="true" />
20
20
  <span class="hero-cross hero-cross--v" :style="{ left: point.left, transition: `left ${tickMs}ms linear` }" aria-hidden="true" />
21
- <div class="hero-readout">{{ readout }}</div>
21
+ <div v-if="readout" class="hero-readout">{{ readout }}</div>
22
22
  <div class="hero-me" :class="{ 'hero-me--stale': stale }" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
23
- <div class="hero-me__tag">{{ name }} <span>{{ statusLabel }}</span></div>
23
+ <div class="hero-me__tag" :class="tagClass">{{ name }} <span>{{ statusLabel }}</span></div>
24
24
  <div class="hero-me__radar" aria-hidden="true" />
25
25
  <div class="hero-me__ping" aria-hidden="true" />
26
26
  <div class="hero-me__body" :style="{ transform: `rotate(${heading}deg)` }" aria-hidden="true"><div class="hero-me__tick" /></div>
@@ -35,9 +35,10 @@
35
35
  import RobotFloorPlan from "./FloorPlan.vue";
36
36
  import RobotPatrolRoutes from "./PatrolRoutes.vue";
37
37
  import RobotReconnecting from "./Reconnecting.vue";
38
- import { trailDots, type TPatrolRouteView } from "../../utils/robot-display";
38
+ import { computed } from "vue";
39
+ import { DETAIL_TAG, nameTagSide, trailDots, type TPatrolRouteView } from "../../utils/robot-display";
39
40
 
40
- withDefaults(
41
+ const props = withDefaults(
41
42
  defineProps<{
42
43
  name?: string;
43
44
  statusLabel?: string;
@@ -69,4 +70,10 @@ withDefaults(
69
70
  { stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
70
71
  );
71
72
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
73
+ // The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
74
+ const tagClass = computed(() => {
75
+ if (!props.point) return {};
76
+ const side = nameTagSide(props.point.left, props.point.top, `${props.name} ${props.statusLabel}`, 320, 240, DETAIL_TAG);
77
+ return { "hero-me__tag--left": side.flipX, "hero-me__tag--below": side.below };
78
+ });
72
79
  </script>
@@ -55,6 +55,7 @@
55
55
  <span v-if="floor !== null" class="hero-mini__count">{{ robots.length }} on {{ floorName(floor) }}</span>
56
56
  </div>
57
57
  <div v-if="unplaced" class="hero-notice">{{ unplaced }}</div>
58
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
58
59
  </div>
59
60
  </template>
60
61
 
@@ -72,6 +73,8 @@ import {
72
73
  mapRobots,
73
74
  nameTagSide,
74
75
  pickFloor,
76
+ positionGap,
77
+ positionGapText,
75
78
  robotStatus,
76
79
  unplacedRobots,
77
80
  unplacedText,
@@ -159,6 +162,12 @@ const unplaced = computed(() =>
159
162
  unplacedText(unplacedRobots(props.rows, props.states).map((r) => brandName(r.brand, props.brands))),
160
163
  );
161
164
 
165
+ /** Online robots whose position isn't arriving: not drawn (never a guessed dot), named here instead. */
166
+ const gapText = computed(() => {
167
+ const names = props.rows.filter((r) => positionGap(props.states[String(r._id)], props.states[String(r._id)]?.capabilities)).map((r) => String(r.name ?? ""));
168
+ return names.length ? positionGapText(names) : "";
169
+ });
170
+
162
171
  const TONE: Record<string, string> = {
163
172
  success: "rgb(var(--v-theme-success))",
164
173
  warning: "rgb(var(--v-theme-warning))",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.57.3",
5
+ "version": "4.58.1",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Web-only robot controls: the live-view player's URL handling and the
3
- * manual-drive client. Not part of the mobile-synced `robot-display.ts`
4
- * (mobile has no live view or manual control - parity exception, like map
5
- * editing, decision 81).
2
+ * Robot controls: the live-view player's URL handling and the manual-drive
3
+ * client. Not part of the mobile-synced `robot-display.ts`. The live-view
4
+ * player also runs on the phones: `scripts/sync-robot-live.mjs` bundles
5
+ * `startLivePlayer` into a WebView page for the RN apps. Manual control stays
6
+ * web-only (parity exception, like map editing, decision 81).
6
7
  */
7
8
 
8
9
  /* ============================================================ live view */
@@ -26,11 +27,18 @@ export function liveVideoKind(url: unknown): TVideoKind {
26
27
  return "page";
27
28
  }
28
29
 
29
- /** When to ask for a fresh URL: 30 s before it expires (never sooner than 5 s); null when it does not expire. */
30
+ /**
31
+ * When to ask for a fresh URL: 30 s before it expires. Null (no scheduled
32
+ * re-mint - the stream's own failure re-mints, with capped backoff) when the
33
+ * expiry is missing, has no time zone, or is under a minute away. PadBot sent
34
+ * "2026-09-26 16:26:02" (no zone): read as local time it was already past, so
35
+ * the old 5 s floor re-minted every ~6 s forever, silently (prod 2026-09-26).
36
+ */
30
37
  export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): number | null {
31
- const t = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
32
- if (!Number.isFinite(t)) return null;
33
- return Math.max(5_000, t - now - 30_000);
38
+ if (typeof expiresAt !== "string" || !/(Z|[+-]\d\d:?\d\d)$/i.test(expiresAt.trim())) return null;
39
+ const t = Date.parse(expiresAt);
40
+ if (!Number.isFinite(t) || t - now < 60_000) return null;
41
+ return Math.max(30_000, t - now - 30_000);
34
42
  }
35
43
 
36
44
  export const LIVE_NO_PICTURE =
@@ -76,7 +84,10 @@ export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, un
76
84
  */
77
85
  export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
78
86
  const b = data instanceof Uint8Array ? data : new Uint8Array(data);
79
- const video = new Set<number>();
87
+ // Per video PID: where its last 4 payload bytes sit, so a header split across
88
+ // two TS packets is still found (a remuxed stream can put one there - 6 of 240
89
+ // in a local ffmpeg remux, and one missed header stops the picture).
90
+ const tails = new Map<number, number[]>();
80
91
  let n = 0;
81
92
  for (let i = 0; i + 188 <= b.length; i += 188) {
82
93
  if (b[i] !== 0x47) continue;
@@ -85,17 +96,24 @@ export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
85
96
  const pid = ((b[i + 1]! & 0x1f) << 8) | b[i + 2]!;
86
97
  let p = i + 4;
87
98
  if (afc & 2) p += 1 + b[p]!;
88
- // A PES start names its stream: 0xE0-0xEF = video.
89
- if (b[i + 1]! & 0x40 && b[p] === 0 && b[p + 1] === 0 && b[p + 2] === 1 && (b[p + 3]! & 0xf0) === 0xe0) video.add(pid);
90
- if (!video.has(pid)) continue;
91
- for (let k = p; k + 4 < i + 188; k++) {
92
- if (b[k] === 0 && b[k + 1] === 0 && b[k + 2] === 1 && b[k + 3] === 0x09 && (b[k + 4]! & 7) === 0) {
93
- b[k + 3] = 0x60;
94
- b[k + 4] = 0x01;
99
+ const end = i + 188;
100
+ if (p >= end) continue;
101
+ // A PES start names its stream: 0xE0-0xEF = video. The PES header is never inside a NAL unit.
102
+ if (b[i + 1]! & 0x40 && b[p] === 0 && b[p + 1] === 0 && b[p + 2] === 1 && (b[p + 3]! & 0xf0) === 0xe0) tails.set(pid, []);
103
+ const tail = tails.get(pid);
104
+ if (!tail) continue;
105
+ const t = tail.length;
106
+ const at = (k: number) => (k < t ? tail[k]! : p + k - t);
107
+ const len = t + end - p;
108
+ for (let k = 0; k + 4 < len; k++) {
109
+ if (b[at(k)] === 0 && b[at(k + 1)] === 0 && b[at(k + 2)] === 1 && b[at(k + 3)] === 0x09 && (b[at(k + 4)]! & 7) === 0) {
110
+ b[at(k + 3)] = 0x60;
111
+ b[at(k + 4)] = 0x01;
95
112
  n++;
96
113
  k += 4;
97
114
  }
98
115
  }
116
+ tails.set(pid, Array.from({ length: Math.min(4, len) }, (_, j) => at(len - Math.min(4, len) + j)));
99
117
  }
100
118
  return n;
101
119
  }
@@ -188,6 +206,9 @@ export function startLivePlayer(
188
206
  };
189
207
  // ponytail: one 1 s poll instead of juggling media events - videoWidth has no reliable event everywhere.
190
208
  const tick = setInterval(() => {
209
+ // A hidden page gets no frames painted (and Chrome pauses muted background
210
+ // video): not the camera's fault, so the watchdogs don't count it.
211
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
191
212
  waited += tickMs;
192
213
  if (!pictured) {
193
214
  if (video.videoWidth > 0) {
@@ -1204,13 +1204,44 @@ export function positionText(live: TLiveRobotState | null | undefined, capabilit
1204
1204
  if (capabilities?.readLocation !== true) return "Not reported";
1205
1205
  const p = live?.position;
1206
1206
  if (!live?.ok || live.online === false) return "Reconnecting";
1207
- // Answering, but it has no position right now (e.g. not relocalised) - said, not guessed.
1207
+ // Answering, but no position came with this reading - said, not guessed (see positionGap).
1208
1208
  if (!p || !finite(p.x) || !finite(p.y)) return "Position not reported right now";
1209
1209
  const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
1210
1210
  const seen = staleText(live, now);
1211
1211
  return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
1212
1212
  }
1213
1213
 
1214
+ /**
1215
+ * The robot is online and its brand is meant to report where it is, but this
1216
+ * reading carries no coordinates. Said as a gap in what we receive - never as
1217
+ * "the robot is lost": only the brand can say that, and none does today.
1218
+ */
1219
+ export function positionGap(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined): boolean {
1220
+ if (capabilities?.readLocation !== true || !live?.ok || live.online !== true || live.monitorable === false) return false;
1221
+ const p = live.position;
1222
+ return !p || !finite(p.x) || !finite(p.y);
1223
+ }
1224
+
1225
+ /**
1226
+ * "HERO-OTH is online, but we're not receiving its position right now. Its last
1227
+ * known position is shown faded." Several robots: one sentence, no faded part.
1228
+ */
1229
+ export function positionGapText(names: string | string[], lastKnownShown = false): string {
1230
+ const list = (Array.isArray(names) ? names : [names]).map((n) => (typeof n === "string" && n.trim()) || "The robot");
1231
+ if (!list.length) return "";
1232
+ const one = list.length === 1;
1233
+ const who = one ? list[0] : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
1234
+ const head = `${who} ${one ? "is" : "are"} online, but we're not receiving ${one ? "its position" : "their positions"} right now.`;
1235
+ return one && lastKnownShown ? `${head} Its last known position is shown faded.` : head;
1236
+ }
1237
+
1238
+ /** The faded marker's tag: "last known · 14:05" (device clock); "last known · 2 d ago" once older than 20 h. */
1239
+ export function lastKnownLabel(at: unknown, now: number = Date.now()): string {
1240
+ const t = typeof at === "number" ? at : at instanceof Date ? at.getTime() : NaN;
1241
+ if (!Number.isFinite(t)) return "last known";
1242
+ return now - t > 20 * 3_600_000 ? `last known · ${agoText(t, now)}` : `last known · ${new Date(t).toTimeString().slice(0, 5)}`;
1243
+ }
1244
+
1214
1245
  export type TRobotFact = { label: string; value: string; dim: boolean };
1215
1246
 
1216
1247
  /**
@@ -1578,16 +1609,29 @@ export function credentialPatch(
1578
1609
  }
1579
1610
 
1580
1611
  /**
1581
- * Keep a robot's name tag inside the mini-map plan: flip it to the robot's
1582
- * left near the right edge, and below the robot near the top edge.
1583
- * ponytail: width estimated from the text (10px Archivo 600) against the
1584
- * smallest plan (320px wide, 240 tall); measure if a tag must be exact.
1612
+ * Keep a robot's name tag inside its plan (mini-map, building map, robot
1613
+ * detail): flip it to the robot's left near the right edge, and below the
1614
+ * robot near the top edge.
1615
+ * ponytail: width estimated from the text (default 10px Archivo 600: 6.2 px a
1616
+ * character + 8 px padding; `tag` for bigger tags) against the smallest plan
1617
+ * (320px wide, 240 tall); measure if a tag must be exact.
1585
1618
  */
1586
- export function nameTagSide(left: unknown, top: unknown, name: unknown, planW = 320, planH = 240) {
1619
+ export function nameTagSide(
1620
+ left: unknown,
1621
+ top: unknown,
1622
+ name: unknown,
1623
+ planW = 320,
1624
+ planH = 240,
1625
+ tag: { charW?: number; pad?: number; gap?: number; above?: number } = {},
1626
+ ) {
1587
1627
  const pct = (v: unknown) => (typeof v === "string" ? parseFloat(v) : typeof v === "number" ? v : 0) || 0;
1588
- const width = String(name ?? "").length * 6.2 + 8;
1628
+ const width = String(name ?? "").length * (tag.charW ?? 6.2) + (tag.pad ?? 8);
1589
1629
  return {
1590
- flipX: (pct(left) / 100) * planW + 10 + width > planW,
1591
- below: (pct(top) / 100) * planH < 20,
1630
+ flipX: (pct(left) / 100) * planW + (tag.gap ?? 10) + width > planW,
1631
+ below: (pct(top) / 100) * planH < (tag.above ?? 20),
1592
1632
  };
1593
1633
  }
1634
+
1635
+ /** The bigger tags: robot detail's "name + status" (11px 800, 14px from the dot, 26px above) and the building map's name (11px 800). */
1636
+ export const DETAIL_TAG = { charW: 7.4, pad: 12, gap: 14, above: 30 };
1637
+ export const BUILDING_TAG = { charW: 7.4, pad: 13, gap: 6, above: 0 };