@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 +16 -4
- package/assets/css/robot-hero.css +8 -0
- package/components/Robot/BuildingMap.vue +29 -3
- package/components/Robot/Detail.vue +74 -8
- package/components/Robot/FloorPlan.vue +26 -8
- package/components/Robot/LivePlan.vue +14 -3
- package/components/Robot/MiniMap.vue +41 -7
- package/components/Robot/PointPicker.vue +43 -6
- package/components/Robot/SiteFleet.vue +10 -1
- package/package.json +1 -1
- package/utils/robot-display.ts +17 -2
- package/utils/robot-maps.ts +109 -0
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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, () =>
|
|
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").
|
|
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
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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<{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
<div v-if="!groups.length" class="hero-muted">No saved points{{ kind === "relocalize" ? " on the robot's current
|
|
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
|
-
<
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.",
|
package/utils/robot-display.ts
CHANGED
|
@@ -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
|
-
: {
|
|
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
|
+
}
|