@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
|
@@ -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>
|
|
@@ -37,9 +37,10 @@
|
|
|
37
37
|
@click:row="onRow"
|
|
38
38
|
>
|
|
39
39
|
<template #item.name="{ item }"><span class="hero-table__name">{{ item.name }}</span></template>
|
|
40
|
-
<template #item.brand="{ item }"><span class="hero-dim">{{
|
|
40
|
+
<template #item.brand="{ item }"><span class="hero-dim">{{ brandModelText(item.brand, brands, live(item)) }}</span></template>
|
|
41
41
|
<template #item.capability="{ item }">
|
|
42
|
-
|
|
42
|
+
<!-- One badge per row; the model's abilities in its tooltip (a mixed fleet without clutter). -->
|
|
43
|
+
<span v-if="capsOf(item)" class="hero-tag hero-tag--neutral hero-tag--xs" :title="abilityTip(item)">{{ capabilityLabel(capsOf(item)) }}</span>
|
|
43
44
|
</template>
|
|
44
45
|
<template #item.status="{ item }">
|
|
45
46
|
<RobotStatusChip v-if="statusOf(item).kind === 'live'" :mode="statusOf(item).mode" :type="item.type" />
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
:states="states"
|
|
71
72
|
:type="type"
|
|
72
73
|
:routes="routes"
|
|
74
|
+
:robot-maps="robotMaps"
|
|
73
75
|
:connected="connected"
|
|
74
76
|
:brands="brands"
|
|
75
77
|
:map="routeMap"
|
|
@@ -91,8 +93,10 @@ import RobotMiniMap from "./MiniMap.vue";
|
|
|
91
93
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
92
94
|
import {
|
|
93
95
|
agoText,
|
|
96
|
+
brandModelText,
|
|
94
97
|
brandName,
|
|
95
98
|
capabilityLabel,
|
|
99
|
+
robotAbilities,
|
|
96
100
|
listMeta,
|
|
97
101
|
robotErrorMessage,
|
|
98
102
|
rowStatus,
|
|
@@ -127,7 +131,7 @@ const emit = defineEmits<{
|
|
|
127
131
|
status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
|
|
128
132
|
}>();
|
|
129
133
|
|
|
130
|
-
const { getSiteFleet, getSiteSummary, getRobotBrands, getPatrolRoutes, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
|
|
134
|
+
const { getSiteFleet, getSiteSummary, getRobotBrands, getPatrolRoutes, getRobotMaps, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* The site's building for the mini-map's robot lanes: the live HERO Fleet
|
|
@@ -226,6 +230,14 @@ function capsOf(row: any) {
|
|
|
226
230
|
if (s && s.monitorable === false) return {};
|
|
227
231
|
return null;
|
|
228
232
|
}
|
|
233
|
+
/** "Does: … · Reports: …" for the capability badge's tooltip. */
|
|
234
|
+
function abilityTip(row: any) {
|
|
235
|
+
const s = live(row);
|
|
236
|
+
return robotAbilities({ capabilities: capsOf(row), profile: s?.ok ? s.profile : null, type: row.type, brand: brandName(row.brand, brands.value) })
|
|
237
|
+
.map((r) => `${r.label}: ${r.value}`)
|
|
238
|
+
.join("
|
|
239
|
+
");
|
|
240
|
+
}
|
|
229
241
|
/** The live fault when the robot answers, else its stored open fault. */
|
|
230
242
|
const faultOf = (row: any) => (live(row)?.ok ? live(row)?.fault ?? null : row.fault ?? null);
|
|
231
243
|
function seenText(row: any) {
|
|
@@ -235,11 +247,19 @@ function seenText(row: any) {
|
|
|
235
247
|
|
|
236
248
|
/* ---- patrol routes for the security robots on screen ---- */
|
|
237
249
|
const routes = ref<Record<string, TPatrolRouteView[]>>({});
|
|
250
|
+
/** Each security robot's own maps (PadBot map images), read once per robot. */
|
|
251
|
+
const robotMaps = ref<Record<string, Array<Record<string, any>>>>({});
|
|
238
252
|
async function loadRoutes() {
|
|
239
253
|
if (props.type !== "security") {
|
|
240
254
|
routes.value = {};
|
|
241
255
|
return;
|
|
242
256
|
}
|
|
257
|
+
const missing = ids.value.filter((id) => !(id in robotMaps.value));
|
|
258
|
+
if (missing.length) {
|
|
259
|
+
const got: Record<string, Array<Record<string, any>>> = {};
|
|
260
|
+
await Promise.allSettled(missing.map(async (id) => (got[id] = (await getRobotMaps(id))?.items ?? [])));
|
|
261
|
+
robotMaps.value = { ...robotMaps.value, ...Object.fromEntries(missing.map((id) => [id, got[id] ?? []])) };
|
|
262
|
+
}
|
|
243
263
|
const next: Record<string, TPatrolRouteView[]> = {};
|
|
244
264
|
await Promise.allSettled(
|
|
245
265
|
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.56.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
|
@@ -395,7 +395,7 @@ export type TRobotCommandKind =
|
|
|
395
395
|
const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
|
|
396
396
|
{ kind: "full", label: "Clean all" },
|
|
397
397
|
{ kind: "area", label: "Clean area" },
|
|
398
|
-
{ kind: "return", label: "Return to
|
|
398
|
+
{ kind: "return", label: "Return to charger" },
|
|
399
399
|
{ kind: "pause", label: "Pause" },
|
|
400
400
|
{ kind: "resume", label: "Resume" },
|
|
401
401
|
{ kind: "cancel", label: "Cancel" },
|
|
@@ -605,14 +605,19 @@ 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;
|
|
611
|
-
/** The brand's own status words ("CLEANING_AUTO"), quoted as "
|
|
613
|
+
/** The brand's own status words ("CLEANING_AUTO"), quoted as "Robot reports". */
|
|
612
614
|
statusName?: string;
|
|
613
615
|
model?: string;
|
|
614
616
|
fault?: { code: string; message?: string; since?: number; imageUrl?: string | null; thermalImageUrl?: string | null } | null;
|
|
617
|
+
/** Brand defaults + this MODEL's differences (core `robotManifest`): two models of one brand can differ. */
|
|
615
618
|
capabilities?: Record<string, unknown>;
|
|
619
|
+
/** Plain-words facts for this brand + model (core `robotManifest`). */
|
|
620
|
+
profile?: TRobotProfile;
|
|
616
621
|
lastSeenAt?: string | null;
|
|
617
622
|
/** Epoch ms of the last answer that was read (set by `mergeLiveStates`). */
|
|
618
623
|
readAt?: number;
|
|
@@ -890,6 +895,8 @@ export type TPatrolRouteView = {
|
|
|
890
895
|
currentCheckpoint: number | null;
|
|
891
896
|
/** Every stop's name in order, placed on the plan or not (PadBot); a route with none placed is listed, never drawn. */
|
|
892
897
|
stops?: string[];
|
|
898
|
+
/** The brand's id of the map this route is on (PadBot). */
|
|
899
|
+
mapId?: string;
|
|
893
900
|
};
|
|
894
901
|
|
|
895
902
|
const placed = (c: { x?: unknown; y?: unknown }) => finite(c?.x) && finite(c?.y);
|
|
@@ -1110,6 +1117,8 @@ export function savedPointGroups(maps: Array<Record<string, any>> | null | undef
|
|
|
1110
1117
|
*/
|
|
1111
1118
|
export function pointCommandBody(kind: "navigate" | "relocalize", point: TSavedPoint, live: TLiveRobotState | null | undefined): Record<string, unknown> | null {
|
|
1112
1119
|
if (kind === "navigate") return { kind: "go-to-point", mapId: point.mapId, targetPointId: point.targetPointId };
|
|
1120
|
+
// Set position only on the map the robot is on (when it says which).
|
|
1121
|
+
if (typeof live?.mapId === "string" && live.mapId && point.mapId !== live.mapId) return null;
|
|
1113
1122
|
if (point.floorNo === null || !finite(live?.floorNo) || live!.floorNo !== point.floorNo) return null;
|
|
1114
1123
|
return { kind: "relocalize", floorNo: point.floorNo, x: point.x, y: point.y, theta: (point.yaw * Math.PI) / 180 };
|
|
1115
1124
|
}
|
|
@@ -1206,14 +1215,16 @@ export type TRobotFact = { label: string; value: string; dim: boolean };
|
|
|
1206
1215
|
|
|
1207
1216
|
/**
|
|
1208
1217
|
* The detail screen's facts grid (README §3): Connection · Battery · Floor ·
|
|
1209
|
-
* Live position ·
|
|
1218
|
+
* Live position · Robot reports · Model · Serial · Last seen. A fact the brand
|
|
1210
1219
|
* cannot report reads "Not reported" at 50%; a brand with no interface
|
|
1211
|
-
* reports nothing live at all.
|
|
1220
|
+
* reports nothing live at all. `hideUnreported` drops those cells instead
|
|
1221
|
+
* (owner 2026-09-26: show only what THIS robot supports - hide, never grey).
|
|
1212
1222
|
*/
|
|
1213
1223
|
export function robotFacts(input: {
|
|
1214
1224
|
record?: Record<string, any> | null;
|
|
1215
1225
|
live?: (TLiveRobotState & { statusName?: string; model?: string }) | null;
|
|
1216
1226
|
now?: number;
|
|
1227
|
+
hideUnreported?: boolean;
|
|
1217
1228
|
}): TRobotFact[] {
|
|
1218
1229
|
const live = input.live ?? null;
|
|
1219
1230
|
const record = input.record ?? {};
|
|
@@ -1233,16 +1244,19 @@ export function robotFacts(input: {
|
|
|
1233
1244
|
const position = !reports("readLocation")
|
|
1234
1245
|
? nr("Live position")
|
|
1235
1246
|
: fact("Live position", live ? positionText(live, caps ?? { readLocation: true }) : "");
|
|
1236
|
-
|
|
1247
|
+
const all = [
|
|
1237
1248
|
connection,
|
|
1238
1249
|
battery,
|
|
1239
1250
|
floor,
|
|
1240
1251
|
position,
|
|
1241
|
-
|
|
1242
|
-
fact("
|
|
1252
|
+
// A bare vendor code ("CLEANING_AUTO") is not words: the status chip already says it in ours.
|
|
1253
|
+
none ? nr("Robot reports") : fact("Robot reports", live?.ok && !/^[A-Z0-9_]+$/.test(live.statusName ?? "") ? live.statusName : ""),
|
|
1254
|
+
fact("Model", live?.ok ? live.profile?.model || live.model : ""),
|
|
1243
1255
|
fact("Serial", record.robotSn),
|
|
1244
1256
|
fact("Last seen", agoText(live?.lastSeenAt ?? record.lastSeenAt, input.now)),
|
|
1245
1257
|
];
|
|
1258
|
+
// Hidden: a fact the brand cannot report, and a model nobody reported.
|
|
1259
|
+
return input.hideUnreported ? all.filter((f) => !(f.dim && f.value === "Not reported") && !((f.label === "Model" || f.label === "Robot reports") && f.value === "—")) : all;
|
|
1246
1260
|
}
|
|
1247
1261
|
|
|
1248
1262
|
/**
|
|
@@ -1252,7 +1266,7 @@ export function robotFacts(input: {
|
|
|
1252
1266
|
*/
|
|
1253
1267
|
export function mergePose(
|
|
1254
1268
|
live: TLiveRobotState | null | undefined,
|
|
1255
|
-
event: { type?: unknown; pose?: { x?: unknown; y?: unknown; theta?: unknown; mode?: unknown } | null; planPosition?: unknown } | null | undefined,
|
|
1269
|
+
event: { type?: unknown; pose?: { x?: unknown; y?: unknown; theta?: unknown; mode?: unknown } | null; planPosition?: unknown; mapId?: unknown } | null | undefined,
|
|
1256
1270
|
): TLiveRobotState | null {
|
|
1257
1271
|
if (!live || !live.ok || event?.type !== "pose" || !event.pose) return live ?? null;
|
|
1258
1272
|
const { x, y, theta, mode } = event.pose;
|
|
@@ -1264,6 +1278,7 @@ export function mergePose(
|
|
|
1264
1278
|
positionAt: Date.now(),
|
|
1265
1279
|
position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
|
|
1266
1280
|
...(plan && finite(plan.x) && finite(plan.y) ? { planPosition: { x: plan.x, y: plan.y } } : {}),
|
|
1281
|
+
...(typeof event.mapId === "string" && event.mapId ? { mapId: event.mapId } : {}),
|
|
1267
1282
|
...(typeof mode === "string" && mode ? { mode } : {}),
|
|
1268
1283
|
} as TLiveRobotState;
|
|
1269
1284
|
}
|
|
@@ -1410,6 +1425,129 @@ export function capabilityNote(capabilities: Record<string, unknown> | null | un
|
|
|
1410
1425
|
return `${label} — no ${list}`;
|
|
1411
1426
|
}
|
|
1412
1427
|
|
|
1428
|
+
/** Plain-words facts for one brand + model, from core's manifest (`TFleetRobot.profile`). */
|
|
1429
|
+
export type TRobotProfile = {
|
|
1430
|
+
model?: string;
|
|
1431
|
+
modelKnown?: boolean;
|
|
1432
|
+
alarms?: string[];
|
|
1433
|
+
makerApp?: string;
|
|
1434
|
+
makerAppOnly?: string[];
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
export type TRobotAbilityRow = { label: string; value: string };
|
|
1438
|
+
|
|
1439
|
+
const listText = (items: string[]) =>
|
|
1440
|
+
items.length > 1 ? `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}` : items[0] ?? "";
|
|
1441
|
+
const firstUp = (t: string) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : t);
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* "What this robot can do" (owner 2026-09-26): a few short rows in our words,
|
|
1445
|
+
* built only from THIS robot's manifest (brand + model), so two models read
|
|
1446
|
+
* differently. Rows with nothing to say are left out; [] while the robot has
|
|
1447
|
+
* not been read, or when it has no interface (the no-monitoring panel says so).
|
|
1448
|
+
*
|
|
1449
|
+
* - Does: what it does when told (only what its maker's API lets us send);
|
|
1450
|
+
* - Reports: what it tells us (battery, floor, position, heat, past patrols);
|
|
1451
|
+
* - Alarms: the alarm kinds it raises;
|
|
1452
|
+
* - In <app>: what only the maker's own app does (so nobody hunts for it here);
|
|
1453
|
+
* - Not shared yet: the facts (battery, floor, position) its maker does not give us.
|
|
1454
|
+
*/
|
|
1455
|
+
export function robotAbilities(input: {
|
|
1456
|
+
capabilities?: Record<string, unknown> | null;
|
|
1457
|
+
profile?: TRobotProfile | null;
|
|
1458
|
+
type?: unknown;
|
|
1459
|
+
brand?: unknown;
|
|
1460
|
+
} | null | undefined): TRobotAbilityRow[] {
|
|
1461
|
+
const caps = input?.capabilities;
|
|
1462
|
+
if (!caps || typeof caps !== "object" || capabilityLabel(caps) === "No interface") return [];
|
|
1463
|
+
const on = (k: string) => caps[k] === true;
|
|
1464
|
+
const patrols = input?.type === "security" && on("patrolControl");
|
|
1465
|
+
const cleans = input?.type !== "security";
|
|
1466
|
+
const does = [
|
|
1467
|
+
patrols && "patrols its routes (start, stop, resume)",
|
|
1468
|
+
cleans && on("startProcess") && "cleans a whole floor or chosen areas",
|
|
1469
|
+
cleans && on("spotClean") && "cleans spots you pick on the plan",
|
|
1470
|
+
on("pauseResume") && "pauses and resumes",
|
|
1471
|
+
!patrols && on("pauseProcess") && "stops a job",
|
|
1472
|
+
on("navigateToPoint") && "goes to a point you pick",
|
|
1473
|
+
on("relocalize") && "can be told where it is",
|
|
1474
|
+
on("sendToDock") && "returns to its charger",
|
|
1475
|
+
on("liveVideo") && "streams live video",
|
|
1476
|
+
on("manualDrive") && "can be driven by hand",
|
|
1477
|
+
].filter(Boolean) as string[];
|
|
1478
|
+
const reports = [
|
|
1479
|
+
on("battery") && "battery",
|
|
1480
|
+
on("floorReporting") && "floor",
|
|
1481
|
+
on("readLocation") && "live position",
|
|
1482
|
+
on("thermalCamera") && "heat readings",
|
|
1483
|
+
on("patrolHistory") && (on("thermalCamera") ? "past patrols with photos and temperatures" : "past patrols with photos"),
|
|
1484
|
+
].filter(Boolean) as string[];
|
|
1485
|
+
const p = input?.profile ?? {};
|
|
1486
|
+
const alarms = (Array.isArray(p.alarms) ? p.alarms : []).filter((a) => typeof a === "string" && a);
|
|
1487
|
+
const makerOnly = (Array.isArray(p.makerAppOnly) ? p.makerAppOnly : []).filter((a) => typeof a === "string" && a);
|
|
1488
|
+
const rows: TRobotAbilityRow[] = [];
|
|
1489
|
+
if (does.length) rows.push({ label: "Does", value: `${firstUp(listText(does))}.` });
|
|
1490
|
+
if (reports.length) rows.push({ label: "Reports", value: `${firstUp(listText(reports))}.` });
|
|
1491
|
+
if (alarms.length) rows.push({ label: "Alarms", value: `${firstUp(listText(alarms))}${on("resolveAlerts") ? " - mark them resolved here." : "."}` });
|
|
1492
|
+
if (makerOnly.length && p.makerApp) rows.push({ label: `In ${p.makerApp}`, value: `${firstUp(listText(makerOnly))}.` });
|
|
1493
|
+
// Facts only: an action it doesn't take is simply absent from "Does", never listed as missing.
|
|
1494
|
+
const missing = [!on("battery") && "battery", !on("floorReporting") && "floor", !on("readLocation") && "live position"].filter(Boolean) as string[];
|
|
1495
|
+
if (missing.length) rows.push({ label: "Not shared yet", value: `${firstUp(listText(missing))}.` });
|
|
1496
|
+
return rows;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* The empty schedule for a robot whose maker keeps schedules in its own app:
|
|
1501
|
+
* "Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet."
|
|
1502
|
+
*/
|
|
1503
|
+
export function scheduleElsewhereText(type: unknown, brand: unknown, profile?: TRobotProfile | null): string {
|
|
1504
|
+
const what = type === "security" ? "Patrol schedules" : "Cleaning schedules";
|
|
1505
|
+
const name = (typeof brand === "string" && brand.trim()) || "The maker";
|
|
1506
|
+
const app = profile?.makerApp || `${name}'s own app`;
|
|
1507
|
+
return `${what} are set in ${app}. ${name} doesn't share them with us yet.`;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* The confirm step for a command (owner 2026-09-26: say what will happen
|
|
1512
|
+
* before it is sent). `point` names the saved point for Go to point / Set position.
|
|
1513
|
+
*/
|
|
1514
|
+
export function commandConfirm(
|
|
1515
|
+
kind: unknown,
|
|
1516
|
+
input: { type?: unknown; name?: unknown; point?: unknown } = {},
|
|
1517
|
+
): { title: string; body: string; action: string } {
|
|
1518
|
+
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1519
|
+
const at = typeof input.point === "string" && input.point ? input.point : "";
|
|
1520
|
+
const label = kind === "full" && input.type === "security" ? "Start patrol" : commandLabel(kind);
|
|
1521
|
+
const body: Record<string, string> = {
|
|
1522
|
+
full: input.type === "security" ? `${who} starts walking its default patrol route once.` : `${who} starts cleaning the whole floor it is on.`,
|
|
1523
|
+
area: `${who} starts cleaning the chosen areas.`,
|
|
1524
|
+
return: `${who} stops what it is doing and drives back to its charger.`,
|
|
1525
|
+
pause: `${who} stops where it is and waits. Press Resume to carry on.`,
|
|
1526
|
+
resume: `${who} carries on with the job it paused.`,
|
|
1527
|
+
cancel: `${who} stops the current job and stays where it is. It will not carry on by itself.`,
|
|
1528
|
+
navigate: at ? `${who} drives to ${at}.` : `${who} drives to the point you pick.`,
|
|
1529
|
+
relocalize: at
|
|
1530
|
+
? `${who} is told it is standing at ${at}. Only do this if it really is there - a wrong position gets it lost.`
|
|
1531
|
+
: `${who} is told where it is standing. Only do this if the position is right.`,
|
|
1532
|
+
"patrol-resume": `${who} carries on with the patrol that was interrupted.`,
|
|
1533
|
+
};
|
|
1534
|
+
return { title: `${label}?`, body: body[String(kind)] ?? `${who}: ${label}.`, action: label };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** The Brand column: "PadBot S2", "Demo S1 (simulated)", or just "Ounitech" when no model is reported. */
|
|
1538
|
+
export function brandModelText(
|
|
1539
|
+
brand: unknown,
|
|
1540
|
+
brands: Array<{ brand?: unknown; displayName?: unknown }> | null | undefined,
|
|
1541
|
+
live?: TLiveRobotState | null,
|
|
1542
|
+
): string {
|
|
1543
|
+
const name = brandName(brand, brands);
|
|
1544
|
+
const model = live?.ok ? (live.profile?.model || live.model || "").trim() : "";
|
|
1545
|
+
if (!model) return name;
|
|
1546
|
+
const word = name.split(" ")[0]!.toLowerCase();
|
|
1547
|
+
const simulated = /\(simulated\)/i.test(name) ? " (simulated)" : "";
|
|
1548
|
+
return (model.toLowerCase().startsWith(word) ? model : `${name.replace(/\s*\(.*\)$/, "")} ${model}`) + simulated;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1413
1551
|
/**
|
|
1414
1552
|
* The note under the credentials. The handoff says "stored encrypted"; they
|
|
1415
1553
|
* are not encrypted at rest today (see HERO-PENDING_INTEGRATIONS), so the note
|
|
@@ -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
|
+
}
|