@7365admin1/layer-common 4.54.1 → 4.56.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.56.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2ef1a8d: Robot detail: "What this robot can do" card built from the robot's brand + model manifest; information grouped Status / Location / Patrol|Cleaning / Camera; unsupported facts hidden (never greyed); commands confirm what will happen and report the result; "Return to charger" everywhere; maker-app schedule text from the manifest. Site fleet Brand column names the model; the capability badge's tooltip lists what that robot does.
8
+
9
+ ## 4.55.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 4.54.1
4
16
 
5
17
  ### Patch Changes
@@ -1575,11 +1587,11 @@
1575
1587
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1576
1588
  own rule from two endpoints the console already calls, unprojected:
1577
1589
 
1578
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1579
- GET /api/roles/id/:role that membership's role document
1590
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1591
+ GET /api/roles/id/:role that membership's role document
1580
1592
 
1581
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1582
- staff = member.type === "admin" && role.type === "admin"
1593
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1594
+ staff = member.type === "admin" && role.type === "admin"
1583
1595
 
1584
1596
  `role.default` is the marker because it is the only property of a platform
1585
1597
  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); }
@@ -774,6 +781,14 @@
774
781
  .hero-fact__value { font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 3px; }
775
782
  .hero-fact__value--dim { opacity: 0.5; }
776
783
  .hero-notreported { font-size: 12px; opacity: 0.7; }
784
+ .hero-facts--tight { margin-bottom: 12px; }
785
+
786
+ /* "What this robot can do": one short row per kind of ability, only what THIS brand + model offers. */
787
+ .hero-can { border: 1px solid var(--hero-divider); border-left: 3px solid var(--hero-accent); padding: 10px 14px; display: grid; gap: 6px; }
788
+ .hero-can__title { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 600; opacity: 0.75; }
789
+ .hero-can__row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; line-height: 1.45; }
790
+ .hero-can__label { font-weight: 700; }
791
+ @media (max-width: 560px) { .hero-can__row { grid-template-columns: 1fr; gap: 0; } }
777
792
 
778
793
  .hero-section__title {
779
794
  font-size: 10px;
@@ -1257,3 +1272,4 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1257
1272
  .hero-drive__pad button:active { background: var(--hero-accent); color: var(--hero-bg); }
1258
1273
  .hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1259
1274
  .hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
1275
+ .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,
@@ -38,20 +38,17 @@
38
38
 
39
39
  <RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
40
40
 
41
- <div class="hero-facts" role="list" aria-label="Robot facts">
42
- <div v-for="f in facts" :key="f.label" class="hero-fact" role="listitem">
43
- <div class="hero-fact__label">{{ f.label }}</div>
44
- <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
41
+ <!-- What THIS robot (brand + model) can do, in our words; nothing it can't. -->
42
+ <section v-if="abilities.length" class="hero-can" aria-label="What this robot can do">
43
+ <div class="hero-can__title">What this robot can do</div>
44
+ <div v-for="r in abilities" :key="r.label" class="hero-can__row">
45
+ <span class="hero-can__label">{{ r.label }}</span>
46
+ <span>{{ r.value }}</span>
45
47
  </div>
46
- </div>
47
- <div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
48
- <div v-if="(caps?.liveVideo === true && online && !fault) || canDrive" class="hero-detail__video">
49
- <v-btn v-if="caps?.liveVideo === true && online && !fault" variant="outlined" @click="liveOpen = true">Live view</v-btn>
50
- <v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
51
- </div>
48
+ </section>
52
49
 
53
50
  <div v-if="commands.length">
54
- <div class="hero-section__title">Command bar</div>
51
+ <div class="hero-section__title">Commands</div>
55
52
  <RobotCommandBar
56
53
  :capabilities="caps"
57
54
  :can-command="canCommand"
@@ -64,8 +61,70 @@
64
61
  </div>
65
62
  <RobotViewOnlyNotice v-if="!canCommand && !noInterface" />
66
63
 
67
- <div class="hero-detail__grid">
64
+ <div>
65
+ <div class="hero-section__title">Status</div>
66
+ <div class="hero-facts" role="list" aria-label="Robot status">
67
+ <div v-for="f in statusFacts" :key="f.label" class="hero-fact" role="listitem">
68
+ <div class="hero-fact__label">{{ f.label }}</div>
69
+ <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <div v-if="!noInterface" class="hero-detail__grid">
68
75
  <div>
76
+ <div class="hero-section__title">Location</div>
77
+ <div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
78
+ <div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
79
+ <div class="hero-fact__label">{{ f.label }}</div>
80
+ <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
81
+ </div>
82
+ </div>
83
+ <template v-if="showPlan">
84
+ <div class="hero-aside__head">
85
+ <span class="hero-aside__title">{{ planTitle }}</span>
86
+ <span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
87
+ </div>
88
+ <div v-if="mapViews.length > 1 || (mapViews.length === 1 && !mapViews[0]!.current)" class="hero-mapsel">
89
+ <select v-model="selectedMapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
90
+ <option v-for="m in mapViews" :key="m.mapId" :value="m.mapId">{{ m.label }}{{ m.current ? " · Robot is here" : "" }}</option>
91
+ </select>
92
+ <span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
93
+ <span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
94
+ <span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
95
+ </div>
96
+ <RobotLivePlan
97
+ :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"
104
+ :lost="posLost"
105
+ :route="route"
106
+ :routes="selectedMap ? mapRoutes : null"
107
+ :image="selectedMap?.imageUrl ?? null"
108
+ :aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
109
+ :points="selectedMap?.points ?? []"
110
+ :route-moving="statusMode === 'WORKING' && robotOnShownMap"
111
+ :spot="spot"
112
+ :pick="spotOn"
113
+ :tick-ms="tickMs"
114
+ @pick="(p) => spot.push(p)"
115
+ />
116
+ <div v-if="spotOn" class="hero-spotbar">
117
+ <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
118
+ Send spot clean · {{ spot.length }} points
119
+ </v-btn>
120
+ <v-btn variant="outlined" @click="spot = []">Clear</v-btn>
121
+ </div>
122
+ </template>
123
+ <div v-else-if="caps" class="hero-sched__empty">{{ brand }} doesn't share where this robot is with us yet.</div>
124
+ </div>
125
+
126
+ <div>
127
+ <div class="hero-section__title">{{ robotType === "security" ? "Patrol" : "Cleaning" }}</div>
69
128
  <div class="hero-aside__head">
70
129
  <span class="hero-aside__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</span>
71
130
  <span class="hero-aside__hint">{{
@@ -73,14 +132,9 @@
73
132
  }}</span>
74
133
  </div>
75
134
  <template v-if="!schedule.length && robotType === 'security' && patrol?.scheduleShared === false">
76
- <div class="hero-sched__empty">Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet.</div>
135
+ <div class="hero-sched__empty">{{ scheduleElsewhere }}</div>
136
+ <!-- The runs themselves are in Patrol history below (said once). -->
77
137
  <div v-if="runHint" class="hero-sched__hint">{{ runHint }} - a guess from recent runs, not the schedule.</div>
78
- <template v-if="recentRuns.length">
79
- <div class="hero-aside__hint">Recent patrol runs</div>
80
- <div v-for="r in recentRuns" :key="r.key" class="hero-sched__row">
81
- <b>{{ r.when }}</b><span>{{ r.route }} · {{ r.result }}</span><span>{{ r.detail }}</span>
82
- </div>
83
- </template>
84
138
  </template>
85
139
  <div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
86
140
  <div v-for="p in schedule" :key="p.key" class="hero-sched__row">
@@ -93,38 +147,29 @@
93
147
  </div>
94
148
  </template>
95
149
  </div>
150
+ </div>
96
151
 
97
- <div>
98
- <div class="hero-aside__head">
99
- <span class="hero-aside__title">{{ planTitle }}</span>
100
- <span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
101
- </div>
102
- <RobotLivePlan
103
- :name="record?.name"
104
- :status-label="statusLabel"
105
- :point="posLive ? point : null"
106
- :stale="shown?.stale === true"
107
- :heading="headingDeg(shown)"
108
- :trail="trail"
109
- :readout="readout"
110
- :lost="posLost"
111
- :route="route"
112
- :route-moving="statusMode === 'WORKING'"
113
- :spot="spot"
114
- :pick="spotOn"
115
- :tick-ms="tickMs"
116
- @pick="(p) => spot.push(p)"
117
- />
118
- <div v-if="spotOn" class="hero-spotbar">
119
- <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
120
- Send spot clean · {{ spot.length }} points
121
- </v-btn>
122
- <v-btn variant="outlined" @click="spot = []">Clear</v-btn>
123
- </div>
152
+ <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
153
+
154
+ <div v-if="caps?.liveVideo === true || canDrive">
155
+ <div class="hero-section__title">Camera</div>
156
+ <div v-if="online || canDrive" class="hero-detail__video">
157
+ <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" @click="liveOpen = true">Live view</v-btn>
158
+ <v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
124
159
  </div>
160
+ <div v-else class="hero-notreported">Live view opens when the robot is online.</div>
125
161
  </div>
126
162
 
127
- <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
163
+ <v-dialog v-model="confirmOpen" width="440">
164
+ <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
165
+ <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
166
+ <div class="hero-dialog__body">{{ confirming.text.body }}</div>
167
+ <div class="hero-dialog__actions">
168
+ <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
169
+ <v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
170
+ </div>
171
+ </div>
172
+ </v-dialog>
128
173
 
129
174
  <v-dialog v-model="editOpen" width="520" persistent>
130
175
  <RobotForm
@@ -160,7 +205,16 @@
160
205
  <RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
161
206
  </v-dialog>
162
207
  <v-dialog v-model="pickOpen" width="520">
163
- <RobotPointPicker v-if="pickOpen && pickKind" :kind="pickKind" :groups="pointGroups" :floor-no="live?.floorNo ?? null" @close="pickOpen = false" @pick="onPick" />
208
+ <RobotPointPicker
209
+ v-if="pickOpen && pickKind"
210
+ :kind="pickKind"
211
+ :groups="pointGroups"
212
+ :floor-no="live?.floorNo ?? null"
213
+ :current-map-id="live?.mapId ?? null"
214
+ :maps="mapViews"
215
+ @close="pickOpen = false"
216
+ @pick="onPick"
217
+ />
164
218
  </v-dialog>
165
219
 
166
220
  <RobotToast v-model="toast" />
@@ -180,20 +234,20 @@ import RobotLiveView from "./LiveView.vue";
180
234
  import RobotManualDrive from "./ManualDrive.vue";
181
235
  import RobotPointPicker from "./PointPicker.vue";
182
236
  import { faultPhotos } from "../../utils/robot-control";
237
+ import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
183
238
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
184
239
  import {
185
240
  brandName,
186
241
  capabilityLabel,
187
242
  cleaningScheduleRows,
188
243
  commandBody,
244
+ commandConfirm,
189
245
  commandLabel,
190
246
  faultBadge,
191
247
  headingDeg,
192
248
  isMoving,
193
249
  mergePose,
194
- notReportedLine,
195
250
  optimisticMode,
196
- patrolRecordRow,
197
251
  patrolRunHint,
198
252
  patrolScheduleRows,
199
253
  pointCommandBody,
@@ -201,9 +255,11 @@ import {
201
255
  unplacedStopsText,
202
256
  planPoint,
203
257
  positionText,
258
+ robotAbilities,
204
259
  robotErrorMessage,
205
260
  robotFacts,
206
261
  robotStatus,
262
+ scheduleElsewhereText,
207
263
  showSpotClean,
208
264
  sinceText,
209
265
  speedText,
@@ -305,8 +361,16 @@ const statusLabel = computed(() => robotStatus(statusMode.value, robotType.value
305
361
  const fault = computed(() => (live.value?.ok ? live.value.fault ?? null : record.value?.fault ?? null));
306
362
  const faultSince = computed(() => sinceText(fault.value?.since, now.value));
307
363
 
308
- const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value }));
309
- const notReported = computed(() => (caps.value ? notReportedLine(brand.value, caps.value, robotType.value) : null));
364
+ // Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
365
+ const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
366
+ const LOCATION = new Set(["Floor", "Live position"]);
367
+ const statusFacts = computed(() => facts.value.filter((f) => !LOCATION.has(f.label)));
368
+ // The plan's readout already shows the live position; the cell only when there is no plan.
369
+ const locationFacts = computed(() => facts.value.filter((f) => LOCATION.has(f.label) && !(f.label === "Live position" && showPlan.value)));
370
+ const abilities = computed(() =>
371
+ robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
372
+ );
373
+ const scheduleElsewhere = computed(() => scheduleElsewhereText(robotType.value, brand.value, live.value?.ok ? live.value.profile : null));
310
374
 
311
375
  const online = computed(() => live.value?.ok === true && live.value.online === true);
312
376
  const commands = computed(() =>
@@ -330,14 +394,26 @@ const readout = computed(() => `${positionText(live.value, caps.value, now.value
330
394
  const spot = ref<Array<{ x: number; y: number }>>([]);
331
395
  const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
332
396
 
333
- const route = computed<TPatrolRouteView | null>(() => patrol.value?.routes?.[0] ?? null);
334
- const floorText = computed(() => facts.value[2]?.value ?? "");
335
- const planTitle = computed(() => (spotOn.value ? "Spot clean" : route.value ? `Live position · ${route.value.name}` : "Live position"));
397
+ // The route drawn on the plan: the shown map's first route, else the first with placed checkpoints (the rest are listed by name).
398
+ const route = computed<TPatrolRouteView | null>(() =>
399
+ selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
400
+ );
401
+ const floorText = computed(() => locationFacts.value.find((f) => f.label === "Floor")?.value ?? "");
402
+ // The plan only for a robot that reports where it is, has maps to show, takes spot clicks, or walks a route we can draw.
403
+ const showPlan = computed(
404
+ () => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
405
+ );
406
+ const planTitle = computed(() => {
407
+ if (spotOn.value) return "Spot clean";
408
+ if (selectedMap.value && !selectedMap.value.current) return `Map · ${selectedMap.value.label}`;
409
+ return route.value ? `Live position · ${route.value.name}` : "Live position";
410
+ });
336
411
  const planHint = computed(() => {
337
412
  if (spotOn.value) return "Click points on the plan, then send";
338
413
  if (noInterface.value) return "No position feed for this model";
414
+ if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
339
415
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
340
- return floorText.value;
416
+ return ""; // the Floor cell above already says it
341
417
  });
342
418
 
343
419
  /* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
@@ -349,7 +425,6 @@ const unplacedRoutes = computed(() =>
349
425
  );
350
426
  // The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
351
427
  const runs = ref<any[]>([]);
352
- const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
353
428
  const runHint = computed(() => patrolRunHint(runs.value, now.value));
354
429
 
355
430
  async function loadSchedule() {
@@ -366,6 +441,37 @@ async function loadSchedule() {
366
441
  }
367
442
  }
368
443
 
444
+ /* ---- the robot's own maps (PadBot: one per floor / area): a selector, opening where the robot is ---- */
445
+ const mapItems = ref<Array<Record<string, any>>>([]);
446
+ const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
447
+ const selectedMapId = ref<string | null>(null);
448
+ const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
449
+ watch(mapViews, (views) => {
450
+ if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
451
+ });
452
+ // The robot changes map (lift / map switch): follow it while the robot's map was shown.
453
+ watch(
454
+ () => live.value?.mapId,
455
+ (to, from) => {
456
+ if (to && from && selectedMapId.value === from && mapViews.value.some((m) => m.mapId === to)) selectedMapId.value = to;
457
+ },
458
+ );
459
+ /** Without robot maps the placeholder plan shows the robot as before; with them, only on its own map. */
460
+ const robotOnShownMap = computed(() => !selectedMap.value || selectedMap.value.current);
461
+ const currentMapLabel = computed(() => mapViews.value.find((m) => m.current)?.label ?? "");
462
+ const hereText = computed(() => (currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : "The robot has not said which map it is on"));
463
+ /** The routes saved on the map shown (a route without a map id goes with the robot's current map). */
464
+ const mapRoutes = computed(() =>
465
+ (patrol.value?.routes ?? []).filter((r) => (r.mapId ? r.mapId === selectedMap.value?.mapId : selectedMap.value?.current)),
466
+ );
467
+ async function loadMaps() {
468
+ if (!record.value || noInterface.value) return;
469
+ try {
470
+ mapItems.value = (await getRobotMaps(props.robotId))?.items ?? [];
471
+ } catch {
472
+ mapItems.value = [];
473
+ }
474
+ }
369
475
  /* ---- the position stream: only for a brand that reports one ---- */
370
476
  function startStream() {
371
477
  stream?.stop();
@@ -396,11 +502,13 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
396
502
  const before = statusMode.value;
397
503
  busy.value = kind;
398
504
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
399
- toast.value = `${commandLabel(kind)} → ${name}`;
505
+ const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
506
+ toast.value = `${label} → ${name} · sending…`;
400
507
  try {
401
508
  await sendRobotCommand(props.robotId, body as any);
402
509
  // A poll already in flight may still carry the old status; wait past it.
403
510
  optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
511
+ toast.value = `${label} → ${name} · accepted. Its status updates here.`;
404
512
  return true;
405
513
  } catch (e) {
406
514
  optimistic.value = null;
@@ -417,7 +525,8 @@ const pickKind = ref<"navigate" | "relocalize" | null>(null);
417
525
  async function send(kind: TRobotCommandKind) {
418
526
  if (kind === "navigate" || kind === "relocalize") {
419
527
  try {
420
- pointGroups.value = savedPointGroups((await getRobotMaps(props.robotId))?.items);
528
+ mapItems.value = (await getRobotMaps(props.robotId))?.items ?? [];
529
+ pointGroups.value = savedPointGroups(mapItems.value);
421
530
  } catch {
422
531
  pointGroups.value = [];
423
532
  }
@@ -432,7 +541,16 @@ async function send(kind: TRobotCommandKind) {
432
541
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
433
542
  return;
434
543
  }
435
- run(kind, body);
544
+ // Say what will happen before it is sent (the point picker is its own confirm).
545
+ confirming.value = { kind, body, text: commandConfirm(kind, { type: robotType.value, name: record.value?.name }) };
546
+ confirmOpen.value = true;
547
+ }
548
+ const confirmOpen = ref(false);
549
+ const confirming = ref<{ kind: TRobotCommandKind; body: Record<string, unknown>; text: { title: string; body: string; action: string } } | null>(null);
550
+ function confirmSend() {
551
+ const c = confirming.value;
552
+ confirmOpen.value = false;
553
+ if (c) run(c.kind, c.body);
436
554
  }
437
555
 
438
556
  /** The robot's plan image size for its floor, for turning plan clicks into pixels. */
@@ -475,7 +593,7 @@ function onPick(p: TSavedPoint) {
475
593
  if (!kind) return;
476
594
  const body = pointCommandBody(kind, p, live.value);
477
595
  if (!body) {
478
- toast.value = "Set position: the robot is not on that floor.";
596
+ toast.value = "Set position: the robot must be on this map first.";
479
597
  return;
480
598
  }
481
599
  run(kind, body);
@@ -537,6 +655,8 @@ watch(
537
655
  record.value = null;
538
656
  plans.value = [];
539
657
  patrol.value = null;
658
+ mapItems.value = [];
659
+ selectedMapId.value = null;
540
660
  trail.value = [];
541
661
  spot.value = [];
542
662
  lastPose.value = null;
@@ -544,11 +664,15 @@ watch(
544
664
  streamLost.value = false;
545
665
  await loadRecord();
546
666
  loadSchedule();
667
+ loadMaps();
547
668
  },
548
669
  { immediate: true },
549
670
  );
550
671
  // The first live reading says whether there is a schedule to ask for (no interface = none).
551
- watch(noInterface, () => loadSchedule());
672
+ watch(noInterface, () => {
673
+ loadSchedule();
674
+ loadMaps();
675
+ });
552
676
  getRobotBrands().then(
553
677
  (res) => (brands.value = res?.items ?? []),
554
678
  () => undefined,
@@ -147,7 +147,7 @@
147
147
  :loading="busy === item._id && lastCommand === 'return'"
148
148
  @click="send(item, { kind: 'return' }, 'return')"
149
149
  >
150
- Return to dock
150
+ Return to charger
151
151
  </AppButton>
152
152
  <AppButton
153
153
  v-if="canCommand"
@@ -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