@7365admin1/layer-common 4.54.0 → 4.55.0

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,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6719639: Robot maps: a robot with more than one map (PadBot) now has a map selector on robot detail and the site mini-map, opening on the map the robot is on ("Robot is here"). Each map shows its own image at its real shape, its saved points and its routes. Go to point offers another map's points only when PadBot can take the robot there (same map group), otherwise says "The robot must be on this map first."; Set position lists only the robot's current map. The building map draws a robot's map on its floor when the floor number is known and the floor has no saved plan.
8
+
9
+ ## 4.54.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 401e0a2: Robot detail: when the robot cannot be read, show the API's plain reason (e.g. "Ounitech rejected the robot list (sign-in) ...") under the title instead of only "Reconnecting". `mergeLiveStates` carries the optional `reason` from `/robots/states`.
14
+
3
15
  ## 4.54.0
4
16
 
5
17
  ### Minor Changes
@@ -1569,11 +1581,11 @@
1569
1581
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1570
1582
  own rule from two endpoints the console already calls, unprojected:
1571
1583
 
1572
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1573
- GET /api/roles/id/:role that membership's role document
1584
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1585
+ GET /api/roles/id/:role that membership's role document
1574
1586
 
1575
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1576
- staff = member.type === "admin" && role.type === "admin"
1587
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1588
+ staff = member.type === "admin" && role.type === "admin"
1577
1589
 
1578
1590
  `role.default` is the marker because it is the only property of a platform
1579
1591
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -663,6 +663,13 @@
663
663
  }
664
664
  .hero-plan--detail { max-height: 440px; }
665
665
  .hero-plan--pick { cursor: crosshair; }
666
+ /* The robot's own map image under the overlays; the plan takes its aspect ratio. */
667
+ .hero-plan__img { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.85; }
668
+ .hero-mapsel { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 6px; }
669
+ .hero-mapsel__here { font-size: 12px; padding: 1px 6px; border: 1px solid var(--hero-accent); color: var(--hero-accent); }
670
+ .hero-mapsel__hint { font-size: 12px; opacity: 0.75; }
671
+ .hero-plan__pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
672
+ .hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
666
673
  .hero-plan:fullscreen { background-color: var(--hero-bg); }
667
674
  .hero-plan__corner { position: absolute; width: 14px; height: 14px; }
668
675
  .hero-plan__corner--tl { left: -1px; top: -1px; border-top: 2px solid var(--hero-accent); border-left: 2px solid var(--hero-accent); }
@@ -1257,3 +1264,4 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1257
1264
  .hero-drive__pad button:active { background: var(--hero-accent); color: var(--hero-bg); }
1258
1265
  .hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1259
1266
  .hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
1267
+ .hero-pick__point:disabled { opacity: 0.45; cursor: not-allowed; }
@@ -116,7 +116,8 @@
116
116
  :style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
117
117
  >
118
118
  <img v-if="f.plan" class="hero-bmap__planimg" :src="f.plan.url" :style="f.plan.style" alt="" aria-hidden="true" draggable="false" />
119
- <template v-if="!fleet && !f.plan">
119
+ <img v-else-if="f.robotMap?.imageUrl" class="hero-bmap__planimg" :src="f.robotMap.imageUrl" style="left: 0; top: 0; width: 100%; height: 100%" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
120
+ <template v-if="!fleet && !f.plan && !f.robotMap?.imageUrl">
120
121
  <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
121
122
  <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
122
123
  <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
@@ -205,6 +206,7 @@
205
206
  <div class="hero-aside__title hero-bmap__asidetitle">Floors · top down</div>
206
207
  <div v-for="f in legend" :key="f.floorNo" class="hero-bmap__legendrow">
207
208
  <b>{{ floorName(f.floorNo) }}</b>
209
+ <div v-if="f.robotMaps.length" class="hero-mapsel__hint">Robot map: {{ f.robotMaps.map((m) => m.label + (m.current ? " (robot is here)" : "")).join(", ") }}</div>
208
210
  <div class="hero-bmap__chips">
209
211
  <button v-for="r in f.robots" :key="r.id" type="button" class="hero-bmap__chip" @click="emit('open', r.id)"><span :class="`hero-tone--${tone(r)}`">{{ r.name }}</span></button>
210
212
  <span v-if="!f.robots.length" class="hero-muted">—</span>
@@ -231,6 +233,7 @@ import { floorPlanFor } from "../../utils/robot-floorplan";
231
233
  import { floorAspect, floorDoors, placementInWorld, planBoxIn, routeOverlay, viewerLifts } from "../../utils/rmf-editor";
232
234
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
233
235
  import { openDoorNames } from "../../utils/robot-fleet-core";
236
+ import { robotMapViews, robotMapsByFloor, type TRobotMapView } from "../../utils/robot-maps";
234
237
  import {
235
238
  brandName,
236
239
  floorName,
@@ -304,9 +307,17 @@ const siteLabel = computed(() => props.siteName || names.value.siteName || "This
304
307
  const ids = computed(() => rows.value.map((r) => String(r._id)));
305
308
  const { states, connected } = useRobotLive(ids, siteStatesFeed());
306
309
  const routes = ref<Record<string, TPatrolRouteView[]>>({});
307
- const { getPatrolRoutes, getRobotBrands } = useRobots();
310
+ const { getPatrolRoutes, getRobotBrands, getRobotMaps } = useRobots();
311
+ /** Each security robot's own maps (PadBot), read once per robot: drawn on their floor when the floor has no saved plan. */
312
+ const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
308
313
  watch(ids, async (list) => {
309
314
  if (props.type !== "security") return void (routes.value = {});
315
+ const missing = list.filter((id) => !(id in robotMaps.value));
316
+ if (missing.length) {
317
+ const got: Record<string, Array<Record<string, any>>> = {};
318
+ await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
319
+ robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
320
+ }
310
321
  const next: Record<string, TPatrolRouteView[]> = {};
311
322
  await Promise.allSettled(list.map(async (id) => {
312
323
  const res = await getPatrolRoutes(id);
@@ -319,7 +330,19 @@ getRobotBrands().then((res: any) => (brands.value = res?.items ?? []), () => und
319
330
 
320
331
  /* ---- floors, features, robots per floor ---- */
321
332
  const shapes = computed<TEditorShape[]>(() => shapesFromMap(map.value));
322
- const floors = computed(() => buildingFloors(mapFloors(ids.value, states.value), map.value));
333
+ /** The robots' maps with a known floor, per floor (a robot's map is "current" when it reports it). */
334
+ const robotFloorMaps = computed(() => {
335
+ const all: TRobotMapView[] = [];
336
+ for (const id of ids.value) {
337
+ for (const m of robotMapViews(robotMaps.value[id], states.value[id]?.mapId ?? null, routes.value[id] ?? [])) {
338
+ const i = all.findIndex((x) => x.mapId === m.mapId);
339
+ if (i < 0) all.push(m);
340
+ else if (m.current) all[i] = { ...all[i]!, current: true };
341
+ }
342
+ }
343
+ return robotMapsByFloor(all);
344
+ });
345
+ const floors = computed(() => buildingFloors([...new Set([...mapFloors(ids.value, states.value), ...robotFloorMaps.value.keys()])].sort((a, b) => a - b), map.value));
323
346
  const focus = ref<number | null>(null);
324
347
  watch(floors, (f) => {
325
348
  if (focus.value !== null && !f.includes(focus.value)) focus.value = null;
@@ -361,6 +384,9 @@ const floors3d = computed(() =>
361
384
  return {
362
385
  floorNo,
363
386
  plan: floorImage(floorNo),
387
+ robotMaps: robotFloorMaps.value.get(floorNo) ?? [],
388
+ // ponytail: one robot map drawn per floor (the one a robot is on, else the first); the rest are listed.
389
+ robotMap: (robotFloorMaps.value.get(floorNo) ?? []).find((m) => m.current) ?? robotFloorMaps.value.get(floorNo)?.[0] ?? null,
364
390
  dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
365
391
  shapes: parts.shapes,
366
392
  glyphs: parts.glyphs,
@@ -20,6 +20,7 @@
20
20
  </div>
21
21
 
22
22
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
23
+ <div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
23
24
 
24
25
  <div v-if="fault" class="hero-faultbar" role="alert">
25
26
  <span class="hero-faultbar__code">{{ fault.code }}</span>
@@ -98,17 +99,29 @@
98
99
  <span class="hero-aside__title">{{ planTitle }}</span>
99
100
  <span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
100
101
  </div>
102
+ <div v-if="mapViews.length > 1 || (mapViews.length === 1 && !mapViews[0]!.current)" class="hero-mapsel">
103
+ <select v-model="selectedMapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
104
+ <option v-for="m in mapViews" :key="m.mapId" :value="m.mapId">{{ m.label }}{{ m.current ? " · Robot is here" : "" }}</option>
105
+ </select>
106
+ <span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
107
+ <span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
108
+ <span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
109
+ </div>
101
110
  <RobotLivePlan
102
111
  :name="record?.name"
103
112
  :status-label="statusLabel"
104
- :point="posLive ? point : null"
113
+ :point="posLive && robotOnShownMap ? point : null"
105
114
  :stale="shown?.stale === true"
106
115
  :heading="headingDeg(shown)"
107
116
  :trail="trail"
108
117
  :readout="readout"
109
118
  :lost="posLost"
110
119
  :route="route"
111
- :route-moving="statusMode === 'WORKING'"
120
+ :routes="selectedMap ? mapRoutes : null"
121
+ :image="selectedMap?.imageUrl ?? null"
122
+ :aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
123
+ :points="selectedMap?.points ?? []"
124
+ :route-moving="statusMode === 'WORKING' && robotOnShownMap"
112
125
  :spot="spot"
113
126
  :pick="spotOn"
114
127
  :tick-ms="tickMs"
@@ -159,7 +172,16 @@
159
172
  <RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
160
173
  </v-dialog>
161
174
  <v-dialog v-model="pickOpen" width="520">
162
- <RobotPointPicker v-if="pickOpen && pickKind" :kind="pickKind" :groups="pointGroups" :floor-no="live?.floorNo ?? null" @close="pickOpen = false" @pick="onPick" />
175
+ <RobotPointPicker
176
+ v-if="pickOpen && pickKind"
177
+ :kind="pickKind"
178
+ :groups="pointGroups"
179
+ :floor-no="live?.floorNo ?? null"
180
+ :current-map-id="live?.mapId ?? null"
181
+ :maps="mapViews"
182
+ @close="pickOpen = false"
183
+ @pick="onPick"
184
+ />
163
185
  </v-dialog>
164
186
 
165
187
  <RobotToast v-model="toast" />
@@ -179,6 +201,7 @@ import RobotLiveView from "./LiveView.vue";
179
201
  import RobotManualDrive from "./ManualDrive.vue";
180
202
  import RobotPointPicker from "./PointPicker.vue";
181
203
  import { faultPhotos } from "../../utils/robot-control";
204
+ import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
182
205
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
183
206
  import {
184
207
  brandName,
@@ -329,12 +352,17 @@ const readout = computed(() => `${positionText(live.value, caps.value, now.value
329
352
  const spot = ref<Array<{ x: number; y: number }>>([]);
330
353
  const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
331
354
 
332
- const route = computed<TPatrolRouteView | null>(() => patrol.value?.routes?.[0] ?? null);
355
+ const route = computed<TPatrolRouteView | null>(() => (selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.[0] ?? null));
333
356
  const floorText = computed(() => facts.value[2]?.value ?? "");
334
- const planTitle = computed(() => (spotOn.value ? "Spot clean" : route.value ? `Live position · ${route.value.name}` : "Live position"));
357
+ const planTitle = computed(() => {
358
+ if (spotOn.value) return "Spot clean";
359
+ if (selectedMap.value && !selectedMap.value.current) return `Map · ${selectedMap.value.label}`;
360
+ return route.value ? `Live position · ${route.value.name}` : "Live position";
361
+ });
335
362
  const planHint = computed(() => {
336
363
  if (spotOn.value) return "Click points on the plan, then send";
337
364
  if (noInterface.value) return "No position feed for this model";
365
+ if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
338
366
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
339
367
  return floorText.value;
340
368
  });
@@ -365,6 +393,37 @@ async function loadSchedule() {
365
393
  }
366
394
  }
367
395
 
396
+ /* ---- the robot's own maps (PadBot: one per floor / area): a selector, opening where the robot is ---- */
397
+ const mapItems = ref<Array<Record<string, any>>>([]);
398
+ const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
399
+ const selectedMapId = ref<string | null>(null);
400
+ const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
401
+ watch(mapViews, (views) => {
402
+ if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
403
+ });
404
+ // The robot changes map (lift / map switch): follow it while the robot's map was shown.
405
+ watch(
406
+ () => live.value?.mapId,
407
+ (to, from) => {
408
+ if (to && from && selectedMapId.value === from && mapViews.value.some((m) => m.mapId === to)) selectedMapId.value = to;
409
+ },
410
+ );
411
+ /** Without robot maps the placeholder plan shows the robot as before; with them, only on its own map. */
412
+ const robotOnShownMap = computed(() => !selectedMap.value || selectedMap.value.current);
413
+ const currentMapLabel = computed(() => mapViews.value.find((m) => m.current)?.label ?? "");
414
+ const hereText = computed(() => (currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : "The robot has not said which map it is on"));
415
+ /** The routes saved on the map shown (a route without a map id goes with the robot's current map). */
416
+ const mapRoutes = computed(() =>
417
+ (patrol.value?.routes ?? []).filter((r) => (r.mapId ? r.mapId === selectedMap.value?.mapId : selectedMap.value?.current)),
418
+ );
419
+ async function loadMaps() {
420
+ if (!record.value || noInterface.value) return;
421
+ try {
422
+ mapItems.value = (await getRobotMaps(props.robotId))?.items ?? [];
423
+ } catch {
424
+ mapItems.value = [];
425
+ }
426
+ }
368
427
  /* ---- the position stream: only for a brand that reports one ---- */
369
428
  function startStream() {
370
429
  stream?.stop();
@@ -416,7 +475,8 @@ const pickKind = ref<"navigate" | "relocalize" | null>(null);
416
475
  async function send(kind: TRobotCommandKind) {
417
476
  if (kind === "navigate" || kind === "relocalize") {
418
477
  try {
419
- pointGroups.value = savedPointGroups((await getRobotMaps(props.robotId))?.items);
478
+ mapItems.value = (await getRobotMaps(props.robotId))?.items ?? [];
479
+ pointGroups.value = savedPointGroups(mapItems.value);
420
480
  } catch {
421
481
  pointGroups.value = [];
422
482
  }
@@ -474,7 +534,7 @@ function onPick(p: TSavedPoint) {
474
534
  if (!kind) return;
475
535
  const body = pointCommandBody(kind, p, live.value);
476
536
  if (!body) {
477
- toast.value = "Set position: the robot is not on that floor.";
537
+ toast.value = "Set position: the robot must be on this map first.";
478
538
  return;
479
539
  }
480
540
  run(kind, body);
@@ -536,6 +596,8 @@ watch(
536
596
  record.value = null;
537
597
  plans.value = [];
538
598
  patrol.value = null;
599
+ mapItems.value = [];
600
+ selectedMapId.value = null;
539
601
  trail.value = [];
540
602
  spot.value = [];
541
603
  lastPose.value = null;
@@ -543,11 +605,15 @@ watch(
543
605
  streamLost.value = false;
544
606
  await loadRecord();
545
607
  loadSchedule();
608
+ loadMaps();
546
609
  },
547
610
  { immediate: true },
548
611
  );
549
612
  // The first live reading says whether there is a schedule to ask for (no interface = none).
550
- watch(noInterface, () => loadSchedule());
613
+ watch(noInterface, () => {
614
+ loadSchedule();
615
+ loadMaps();
616
+ });
551
617
  getRobotBrands().then(
552
618
  (res) => (brands.value = res?.items ?? []),
553
619
  () => undefined,
@@ -2,27 +2,45 @@
2
2
  <!--
3
3
  The floor plan both the mini-map and robot detail draw on: 4:3, 10% grid,
4
4
  wall outlines, the red dock, corner brackets and the scan line. A
5
- placeholder for the image each robot uploads (README "Assets"). Overlays
5
+ placeholder for the image each robot uploads (README "Assets"). With
6
+ `image` (the robot's own map, e.g. PadBot) the plan takes that image's
7
+ aspect ratio, so 0-100 overlay shares land on the right pixel. Overlays
6
8
  (routes, robots, trail, spot points) go in the default slot.
7
9
  -->
8
- <div ref="root" class="hero-plan" :class="{ 'hero-plan--detail': detail, 'hero-plan--pick': pick }" @click="onClick">
10
+ <div ref="root" class="hero-plan" :class="{ 'hero-plan--detail': detail, 'hero-plan--pick': pick, 'hero-plan--image': !!image }" :style="frameStyle" @click="onClick">
11
+ <img v-if="image" class="hero-plan__img" :src="image" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
9
12
  <span class="hero-plan__corner hero-plan__corner--tl" aria-hidden="true" />
10
13
  <span class="hero-plan__corner hero-plan__corner--tr" aria-hidden="true" />
11
14
  <span class="hero-plan__corner hero-plan__corner--bl" aria-hidden="true" />
12
15
  <span class="hero-plan__corner hero-plan__corner--br" aria-hidden="true" />
13
- <span class="hero-plan__wall" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
14
- <span class="hero-plan__wall" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
15
- <span class="hero-plan__wall" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
16
- <span class="hero-plan__dock" aria-hidden="true"><span v-if="detail" class="hero-plan__dock-label">Dock</span></span>
16
+ <template v-if="!image">
17
+ <span class="hero-plan__wall" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
18
+ <span class="hero-plan__wall" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
19
+ <span class="hero-plan__wall" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
20
+ <span class="hero-plan__dock" aria-hidden="true"><span v-if="detail" class="hero-plan__dock-label">Dock</span></span>
21
+ </template>
17
22
  <span class="hero-plan__scan" aria-hidden="true" />
18
23
  <slot />
19
24
  </div>
20
25
  </template>
21
26
 
22
27
  <script setup lang="ts">
23
- import { ref } from "vue";
28
+ import { computed, ref } from "vue";
24
29
 
25
- const props = defineProps<{ detail?: boolean; pick?: boolean }>();
30
+ const props = defineProps<{
31
+ detail?: boolean;
32
+ pick?: boolean;
33
+ /** The robot's own map image (http/https); null keeps the placeholder plan. */
34
+ image?: string | null;
35
+ /** That image's width / height, so the plan keeps its shape. */
36
+ aspect?: number | null;
37
+ }>();
38
+ /** The image's own shape; the detail plan's 440 px height cap narrows the width instead of squashing it. */
39
+ const frameStyle = computed(() => {
40
+ const a = props.image && props.aspect && Number.isFinite(props.aspect) && props.aspect > 0 ? props.aspect : null;
41
+ if (!a) return undefined;
42
+ return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%, ${Math.round(440 * a)}px)`, maxHeight: "none", marginInline: "auto" } : {}) };
43
+ });
26
44
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
27
45
  const root = ref<HTMLElement | null>(null);
28
46
 
@@ -8,8 +8,12 @@
8
8
  clean: each click adds a numbered point. "Reconnecting…" when the position
9
9
  is lost - never a frozen dot.
10
10
  -->
11
- <RobotFloorPlan detail :pick="pick" @pick="emit('pick', $event)">
12
- <RobotPatrolRoutes v-if="route" :routes="[route]" :animate="routeMoving" large />
11
+ <RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
12
+ <template v-for="p in points" :key="p.targetPointId">
13
+ <span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
14
+ <span class="hero-plan__ptname" :style="{ left: `${p.x}%`, top: `${p.y}%` }">{{ p.name }}</span>
15
+ </template>
16
+ <RobotPatrolRoutes v-if="routes?.length || route" :routes="routes?.length ? routes : [route!]" :animate="routeMoving" large />
13
17
  <template v-if="point">
14
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" />
15
19
  <span class="hero-cross hero-cross--h" :style="{ top: point.top, transition: `top ${tickMs}ms linear` }" aria-hidden="true" />
@@ -46,6 +50,13 @@ withDefaults(
46
50
  /** Position lost: "Reconnecting…" over the plan. */
47
51
  lost?: boolean;
48
52
  route?: TPatrolRouteView | null;
53
+ /** Several routes (the robot's routes on the map shown); wins over `route`. */
54
+ routes?: TPatrolRouteView[] | null;
55
+ /** The map shown: the robot's own image and its width / height. */
56
+ image?: string | null;
57
+ aspect?: number | null;
58
+ /** Saved points on the map shown (0-100 shares). */
59
+ points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
49
60
  /** The route's dash walks only while the robot patrols. */
50
61
  routeMoving?: boolean;
51
62
  /** Spot-clean points (0-100 shares) and whether clicks add them. */
@@ -55,7 +66,7 @@ withDefaults(
55
66
  /** The last reading kept through a short gap: drawn faded, never as live. */
56
67
  stale?: boolean;
57
68
  }>(),
58
- { stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
69
+ { 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 },
59
70
  );
60
71
  const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
61
72
  </script>
@@ -15,12 +15,15 @@
15
15
  </svg>
16
16
  </v-btn>
17
17
  <button v-if="hasRoutes" type="button" class="hero-mini__toggle" :aria-pressed="showRoutes" title="Show the robots' lanes and waypoints" @click="showRoutes = !showRoutes">Routes</button>
18
+ <select v-if="floorMaps.length" v-model="chosenMap" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
19
+ <option v-for="m in floorMaps" :key="m.mapId" :value="m.mapId">{{ m.label }}{{ m.current ? " · Robot is here" : "" }}</option>
20
+ </select>
18
21
  <select v-if="floors.length" :value="floor" class="hero-input hero-select hero-select--sm" aria-label="Floor" @change="chosen = Number(($event.target as HTMLSelectElement).value)">
19
22
  <option v-for="f in floors" :key="f" :value="f">{{ floorName(f) }}</option>
20
23
  </select>
21
24
  </div>
22
25
 
23
- <RobotFloorPlan ref="plan">
26
+ <RobotFloorPlan ref="plan" :image="shownMap?.imageUrl ?? null" :aspect="shownMap?.width && shownMap?.height ? shownMap.width / shownMap.height : null">
24
27
  <template v-if="showRoutes && overlay.lanes.length + overlay.waypoints.length">
25
28
  <svg class="hero-plan__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
26
29
  <line v-for="(l, i) in overlay.lanes" :key="`l${i}`" class="hero-mini__lane" :class="{ 'hero-mini__lane--oneway': l.oneWay }" :x1="l.a.x" :y1="l.a.y" :x2="l.b.x" :y2="l.b.y" vector-effect="non-scaling-stroke" />
@@ -46,7 +49,7 @@
46
49
  </RobotFloorPlan>
47
50
 
48
51
  <div class="hero-mini__foot">
49
- <span>Floor plan from robot (placeholder)</span>
52
+ <span>{{ shownMap ? `Robot map · ${shownMap.label}` : "Floor plan from robot (placeholder)" }}</span>
50
53
  <span v-if="type === 'security'"><span class="hero-mini__legend" aria-hidden="true" />Patrol routes</span>
51
54
  <span v-if="showRoutes && hasRoutes"><span class="hero-mini__legend hero-mini__legend--lane" aria-hidden="true" />Robot lanes</span>
52
55
  <span v-if="floor !== null" class="hero-mini__count">{{ robots.length }} on {{ floorName(floor) }}</span>
@@ -56,8 +59,9 @@
56
59
  </template>
57
60
 
58
61
  <script setup lang="ts">
59
- import { computed, ref } from "vue";
62
+ import { computed, ref, watch } from "vue";
60
63
  import { routeOverlay } from "../../utils/rmf-editor";
64
+ import { robotMapViews, type TRobotMapView } from "../../utils/robot-maps";
61
65
  import RobotFloorPlan from "./FloorPlan.vue";
62
66
  import RobotPatrolRoutes from "./PatrolRoutes.vue";
63
67
  import RobotReconnecting from "./Reconnecting.vue";
@@ -91,17 +95,46 @@ const props = withDefaults(
91
95
  tickMs?: number;
92
96
  /** The site's building (saved map or live HERO Fleet Core building) - its robot lanes and waypoints can be shown. */
93
97
  map?: Record<string, any> | null;
98
+ /** Each robot's own maps (`/robots/:id/maps` items) by robot id - PadBot's map images and a map selector. */
99
+ robotMaps?: Record<string, Array<Record<string, any>>>;
94
100
  }>(),
95
- { type: "cleaning", routes: () => ({}), connected: true, brands: () => [], tickMs: 4000, map: null },
101
+ { type: "cleaning", routes: () => ({}), connected: true, brands: () => [], tickMs: 4000, map: null, robotMaps: () => ({}) },
96
102
  );
97
103
  const emit = defineEmits<{ open: [id: string] }>();
98
104
 
99
105
  const plan = ref<InstanceType<typeof RobotFloorPlan> | null>(null);
100
106
  const ids = computed(() => props.rows.map((r) => String(r._id)));
101
- const floors = computed(() => mapFloors(ids.value, props.states));
107
+ /** Every robot map once (a robot's map is "current" when that robot reports it). */
108
+ const allMaps = computed(() => {
109
+ const seen = new Map<string, TRobotMapView>();
110
+ for (const id of ids.value) {
111
+ for (const m of robotMapViews(props.robotMaps[id], props.states[id]?.mapId ?? null, props.routes[id] ?? [])) {
112
+ const had = seen.get(m.mapId);
113
+ seen.set(m.mapId, had ? { ...had, current: had.current || m.current } : m);
114
+ }
115
+ }
116
+ return [...seen.values()];
117
+ });
118
+ // Floors the robots report, plus the floors their own maps name.
119
+ const floors = computed(() =>
120
+ [...new Set([...mapFloors(ids.value, props.states), ...allMaps.value.flatMap((m) => (m.floorNo === null ? [] : [m.floorNo]))])].sort((a, b) => a - b),
121
+ );
102
122
  const chosen = ref<number | null>(null);
103
123
  const floor = computed(() => pickFloor(floors.value, chosen.value, ids.value, props.states));
104
- const robots = computed(() => mapRobots(props.rows, props.states, floor.value));
124
+ /** The robot maps for the floor shown (a map with no floor is offered on every floor). */
125
+ const floorMaps = computed(() => allMaps.value.filter((m) => m.floorNo === null || floor.value === null || m.floorNo === floor.value));
126
+ const chosenMap = ref<string | null>(null);
127
+ const shownMap = computed(
128
+ () => floorMaps.value.find((m) => m.mapId === chosenMap.value) ?? floorMaps.value.find((m) => m.current) ?? floorMaps.value[0] ?? null,
129
+ );
130
+ watch(shownMap, (m) => (chosenMap.value = m?.mapId ?? null), { immediate: true });
131
+ /** On a robot map, only the robots on that map (a brand without map ids is drawn as before). */
132
+ const robots = computed(() =>
133
+ mapRobots(props.rows, props.states, floor.value).filter((r) => {
134
+ const on = props.states[r.id]?.mapId;
135
+ return !shownMap.value || !on || on === shownMap.value.mapId;
136
+ }),
137
+ );
105
138
  /** The robots' lanes and waypoints on the floor shown (toggle; on by default once the building has any). */
106
139
  const overlay = computed(() => (floor.value === null ? { lanes: [], waypoints: [], fiducials: [] } : routeOverlay(props.map, floor.value)));
107
140
  const hasRoutes = computed(() => !!props.map && (props.map.rmf?.levels ?? props.map.levels ?? []).some((l: any) => (l.lanes ?? []).length));
@@ -115,7 +148,8 @@ const floorRoutes = computed(() => {
115
148
  const st = props.states[id];
116
149
  for (const r of props.routes[id] ?? []) {
117
150
  const f = r.floorNo ?? (st?.floorKnown ? st.floorNo : null);
118
- if (f === floor.value && !seen.has(r.id)) seen.set(r.id, r);
151
+ const onMap = !shownMap.value || !r.mapId || r.mapId === shownMap.value.mapId;
152
+ if ((r.mapId && shownMap.value ? onMap : f === floor.value) && !seen.has(r.id)) seen.set(r.id, r);
119
153
  }
120
154
  }
121
155
  return [...seen.values()];
@@ -2,11 +2,20 @@
2
2
  <!-- Go to point / Set position: the robot's own saved points, per map. -->
3
3
  <div class="hero-dialog" role="dialog" aria-labelledby="hero-pick-title">
4
4
  <div id="hero-pick-title" class="hero-dialog__title">{{ kind === "navigate" ? "Go to point" : "Set position" }}</div>
5
- <div v-if="kind === 'relocalize'" class="hero-dialog__body">Only points on the floor the robot reports are listed.</div>
6
- <div v-if="!groups.length" class="hero-muted">No saved points{{ kind === "relocalize" ? " on the robot's current floor" : "" }}.</div>
5
+ <div v-if="kind === 'relocalize'" class="hero-dialog__body">Only points on the map the robot is on are listed.</div>
6
+ <div v-if="!groups.length" class="hero-muted">No saved points{{ kind === "relocalize" ? " on the robot's current map" : "" }}.</div>
7
7
  <div v-for="g in groups" :key="g.mapId" class="hero-pick__group">
8
- <div class="hero-aside__title">{{ g.title }}</div>
9
- <button v-for="p in g.points" :key="p.targetPointId" type="button" class="hero-pick__point" :aria-pressed="chosen === p" @click="chosen = p">
8
+ <div class="hero-aside__title">{{ g.title }}<template v-if="g.here"> · Robot is here</template></div>
9
+ <div v-if="g.note" class="hero-mapsel__hint">{{ g.note }}</div>
10
+ <button
11
+ v-for="p in g.points"
12
+ :key="p.targetPointId"
13
+ type="button"
14
+ class="hero-pick__point"
15
+ :aria-pressed="chosen === p"
16
+ :disabled="!g.ok"
17
+ @click="chosen = p"
18
+ >
10
19
  {{ p.name }} <span class="hero-dim">{{ p.type }}</span>
11
20
  </button>
12
21
  </div>
@@ -25,9 +34,37 @@
25
34
  <script setup lang="ts">
26
35
  import { computed, ref } from "vue";
27
36
  import type { TSavedPoint, TSavedPointGroup } from "../../utils/robot-display";
37
+ import { robotMapReach, type TRobotMapView } from "../../utils/robot-maps";
28
38
 
29
- const props = defineProps<{ kind: "navigate" | "relocalize"; groups: TSavedPointGroup[]; floorNo?: number | null }>();
39
+ const props = withDefaults(
40
+ defineProps<{
41
+ kind: "navigate" | "relocalize";
42
+ groups: TSavedPointGroup[];
43
+ floorNo?: number | null;
44
+ /** The map the robot reports it is on (PadBot); null when it has not said. */
45
+ currentMapId?: string | null;
46
+ /** The robot's maps, for the map-group rule of Go to point. */
47
+ maps?: TRobotMapView[];
48
+ }>(),
49
+ { floorNo: null, currentMapId: null, maps: () => [] },
50
+ );
30
51
  const emit = defineEmits<{ close: []; pick: [point: TSavedPoint] }>();
31
52
  const chosen = ref<TSavedPoint | null>(null);
32
- const groups = computed(() => (props.kind === "relocalize" ? props.groups.filter((g) => g.floorNo !== null && g.floorNo === props.floorNo) : props.groups));
53
+ /** The robot's own map first; another map's points only when the robot can get there on its own. */
54
+ const groups = computed(() => {
55
+ const here = (g: TSavedPointGroup) => !!props.currentMapId && g.mapId === props.currentMapId;
56
+ const list =
57
+ props.kind === "relocalize"
58
+ ? props.groups.filter((g) => (props.currentMapId ? here(g) : g.floorNo !== null && g.floorNo === props.floorNo))
59
+ : props.groups;
60
+ return [...list]
61
+ .sort((a, b) => Number(here(b)) - Number(here(a)))
62
+ .map((g) => {
63
+ // With one map, or no map reported by a brand without map ids, nothing to guard.
64
+ const reach = props.kind === "navigate" && props.groups.length > 1 ? robotMapReach(props.maps, props.currentMapId, g.mapId) : { ok: true, note: "" };
65
+ // The selector's name for the map ("Map 1 (B1)") when PadBot gives only ids.
66
+ const title = props.maps.find((m) => m.mapId === g.mapId)?.label ?? g.title;
67
+ return { ...g, title, here: here(g), ok: reach.ok, note: reach.note };
68
+ });
69
+ });
33
70
  </script>
@@ -70,6 +70,7 @@
70
70
  :states="states"
71
71
  :type="type"
72
72
  :routes="routes"
73
+ :robot-maps="robotMaps"
73
74
  :connected="connected"
74
75
  :brands="brands"
75
76
  :map="routeMap"
@@ -127,7 +128,7 @@ const emit = defineEmits<{
127
128
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
128
129
  }>();
129
130
 
130
- const { getSiteFleet, getSiteSummary, getRobotBrands, getPatrolRoutes, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
131
+ const { getSiteFleet, getSiteSummary, getRobotBrands, getPatrolRoutes, getRobotMaps, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
131
132
 
132
133
  /**
133
134
  * The site's building for the mini-map's robot lanes: the live HERO Fleet
@@ -235,11 +236,19 @@ function seenText(row: any) {
235
236
 
236
237
  /* ---- patrol routes for the security robots on screen ---- */
237
238
  const routes = ref<Record<string, TPatrolRouteView[]>>({});
239
+ /** Each security robot's own maps (PadBot map images), read once per robot. */
240
+ const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
238
241
  async function loadRoutes() {
239
242
  if (props.type !== "security") {
240
243
  routes.value = {};
241
244
  return;
242
245
  }
246
+ const missing = ids.value.filter((id) => !(id in robotMaps.value));
247
+ if (missing.length) {
248
+ const got: Record<string, Array<Record<string, any>>> = {};
249
+ await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
250
+ robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
251
+ }
243
252
  const next: Record<string, TPatrolRouteView[]> = {};
244
253
  await Promise.allSettled(
245
254
  ids.value.map(async (id) => {
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.54.0",
5
+ "version": "4.55.0",
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.",
@@ -598,11 +598,15 @@ export type TLiveRobotState = {
598
598
  ok: boolean;
599
599
  /** false = its brand has no interface: nothing to show, ever. */
600
600
  monitorable?: boolean;
601
+ /** When `ok` is false: why, in words (e.g. "Ounitech rejected the robot list (sign-in) (code X: ...)"). */
602
+ reason?: string;
601
603
  online?: boolean;
602
604
  mode?: string;
603
605
  batteryPercent?: number | null;
604
606
  floorNo?: number | null;
605
607
  floorKnown?: boolean;
608
+ /** The brand's id of the map the robot is on now (PadBot); absent / null when unknown. */
609
+ mapId?: string | null;
606
610
  position?: { x: number; y: number; theta: number } | null;
607
611
  /** Where it is on its own floor plan, 0-100 from the top-left; absent when the brand cannot say. */
608
612
  planPosition?: { x: number; y: number } | null;
@@ -654,7 +658,13 @@ export function mergeLiveStates(
654
658
  next[item.id] =
655
659
  item.monitorable !== false && p?.ok && recent(p.readAt)
656
660
  ? { ...p, stale: true }
657
- : { id: item.id, ok: false, monitorable: item.monitorable !== false };
661
+ : {
662
+ id: item.id,
663
+ ok: false,
664
+ monitorable: item.monitorable !== false,
665
+ // The API's plain reason, so the detail screen says WHY instead of only "Reconnecting".
666
+ ...(typeof item.reason === "string" && item.reason ? { reason: item.reason.slice(0, 300) } : {}),
667
+ };
658
668
  continue;
659
669
  }
660
670
  const s = { ...item, ok: true, readAt: now, stale: false } as TLiveRobotState;
@@ -882,6 +892,8 @@ export type TPatrolRouteView = {
882
892
  currentCheckpoint: number | null;
883
893
  /** Every stop's name in order, placed on the plan or not (PadBot); a route with none placed is listed, never drawn. */
884
894
  stops?: string[];
895
+ /** The brand's id of the map this route is on (PadBot). */
896
+ mapId?: string;
885
897
  };
886
898
 
887
899
  const placed = (c: { x?: unknown; y?: unknown }) => finite(c?.x) && finite(c?.y);
@@ -1102,6 +1114,8 @@ export function savedPointGroups(maps: Array<Record<string, any>> | null | undef
1102
1114
  */
1103
1115
  export function pointCommandBody(kind: "navigate" | "relocalize", point: TSavedPoint, live: TLiveRobotState | null | undefined): Record<string, unknown> | null {
1104
1116
  if (kind === "navigate") return { kind: "go-to-point", mapId: point.mapId, targetPointId: point.targetPointId };
1117
+ // Set position only on the map the robot is on (when it says which).
1118
+ if (typeof live?.mapId === "string" && live.mapId && point.mapId !== live.mapId) return null;
1105
1119
  if (point.floorNo === null || !finite(live?.floorNo) || live!.floorNo !== point.floorNo) return null;
1106
1120
  return { kind: "relocalize", floorNo: point.floorNo, x: point.x, y: point.y, theta: (point.yaw * Math.PI) / 180 };
1107
1121
  }
@@ -1244,7 +1258,7 @@ export function robotFacts(input: {
1244
1258
  */
1245
1259
  export function mergePose(
1246
1260
  live: TLiveRobotState | null | undefined,
1247
- event: { type?: unknown; pose?: { x?: unknown; y?: unknown; theta?: unknown; mode?: unknown } | null; planPosition?: unknown } | null | undefined,
1261
+ event: { type?: unknown; pose?: { x?: unknown; y?: unknown; theta?: unknown; mode?: unknown } | null; planPosition?: unknown; mapId?: unknown } | null | undefined,
1248
1262
  ): TLiveRobotState | null {
1249
1263
  if (!live || !live.ok || event?.type !== "pose" || !event.pose) return live ?? null;
1250
1264
  const { x, y, theta, mode } = event.pose;
@@ -1256,6 +1270,7 @@ export function mergePose(
1256
1270
  positionAt: Date.now(),
1257
1271
  position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
1258
1272
  ...(plan && finite(plan.x) && finite(plan.y) ? { planPosition: { x: plan.x, y: plan.y } } : {}),
1273
+ ...(typeof event.mapId === "string" && event.mapId ? { mapId: event.mapId } : {}),
1259
1274
  ...(typeof mode === "string" && mode ? { mode } : {}),
1260
1275
  } as TLiveRobotState;
1261
1276
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * A robot's OWN maps (PadBot §5.1: one map per floor or area) for the map
3
+ * selector on robot detail, Go to point / Set position and the building map.
4
+ * Pure helpers - no Vue, no Node globals.
5
+ */
6
+
7
+ export type TRobotMapPoint = { targetPointId: string; name: string; type: string; x: number; y: number };
8
+
9
+ export type TRobotMapView = {
10
+ mapId: string;
11
+ /** "Map 1 (B1)", "Lobby · L3". */
12
+ label: string;
13
+ floorNo: number | null;
14
+ imageUrl: string | null;
15
+ /** Image size in px - the plan is drawn at this aspect ratio. */
16
+ width: number | null;
17
+ height: number | null;
18
+ mapGroup: string;
19
+ /** The robot reports it is on this map now. */
20
+ current: boolean;
21
+ /** The patrol routes saved on this map, by name. */
22
+ routeNames: string[];
23
+ /** Saved points placed on this map's image (0-100 from its top-left). */
24
+ points: TRobotMapPoint[];
25
+ };
26
+
27
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
28
+ const levelName = (n: number) => (n > 0 ? `L${n}` : n < 0 ? `B${-n}` : "G");
29
+ const TYPE_WORDS: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
30
+
31
+ /**
32
+ * World metres -> share of the map image from its top-left (ROS map: origin at
33
+ * the bottom-left pixel, y up) - the same transform the API uses for the
34
+ * robot's own position, so points and robot line up on each map's image.
35
+ * Null without a usable frame or outside the image.
36
+ */
37
+ export function robotMapShare(map: Record<string, any> | null | undefined, wx: unknown, wy: unknown): { x: number; y: number } | null {
38
+ const w = map?.width, h = map?.height, r = map?.resolution;
39
+ if (!isNum(w) || !isNum(h) || !isNum(r) || w <= 0 || h <= 0 || r <= 0 || !isNum(wx) || !isNum(wy)) return null;
40
+ const sx = ((wx - (isNum(map?.originX) ? map.originX : 0)) / r / w) * 100;
41
+ const sy = 100 - ((wy - (isNum(map?.originY) ? map.originY : 0)) / r / h) * 100;
42
+ if (sx < 0 || sx > 100 || sy < 0 || sy > 100) return null;
43
+ return { x: Math.round(sx * 100) / 100, y: Math.round(sy * 100) / 100 };
44
+ }
45
+
46
+ /**
47
+ * `/robots/:id/maps` items (PadBot: `mapId` + `imageUrl`) as selector entries,
48
+ * in PadBot's order. Named by PadBot's display name; without one, "Map N"
49
+ * with its floor, else its first route's name, as the hint ("Map 1 (B1)").
50
+ * [] for brands whose maps are not vendor images (Ounitech keeps its own view).
51
+ */
52
+ export function robotMapViews(
53
+ items: Array<Record<string, any>> | null | undefined,
54
+ currentMapId: string | null | undefined,
55
+ routes: Array<{ name?: string; mapId?: string }> | null | undefined = [],
56
+ ): TRobotMapView[] {
57
+ const list = (Array.isArray(items) ? items : []).filter((m) => typeof m?.mapId === "string" && m.mapId && typeof m?.imageUrl === "string");
58
+ return list.map((m, i) => {
59
+ const floorNo = Number.isInteger(m.floor) ? (m.floor as number) : null;
60
+ const routeNames = (routes ?? []).filter((r) => r?.mapId === m.mapId && r.name).map((r) => String(r.name));
61
+ const shown = typeof m.mapDisplayName === "string" ? m.mapDisplayName.trim() : "";
62
+ const hint = floorNo !== null ? levelName(floorNo) : routeNames[0]?.replace(/ \(default\)$/, "") ?? "";
63
+ const label = shown ? (floorNo !== null ? `${shown} · ${levelName(floorNo)}` : shown) : hint ? `Map ${i + 1} (${hint})` : `Map ${i + 1}`;
64
+ const points = (Array.isArray(m.targetPoints) ? m.targetPoints : []).flatMap((t: any) => {
65
+ const at = robotMapShare(m, t?.worldX, t?.worldY);
66
+ return at && typeof t?.targetPointId === "string" && t.targetPointId
67
+ ? [{ targetPointId: t.targetPointId, name: String(t.name || "Unnamed point"), type: TYPE_WORDS[t.targetPointType] ?? "Point", ...at }]
68
+ : [];
69
+ });
70
+ return {
71
+ mapId: m.mapId,
72
+ label,
73
+ floorNo,
74
+ imageUrl: /^https?:\/\//i.test(m.imageUrl) ? m.imageUrl : null,
75
+ width: isNum(m.width) && m.width > 0 ? m.width : null,
76
+ height: isNum(m.height) && m.height > 0 ? m.height : null,
77
+ mapGroup: typeof m.mapGroup === "string" ? m.mapGroup.trim() : "",
78
+ current: !!currentMapId && m.mapId === currentMapId,
79
+ routeNames,
80
+ points,
81
+ };
82
+ });
83
+ }
84
+
85
+ /** The map the selector opens on: where the robot is, else PadBot's default map, else the first. */
86
+ export function robotMapDefault(items: Array<Record<string, any>> | null | undefined, views: TRobotMapView[]): string | null {
87
+ return views.find((v) => v.current)?.mapId ?? (Array.isArray(items) ? items : []).find((m) => m?.isDefault === true && views.some((v) => v.mapId === m.mapId))?.mapId ?? views[0]?.mapId ?? null;
88
+ }
89
+
90
+ /**
91
+ * Whether Go to point may send the robot to a point on `targetMapId`. Same
92
+ * map: yes. Another map: only inside the same PadBot map group (the robot
93
+ * travels between those maps by lift / map switch); an unknown current map is
94
+ * never guessed. Mirrors the API's own check.
95
+ */
96
+ export function robotMapReach(views: TRobotMapView[], currentMapId: string | null | undefined, targetMapId: string): { ok: boolean; note: string } {
97
+ if (currentMapId && currentMapId === targetMapId) return { ok: true, note: "" };
98
+ const from = views.find((v) => v.mapId === currentMapId);
99
+ const to = views.find((v) => v.mapId === targetMapId);
100
+ if (from?.mapGroup && from.mapGroup === to?.mapGroup) return { ok: true, note: "The robot will travel to this map on its own (lift or map switch)." };
101
+ return { ok: false, note: "The robot must be on this map first." };
102
+ }
103
+
104
+ /** The robot maps with a known floor, per floor, for the building map. */
105
+ export function robotMapsByFloor(views: TRobotMapView[]): Map<number, TRobotMapView[]> {
106
+ const out = new Map<number, TRobotMapView[]>();
107
+ for (const v of views) if (v.floorNo !== null) out.set(v.floorNo, [...(out.get(v.floorNo) ?? []), v]);
108
+ return out;
109
+ }