@7365admin1/layer-common 4.54.1 → 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 +10 -4
- package/assets/css/robot-hero.css +8 -0
- package/components/Robot/BuildingMap.vue +29 -3
- package/components/Robot/Detail.vue +73 -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 +8 -1
- package/utils/robot-maps.ts +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 4.54.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -1575,11 +1581,11 @@
|
|
|
1575
1581
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1576
1582
|
own rule from two endpoints the console already calls, unprojected:
|
|
1577
1583
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1584
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1585
|
+
GET /api/roles/id/:role that membership's role document
|
|
1580
1586
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1587
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1588
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1583
1589
|
|
|
1584
1590
|
`role.default` is the marker because it is the only property of a platform
|
|
1585
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,
|
|
@@ -99,17 +99,29 @@
|
|
|
99
99
|
<span class="hero-aside__title">{{ planTitle }}</span>
|
|
100
100
|
<span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
|
|
101
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>
|
|
102
110
|
<RobotLivePlan
|
|
103
111
|
:name="record?.name"
|
|
104
112
|
:status-label="statusLabel"
|
|
105
|
-
:point="posLive ? point : null"
|
|
113
|
+
:point="posLive && robotOnShownMap ? point : null"
|
|
106
114
|
:stale="shown?.stale === true"
|
|
107
115
|
:heading="headingDeg(shown)"
|
|
108
116
|
:trail="trail"
|
|
109
117
|
:readout="readout"
|
|
110
118
|
:lost="posLost"
|
|
111
119
|
:route="route"
|
|
112
|
-
:
|
|
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"
|
|
113
125
|
:spot="spot"
|
|
114
126
|
:pick="spotOn"
|
|
115
127
|
:tick-ms="tickMs"
|
|
@@ -160,7 +172,16 @@
|
|
|
160
172
|
<RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
|
|
161
173
|
</v-dialog>
|
|
162
174
|
<v-dialog v-model="pickOpen" width="520">
|
|
163
|
-
<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
|
+
/>
|
|
164
185
|
</v-dialog>
|
|
165
186
|
|
|
166
187
|
<RobotToast v-model="toast" />
|
|
@@ -180,6 +201,7 @@ import RobotLiveView from "./LiveView.vue";
|
|
|
180
201
|
import RobotManualDrive from "./ManualDrive.vue";
|
|
181
202
|
import RobotPointPicker from "./PointPicker.vue";
|
|
182
203
|
import { faultPhotos } from "../../utils/robot-control";
|
|
204
|
+
import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
183
205
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
184
206
|
import {
|
|
185
207
|
brandName,
|
|
@@ -330,12 +352,17 @@ const readout = computed(() => `${positionText(live.value, caps.value, now.value
|
|
|
330
352
|
const spot = ref<Array<{ x: number; y: number }>>([]);
|
|
331
353
|
const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
|
|
332
354
|
|
|
333
|
-
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));
|
|
334
356
|
const floorText = computed(() => facts.value[2]?.value ?? "");
|
|
335
|
-
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
|
+
});
|
|
336
362
|
const planHint = computed(() => {
|
|
337
363
|
if (spotOn.value) return "Click points on the plan, then send";
|
|
338
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`;
|
|
339
366
|
if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
|
|
340
367
|
return floorText.value;
|
|
341
368
|
});
|
|
@@ -366,6 +393,37 @@ async function loadSchedule() {
|
|
|
366
393
|
}
|
|
367
394
|
}
|
|
368
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
|
+
}
|
|
369
427
|
/* ---- the position stream: only for a brand that reports one ---- */
|
|
370
428
|
function startStream() {
|
|
371
429
|
stream?.stop();
|
|
@@ -417,7 +475,8 @@ const pickKind = ref<"navigate" | "relocalize" | null>(null);
|
|
|
417
475
|
async function send(kind: TRobotCommandKind) {
|
|
418
476
|
if (kind === "navigate" || kind === "relocalize") {
|
|
419
477
|
try {
|
|
420
|
-
|
|
478
|
+
mapItems.value = (await getRobotMaps(props.robotId))?.items ?? [];
|
|
479
|
+
pointGroups.value = savedPointGroups(mapItems.value);
|
|
421
480
|
} catch {
|
|
422
481
|
pointGroups.value = [];
|
|
423
482
|
}
|
|
@@ -475,7 +534,7 @@ function onPick(p: TSavedPoint) {
|
|
|
475
534
|
if (!kind) return;
|
|
476
535
|
const body = pointCommandBody(kind, p, live.value);
|
|
477
536
|
if (!body) {
|
|
478
|
-
toast.value = "Set position: the robot
|
|
537
|
+
toast.value = "Set position: the robot must be on this map first.";
|
|
479
538
|
return;
|
|
480
539
|
}
|
|
481
540
|
run(kind, body);
|
|
@@ -537,6 +596,8 @@ watch(
|
|
|
537
596
|
record.value = null;
|
|
538
597
|
plans.value = [];
|
|
539
598
|
patrol.value = null;
|
|
599
|
+
mapItems.value = [];
|
|
600
|
+
selectedMapId.value = null;
|
|
540
601
|
trail.value = [];
|
|
541
602
|
spot.value = [];
|
|
542
603
|
lastPose.value = null;
|
|
@@ -544,11 +605,15 @@ watch(
|
|
|
544
605
|
streamLost.value = false;
|
|
545
606
|
await loadRecord();
|
|
546
607
|
loadSchedule();
|
|
608
|
+
loadMaps();
|
|
547
609
|
},
|
|
548
610
|
{ immediate: true },
|
|
549
611
|
);
|
|
550
612
|
// The first live reading says whether there is a schedule to ask for (no interface = none).
|
|
551
|
-
watch(noInterface, () =>
|
|
613
|
+
watch(noInterface, () => {
|
|
614
|
+
loadSchedule();
|
|
615
|
+
loadMaps();
|
|
616
|
+
});
|
|
552
617
|
getRobotBrands().then(
|
|
553
618
|
(res) => (brands.value = res?.items ?? []),
|
|
554
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
|
@@ -605,6 +605,8 @@ export type TLiveRobotState = {
|
|
|
605
605
|
batteryPercent?: number | null;
|
|
606
606
|
floorNo?: number | null;
|
|
607
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;
|
|
608
610
|
position?: { x: number; y: number; theta: number } | null;
|
|
609
611
|
/** Where it is on its own floor plan, 0-100 from the top-left; absent when the brand cannot say. */
|
|
610
612
|
planPosition?: { x: number; y: number } | null;
|
|
@@ -890,6 +892,8 @@ export type TPatrolRouteView = {
|
|
|
890
892
|
currentCheckpoint: number | null;
|
|
891
893
|
/** Every stop's name in order, placed on the plan or not (PadBot); a route with none placed is listed, never drawn. */
|
|
892
894
|
stops?: string[];
|
|
895
|
+
/** The brand's id of the map this route is on (PadBot). */
|
|
896
|
+
mapId?: string;
|
|
893
897
|
};
|
|
894
898
|
|
|
895
899
|
const placed = (c: { x?: unknown; y?: unknown }) => finite(c?.x) && finite(c?.y);
|
|
@@ -1110,6 +1114,8 @@ export function savedPointGroups(maps: Array<Record<string, any>> | null | undef
|
|
|
1110
1114
|
*/
|
|
1111
1115
|
export function pointCommandBody(kind: "navigate" | "relocalize", point: TSavedPoint, live: TLiveRobotState | null | undefined): Record<string, unknown> | null {
|
|
1112
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;
|
|
1113
1119
|
if (point.floorNo === null || !finite(live?.floorNo) || live!.floorNo !== point.floorNo) return null;
|
|
1114
1120
|
return { kind: "relocalize", floorNo: point.floorNo, x: point.x, y: point.y, theta: (point.yaw * Math.PI) / 180 };
|
|
1115
1121
|
}
|
|
@@ -1252,7 +1258,7 @@ export function robotFacts(input: {
|
|
|
1252
1258
|
*/
|
|
1253
1259
|
export function mergePose(
|
|
1254
1260
|
live: TLiveRobotState | null | undefined,
|
|
1255
|
-
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,
|
|
1256
1262
|
): TLiveRobotState | null {
|
|
1257
1263
|
if (!live || !live.ok || event?.type !== "pose" || !event.pose) return live ?? null;
|
|
1258
1264
|
const { x, y, theta, mode } = event.pose;
|
|
@@ -1264,6 +1270,7 @@ export function mergePose(
|
|
|
1264
1270
|
positionAt: Date.now(),
|
|
1265
1271
|
position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
|
|
1266
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 } : {}),
|
|
1267
1274
|
...(typeof mode === "string" && mode ? { mode } : {}),
|
|
1268
1275
|
} as TLiveRobotState;
|
|
1269
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
|
+
}
|