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