@7365admin1/layer-common 4.77.0 → 4.78.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 +7 -0
- package/components/Robot/CleaningRecords.vue +93 -5
- package/components/Robot/Detail.vue +13 -12
- package/components/Robot/LivePlan.vue +12 -1
- package/package.json +1 -1
- package/utils/robot-maps.ts +55 -0
- package/utils/robot-records.ts +79 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.78.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7050e64: Robot detail: virtual walls, safe and slow zones and the taught route drawn on the robot map (with a line naming them); records card adds status history, map details (walls, zones, versions, floor map history, obstacle picture), changes made in the maker's app and read-only robot settings; the 0-jobs state says the maker reports no finished cleaning jobs. Mobile robot-rules copy now carries the records rows.
|
|
8
|
+
|
|
3
9
|
## 4.77.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1920,11 +1926,11 @@
|
|
|
1920
1926
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1921
1927
|
own rule from two endpoints the console already calls, unprojected:
|
|
1922
1928
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1929
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1930
|
+
GET /api/roles/id/:role that membership's role document
|
|
1925
1931
|
|
|
1926
|
-
|
|
1927
|
-
|
|
1932
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1933
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1928
1934
|
|
|
1929
1935
|
`role.default` is the marker because it is the only property of a platform
|
|
1930
1936
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -671,6 +671,13 @@
|
|
|
671
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
672
|
.hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
|
|
673
673
|
.hero-plan__overlay { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.35; }
|
|
674
|
+
.hero-plan__zones { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
|
675
|
+
.hero-plan__zones polyline, .hero-plan__zones polygon { vector-effect: non-scaling-stroke; stroke-width: 2; stroke-linejoin: round; }
|
|
676
|
+
.hero-plan__wall { fill: none; stroke: #e53935; }
|
|
677
|
+
.hero-plan__safe { fill: rgba(67, 160, 71, 0.18); stroke: #43a047; }
|
|
678
|
+
.hero-plan__slow { fill: rgba(251, 140, 0, 0.18); stroke: #fb8c00; }
|
|
679
|
+
.hero-plan__taught { fill: none; stroke: #1e88e5; stroke-dasharray: 4 3; }
|
|
680
|
+
.hero-rec__obstacles { display: block; max-width: 100%; max-height: 320px; margin-top: 4px; border: 1px solid var(--hero-line, rgba(127, 127, 127, 0.3)); image-rendering: pixelated; }
|
|
674
681
|
.hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
|
|
675
682
|
.hero-rec { margin-top: 16px; }
|
|
676
683
|
.hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
3
|
Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
|
|
4
|
-
jobs, alert history, then - folded - every reading the
|
|
5
|
-
wear, the power schedule and lifts
|
|
4
|
+
jobs, alert history, status history, then - folded - every reading the
|
|
5
|
+
robot sends, parts wear, the power schedule and lifts, map details (walls,
|
|
6
|
+
zones, versions, obstacles), changes made in the maker's app and the
|
|
7
|
+
robot's settings. A section shows only when the brand sends its part;
|
|
8
|
+
brands without records render nothing.
|
|
6
9
|
A part the maker refused is said in words, never left blank.
|
|
7
10
|
-->
|
|
8
11
|
<section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
|
|
@@ -13,7 +16,9 @@
|
|
|
13
16
|
<v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
|
|
14
17
|
</div>
|
|
15
18
|
<div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
|
|
16
|
-
<div v-if="!jobs.length" class="hero-sched__empty">
|
|
19
|
+
<div v-if="!jobs.length && !records.tasks?.error" class="hero-sched__empty">
|
|
20
|
+
{{ noJobs }} {{ noJobsMore }}
|
|
21
|
+
</div>
|
|
17
22
|
<div v-else class="hero-rec__wrap">
|
|
18
23
|
<table class="hero-rec__table">
|
|
19
24
|
<thead>
|
|
@@ -38,6 +43,14 @@
|
|
|
38
43
|
</div>
|
|
39
44
|
<v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
|
|
40
45
|
|
|
46
|
+
<template v-if="has.has('statusLog')">
|
|
47
|
+
<div class="hero-section__title mt-4">Status history</div>
|
|
48
|
+
<div class="hero-sched__note">What the robot was doing in the last 7 days (cleaning, returning, charging and so on).</div>
|
|
49
|
+
<div v-if="!statusLog.length" class="hero-sched__empty">No status changes in the last 7 days.</div>
|
|
50
|
+
<div v-for="l in shownLog" :key="l.key" class="hero-sched__row"><b>{{ l.status }}</b><span>{{ l.when }}</span></div>
|
|
51
|
+
<v-btn v-if="statusLog.length > 5" variant="text" size="small" @click="allLog = !allLog">{{ allLog ? "Show fewer" : `See all ${statusLog.length}` }}</v-btn>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
41
54
|
<details class="hero-can hero-can--fold" :open="attention.length > 0">
|
|
42
55
|
<summary class="hero-can__title">
|
|
43
56
|
Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
|
|
@@ -68,21 +81,84 @@
|
|
|
68
81
|
<div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
|
|
69
82
|
<div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
|
|
70
83
|
</details>
|
|
84
|
+
|
|
85
|
+
<details v-if="has.has('mapVersions') || has.has('mapHistory') || has.has('mapLayers') || has.has('obstacles')" class="hero-can hero-can--fold">
|
|
86
|
+
<summary class="hero-can__title">Map details: walls, zones, versions and obstacles</summary>
|
|
87
|
+
<div class="hero-sched__note">Walls and zones on the robot's map (also drawn on the map above)</div>
|
|
88
|
+
<div v-if="!zones.length" class="hero-sched__empty">No virtual walls, safe zones or slow zones on this map.</div>
|
|
89
|
+
<div v-for="z in zones" :key="z.key" class="hero-sched__row"><b>{{ z.kind }}</b><span>{{ z.names }}</span></div>
|
|
90
|
+
<template v-if="has.has('mapVersions')">
|
|
91
|
+
<div class="hero-sched__note mt-2">Versions of this map (each re-scan or edit in {{ makerApp }})</div>
|
|
92
|
+
<div v-if="!versions.length" class="hero-sched__empty">No versions listed.</div>
|
|
93
|
+
<div v-for="v in versions" :key="v.key" class="hero-sched__row"><b>{{ v.version }}</b><span>{{ v.when }}</span><span>{{ v.state }}</span></div>
|
|
94
|
+
</template>
|
|
95
|
+
<template v-if="has.has('mapHistory')">
|
|
96
|
+
<div class="hero-sched__note mt-2">Every map made for this floor</div>
|
|
97
|
+
<div v-if="!history.length" class="hero-sched__empty">No maps listed for this floor.</div>
|
|
98
|
+
<div v-for="m in history" :key="m.key" class="hero-sched__row"><b>{{ m.name }}</b><span>{{ m.size }} · {{ m.when }}</span><span>{{ m.state }}</span></div>
|
|
99
|
+
</template>
|
|
100
|
+
<template v-if="has.has('obstacles')">
|
|
101
|
+
<div class="hero-sched__note mt-2">Obstacles the robot's sensors have marked (as {{ brand || "the maker" }} draws them)</div>
|
|
102
|
+
<img v-if="obstacles.image" class="hero-rec__obstacles" :src="obstacles.image" alt="Obstacles marked by the robot's sensors" />
|
|
103
|
+
<div v-else class="hero-sched__empty">{{ obstacles.note }}</div>
|
|
104
|
+
</template>
|
|
105
|
+
</details>
|
|
106
|
+
|
|
107
|
+
<details v-if="has.has('appActions')" class="hero-can hero-can--fold">
|
|
108
|
+
<summary class="hero-can__title">Changes made in {{ makerApp }} · last 30 days</summary>
|
|
109
|
+
<div v-if="!actions.length" class="hero-sched__empty">Nobody used {{ makerApp }} on this robot in the last 30 days.</div>
|
|
110
|
+
<div v-for="a in actions" :key="a.key" class="hero-sched__row"><b>{{ a.action }}</b><span>{{ a.when }}</span><span>{{ a.by }}</span></div>
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
<details v-if="has.has('settings')" class="hero-can hero-can--fold">
|
|
114
|
+
<summary class="hero-can__title">Robot settings (read-only)</summary>
|
|
115
|
+
<div class="hero-sched__note">As the robot reports them. Settings are changed in {{ makerApp }}.</div>
|
|
116
|
+
<div v-if="!settings.length" class="hero-sched__empty">The robot sent no settings.</div>
|
|
117
|
+
<div v-for="g in settings" :key="g.group" class="mb-2">
|
|
118
|
+
<div class="hero-sched__note">{{ g.group }}</div>
|
|
119
|
+
<div class="hero-rec__grid">
|
|
120
|
+
<div v-for="r in g.rows" :key="r.key" class="hero-can__row">
|
|
121
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
122
|
+
<span>{{ r.value }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
71
127
|
</section>
|
|
72
128
|
</template>
|
|
73
129
|
|
|
74
130
|
<script setup lang="ts">
|
|
75
131
|
import { computed, ref, watch } from "vue";
|
|
76
132
|
import { robotErrorMessage } from "../../utils/robot-display";
|
|
77
|
-
import {
|
|
133
|
+
import {
|
|
134
|
+
alertHistoryRows,
|
|
135
|
+
appActionRows,
|
|
136
|
+
attentionReadings,
|
|
137
|
+
jobRows,
|
|
138
|
+
liftRows,
|
|
139
|
+
mapHistoryRows,
|
|
140
|
+
mapVersionRows,
|
|
141
|
+
noJobsHint,
|
|
142
|
+
noJobsText,
|
|
143
|
+
obstaclePicture,
|
|
144
|
+
partRows,
|
|
145
|
+
powerRows,
|
|
146
|
+
readingGroups,
|
|
147
|
+
recordErrors,
|
|
148
|
+
recordParts,
|
|
149
|
+
settingGroups,
|
|
150
|
+
statusLogRows,
|
|
151
|
+
zoneRows,
|
|
152
|
+
} from "../../utils/robot-records";
|
|
78
153
|
|
|
79
|
-
const props = defineProps<{ robotId: string; makerApp: string }>();
|
|
154
|
+
const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
|
|
80
155
|
const { getRobotRecords } = useRobots();
|
|
81
156
|
|
|
82
157
|
const records = ref<Record<string, any> | null>(null);
|
|
83
158
|
const loading = ref(false);
|
|
84
159
|
const allJobs = ref(false);
|
|
85
160
|
const allAlerts = ref(false);
|
|
161
|
+
const allLog = ref(false);
|
|
86
162
|
|
|
87
163
|
// ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
|
|
88
164
|
async function load(force = false) {
|
|
@@ -104,6 +180,7 @@ watch(
|
|
|
104
180
|
records.value = null;
|
|
105
181
|
allJobs.value = false;
|
|
106
182
|
allAlerts.value = false;
|
|
183
|
+
allLog.value = false;
|
|
107
184
|
load(true);
|
|
108
185
|
},
|
|
109
186
|
{ immediate: true },
|
|
@@ -119,4 +196,15 @@ const parts = computed(() => partRows(records.value));
|
|
|
119
196
|
const power = computed(() => powerRows(records.value));
|
|
120
197
|
const lifts = computed(() => liftRows(records.value));
|
|
121
198
|
const errors = computed(() => recordErrors(records.value));
|
|
199
|
+
const noJobs = computed(() => noJobsText(props.brand ?? ""));
|
|
200
|
+
const noJobsMore = computed(() => noJobsHint(props.brand ?? ""));
|
|
201
|
+
const has = computed(() => recordParts(records.value));
|
|
202
|
+
const statusLog = computed(() => statusLogRows(records.value));
|
|
203
|
+
const shownLog = computed(() => (allLog.value ? statusLog.value : statusLog.value.slice(0, 5)));
|
|
204
|
+
const actions = computed(() => appActionRows(records.value));
|
|
205
|
+
const settings = computed(() => settingGroups(records.value));
|
|
206
|
+
const zones = computed(() => zoneRows(records.value));
|
|
207
|
+
const versions = computed(() => mapVersionRows(records.value));
|
|
208
|
+
const history = computed(() => mapHistoryRows(records.value));
|
|
209
|
+
const obstacles = computed(() => obstaclePicture(records.value));
|
|
122
210
|
</script>
|
|
@@ -113,12 +113,15 @@
|
|
|
113
113
|
:points="selectedMap?.points ?? []"
|
|
114
114
|
:overlay="selectedMap?.overlay ?? null"
|
|
115
115
|
:areas="selectedMap?.areas ?? []"
|
|
116
|
+
:zones="selectedMap?.zones ?? []"
|
|
117
|
+
:taught-route="selectedMap?.taughtRoute ?? []"
|
|
116
118
|
:route-moving="statusMode === 'WORKING' && robotOnShownMap"
|
|
117
119
|
:spot="spot"
|
|
118
120
|
:pick="spotOn"
|
|
119
121
|
:tick-ms="tickMs"
|
|
120
122
|
@pick="(p) => spot.push(p)"
|
|
121
123
|
/>
|
|
124
|
+
<p v-if="mapLegend" class="hero-muted hero-detail__lag">{{ mapLegend }}</p>
|
|
122
125
|
<p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
|
|
123
126
|
Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
|
|
124
127
|
</p>
|
|
@@ -193,7 +196,7 @@
|
|
|
193
196
|
</section>
|
|
194
197
|
|
|
195
198
|
<!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
|
|
196
|
-
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" />
|
|
199
|
+
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" :brand="brand" />
|
|
197
200
|
|
|
198
201
|
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
199
202
|
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
@@ -301,7 +304,7 @@ import RobotLiveView from "./LiveView.vue";
|
|
|
301
304
|
import RobotManualDrive from "./ManualDrive.vue";
|
|
302
305
|
import RobotPointPicker from "./PointPicker.vue";
|
|
303
306
|
import { faultPhotos } from "../../utils/robot-control";
|
|
304
|
-
import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
307
|
+
import { cleanAreaChoices, cleanAreaCommand, mapLayerLegend, robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
305
308
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
306
309
|
import {
|
|
307
310
|
attentionText,
|
|
@@ -574,6 +577,7 @@ const mapItems = ref<Array<Record<string, any>>>([]);
|
|
|
574
577
|
const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
|
|
575
578
|
const selectedMapId = ref<string | null>(null);
|
|
576
579
|
const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
|
|
580
|
+
const mapLegend = computed(() => mapLayerLegend(selectedMap.value));
|
|
577
581
|
watch(mapViews, (views) => {
|
|
578
582
|
if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
|
|
579
583
|
});
|
|
@@ -717,10 +721,9 @@ async function send(kind: TRobotCommandKind) {
|
|
|
717
721
|
}
|
|
718
722
|
// Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
|
|
719
723
|
if (kind === "area" && robotType.value !== "security") {
|
|
720
|
-
const
|
|
721
|
-
const zones = map?.areas.map((a) => a.name) ?? [];
|
|
724
|
+
const { areas: zones, empty } = cleanAreaChoices(mapViews.value, selectedMapId.value, makerApp.value);
|
|
722
725
|
if (!zones.length) {
|
|
723
|
-
commandResult.value = { text:
|
|
726
|
+
commandResult.value = { text: empty, warn: true };
|
|
724
727
|
return;
|
|
725
728
|
}
|
|
726
729
|
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
|
|
@@ -753,13 +756,11 @@ const confirming = ref<{
|
|
|
753
756
|
function pickZones(names: unknown) {
|
|
754
757
|
const c = confirming.value;
|
|
755
758
|
if (!c?.zones) return;
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
c.
|
|
759
|
-
c.
|
|
760
|
-
c.
|
|
761
|
-
const text = commandConfirm("area", { type: robotType.value, name: record.value?.name });
|
|
762
|
-
c.text = { ...text, body: picked.length ? `${record.value?.name ?? "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area." };
|
|
759
|
+
const cmd = cleanAreaCommand(record.value?.name, c.zones, names);
|
|
760
|
+
c.zoneNames = cmd.body.areas;
|
|
761
|
+
c.body = cmd.body;
|
|
762
|
+
c.what = cmd.what;
|
|
763
|
+
c.text = { ...commandConfirm("area", { type: robotType.value, name: record.value?.name }), body: cmd.sentence };
|
|
763
764
|
}
|
|
764
765
|
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
765
766
|
function pickRoute(id: unknown) {
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
|
|
12
12
|
<img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
|
|
13
|
+
<svg v-if="image && (zones.length || taughtRoute.length > 1)" class="hero-plan__zones" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
14
|
+
<template v-for="(z, i) in zones" :key="`${z.kind}-${i}`">
|
|
15
|
+
<polyline v-if="z.kind === 'wall'" class="hero-plan__wall" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
|
|
16
|
+
<polygon v-else :class="z.kind === 'safe' ? 'hero-plan__safe' : 'hero-plan__slow'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
|
|
17
|
+
</template>
|
|
18
|
+
<polyline v-if="taughtRoute.length > 1" class="hero-plan__taught" :points="svgPoints(taughtRoute)" />
|
|
19
|
+
</svg>
|
|
13
20
|
<span v-for="a in placedAreas" :key="a.name" class="hero-plan__area" :style="{ left: `${a.at!.x}%`, top: `${a.at!.y}%` }">{{ a.name }}</span>
|
|
14
21
|
<template v-for="p in points" :key="p.targetPointId">
|
|
15
22
|
<span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
|
|
@@ -61,6 +68,9 @@ const props = withDefaults(
|
|
|
61
68
|
/** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
|
|
62
69
|
overlay?: string | null;
|
|
63
70
|
areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
71
|
+
/** Virtual walls / safe zones / slow zones and the taught route on the map shown (0-100 shares). */
|
|
72
|
+
zones?: Array<{ kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> }>;
|
|
73
|
+
taughtRoute?: Array<{ x: number; y: number }>;
|
|
64
74
|
/** Saved points on the map shown (0-100 shares). */
|
|
65
75
|
points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
|
|
66
76
|
/** The route's dash walks only while the robot patrols. */
|
|
@@ -72,8 +82,9 @@ const props = withDefaults(
|
|
|
72
82
|
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
73
83
|
stale?: boolean;
|
|
74
84
|
}>(),
|
|
75
|
-
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
85
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], zones: () => [], taughtRoute: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
76
86
|
);
|
|
87
|
+
const svgPoints = (pts: Array<{ x: number; y: number }>) => pts.map((p) => `${p.x},${p.y}`).join(" ");
|
|
77
88
|
const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
|
|
78
89
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
79
90
|
// The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
|
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.78.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-maps.ts
CHANGED
|
@@ -28,8 +28,36 @@ export type TRobotMapView = {
|
|
|
28
28
|
areas: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
29
29
|
/** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
|
|
30
30
|
approximate: boolean;
|
|
31
|
+
/** Virtual walls (never crossed), safe zones and slow zones on this map, as 0-100 shares. */
|
|
32
|
+
zones: TRobotMapZone[];
|
|
33
|
+
/** The route the robot was taught on this map (0-100 shares); empty when none. */
|
|
34
|
+
taughtRoute: Array<{ x: number; y: number }>;
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
export type TRobotMapZone = { kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> };
|
|
38
|
+
|
|
39
|
+
const share = (p: any) => isNum(p?.x) && isNum(p?.y) && p.x >= 0 && p.x <= 100 && p.y >= 0 && p.y <= 100;
|
|
40
|
+
function mapZones(list: unknown): TRobotMapZone[] {
|
|
41
|
+
return (Array.isArray(list) ? list : []).flatMap((z: any) => {
|
|
42
|
+
const points = (Array.isArray(z?.points) ? z.points : []).filter(share).map((p: any) => ({ x: p.x, y: p.y }));
|
|
43
|
+
return (z?.kind === "wall" || z?.kind === "safe" || z?.kind === "slow") && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line under the map naming what is drawn on it: "Drawn on the map: 1 virtual wall (red) · 1 slow zone (amber) · taught route (blue dashes)". */
|
|
48
|
+
export function mapLayerLegend(view: Pick<TRobotMapView, "zones" | "taughtRoute"> | null | undefined): string {
|
|
49
|
+
if (!view) return "";
|
|
50
|
+
const n = (k: TRobotMapZone["kind"]) => view.zones.filter((z) => z.kind === k).length;
|
|
51
|
+
const part = (count: number, one: string, many: string, colour: string) => (count ? `${count} ${count === 1 ? one : many} (${colour})` : "");
|
|
52
|
+
const parts = [
|
|
53
|
+
part(n("wall"), "virtual wall", "virtual walls", "red"),
|
|
54
|
+
part(n("safe"), "safe zone", "safe zones", "green"),
|
|
55
|
+
part(n("slow"), "slow zone", "slow zones", "amber"),
|
|
56
|
+
view.taughtRoute.length >= 2 ? "taught route (blue dashes)" : "",
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
return parts.length ? `Drawn on the map: ${parts.join(" · ")}` : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
34
62
|
const levelName = (n: number) => (n > 0 ? `L${n}` : n < 0 ? `B${-n}` : "G");
|
|
35
63
|
const TYPE_WORDS: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
|
|
@@ -90,6 +118,8 @@ export function robotMapViews(
|
|
|
90
118
|
.filter((a: any) => typeof a?.name === "string" && a.name.trim())
|
|
91
119
|
.map((a: any) => ({ name: String(a.name).trim(), at: a.at && isNum(a.at.x) && isNum(a.at.y) ? { x: a.at.x, y: a.at.y } : null })),
|
|
92
120
|
approximate: m.positionApproximate === true,
|
|
121
|
+
zones: mapZones(m.zones),
|
|
122
|
+
taughtRoute: (Array.isArray(m.taughtRoute) ? m.taughtRoute : []).filter(share).map((p: any) => ({ x: p.x, y: p.y })),
|
|
93
123
|
};
|
|
94
124
|
});
|
|
95
125
|
}
|
|
@@ -119,3 +149,28 @@ export function robotMapsByFloor(views: TRobotMapView[]): Map<number, TRobotMapV
|
|
|
119
149
|
for (const v of views) if (v.floorNo !== null) out.set(v.floorNo, [...(out.get(v.floorNo) ?? []), v]);
|
|
120
150
|
return out;
|
|
121
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Clean area (cleaning robots, web and phones): the area names on the map the
|
|
155
|
+
* robot is on (else the map shown). `empty` is the sentence when there are none.
|
|
156
|
+
*/
|
|
157
|
+
export function cleanAreaChoices(views: TRobotMapView[], selectedMapId: string | null | undefined, makerApp: string): { mapLabel: string; areas: string[]; empty: string } {
|
|
158
|
+
const map = views.find((v) => v.current) ?? views.find((v) => v.mapId === selectedMapId) ?? null;
|
|
159
|
+
const areas = map ? map.areas.map((a) => a.name) : [];
|
|
160
|
+
return {
|
|
161
|
+
mapLabel: map?.label ?? "",
|
|
162
|
+
areas,
|
|
163
|
+
empty: areas.length ? "" : `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp || "the maker's app"}.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The picked areas as the command body, the outcome's words and the confirm sentence (only names that are on the map). */
|
|
168
|
+
export function cleanAreaCommand(robotName: string | null | undefined, areas: string[], picked: unknown): { body: { kind: "area"; areas: string[] }; what: string | undefined; sentence: string } {
|
|
169
|
+
const names = (Array.isArray(picked) ? picked : []).filter((n): n is string => typeof n === "string" && areas.includes(n));
|
|
170
|
+
const list = names.length > 2 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names.join(" and ");
|
|
171
|
+
return {
|
|
172
|
+
body: { kind: "area", areas: names },
|
|
173
|
+
what: names.length ? `cleaning ${list}` : undefined,
|
|
174
|
+
sentence: names.length ? `${robotName || "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area.",
|
|
175
|
+
};
|
|
176
|
+
}
|
package/utils/robot-records.ts
CHANGED
|
@@ -24,7 +24,8 @@ export function whenText(iso: unknown, timeZone?: string): string {
|
|
|
24
24
|
const t = typeof iso === "string" ? Date.parse(iso) : NaN;
|
|
25
25
|
if (!Number.isFinite(t)) return "—";
|
|
26
26
|
const parts = Object.fromEntries(
|
|
27
|
-
|
|
27
|
+
// Fixed "30 Sep, 08:15" parts (en-GB), never the device locale; the zone is the viewer's unless one is given.
|
|
28
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: timeZone || undefined, day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
|
|
28
29
|
.formatToParts(t)
|
|
29
30
|
.map((p) => [p.type, p.value]),
|
|
30
31
|
);
|
|
@@ -124,6 +125,12 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
|
|
|
124
125
|
lifts: "lifts",
|
|
125
126
|
readings: "robot readings",
|
|
126
127
|
station: "base station",
|
|
128
|
+
statusLog: "status history",
|
|
129
|
+
appActions: "changes made in the maker's app",
|
|
130
|
+
settings: "robot settings",
|
|
131
|
+
obstacles: "obstacle picture",
|
|
132
|
+
mapVersions: "map versions",
|
|
133
|
+
mapHistory: "floor's map history",
|
|
127
134
|
};
|
|
128
135
|
if (!records) return [];
|
|
129
136
|
const out = typeof records.error === "string" && records.error ? [records.error] : [];
|
|
@@ -133,3 +140,74 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
|
|
|
133
140
|
}
|
|
134
141
|
return out;
|
|
135
142
|
}
|
|
143
|
+
|
|
144
|
+
/** The empty cleaning-jobs line, in plain words (the maker's own list is empty, not ours). */
|
|
145
|
+
export function noJobsText(brand: string, days = 14): string {
|
|
146
|
+
return `${brand || "The maker"} reports no finished cleaning jobs in the last ${days} days.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The line under it: what would make a job appear, and that the maker has been asked. */
|
|
150
|
+
export function noJobsHint(brand: string): string {
|
|
151
|
+
const who = brand || "the maker";
|
|
152
|
+
return `A job started here or by a schedule is listed once ${who} records it; if this robot was meant to clean, we have asked ${who} why nothing is listed.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type TWhenRow = { key: string; when: string };
|
|
156
|
+
|
|
157
|
+
/** The robot's status changes (cleaning, charging, returning ...), newest first as sent. */
|
|
158
|
+
export function statusLogRows(records: { statusLog?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { status: string }> {
|
|
159
|
+
return items(records?.statusLog).map((l, i) => ({ key: `${l.at ?? ""}-${i}`, when: whenText(l.at, timeZone), status: String(l.status || "—") }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What people did to the robot in the maker's app ("Started a cleaning job · Leong"). */
|
|
163
|
+
export function appActionRows(records: { appActions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { action: string; by: string }> {
|
|
164
|
+
return items(records?.appActions).map((a, i) => ({ key: `${a.at ?? ""}-${i}`, when: whenText(a.at, timeZone), action: String(a.action || "—"), by: a.by ? String(a.by) : "—" }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The robot's settings, grouped in the API's order ("Cleaning", "Battery", ... "Other settings" last). */
|
|
168
|
+
export function settingGroups(records: { settings?: TPart<Record<string, any>> } | null | undefined) {
|
|
169
|
+
return readingGroups({ readings: records?.settings });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The current map's walls and zones by kind: "2 virtual walls (Entrance, …)". */
|
|
173
|
+
export function zoneRows(records: { mapLayers?: TPart<Record<string, any>> } | null | undefined): Array<{ key: string; kind: string; names: string }> {
|
|
174
|
+
const zones: Array<{ kind?: string; name?: string }> = items(records?.mapLayers).flatMap((m) => (Array.isArray(m?.zones) ? m.zones : []));
|
|
175
|
+
const words: Record<string, [string, string]> = { wall: ["virtual wall", "virtual walls"], safe: ["safe zone", "safe zones"], slow: ["slow zone", "slow zones"] };
|
|
176
|
+
return (["wall", "safe", "slow"] as const).flatMap((k) => {
|
|
177
|
+
const names = zones.filter((z) => z.kind === k).map((z) => String(z.name ?? ""));
|
|
178
|
+
if (!names.length) return [];
|
|
179
|
+
const [one, many] = words[k]!;
|
|
180
|
+
return [{ key: k, kind: `${names.length} ${names.length === 1 ? one : many}`, names: names.join(", ") }];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Each version of the current map (re-scan or edit), newest first. */
|
|
185
|
+
export function mapVersionRows(records: { mapVersions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
|
|
186
|
+
return items(records?.mapVersions)
|
|
187
|
+
.slice()
|
|
188
|
+
.sort((a, b) => (isNum(b.version) ? b.version : 0) - (isNum(a.version) ? a.version : 0))
|
|
189
|
+
.map((v, i) => ({ key: `${v.version ?? i}`, version: isNum(v.version) ? `Version ${v.version}` : "Version —", when: whenText(v.createdAt, timeZone), state: v.latest ? "In use" : "Older" }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every map made for the robot's floor: name, size, in use or not. */
|
|
193
|
+
export function mapHistoryRows(records: { mapHistory?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
|
|
194
|
+
return items(records?.mapHistory).map((m, i) => ({
|
|
195
|
+
key: `${m.name}-${i}`,
|
|
196
|
+
name: String(m.name ?? "Map"),
|
|
197
|
+
size: isNum(m.areaM2) ? `${m.areaM2} m²` : "—",
|
|
198
|
+
when: `made ${whenText(m.createdAt, timeZone)}${m.updatedAt ? ` · changed ${whenText(m.updatedAt, timeZone)}` : ""}`,
|
|
199
|
+
state: m.latest ? "Latest map" : m.inUse ? "Switched on" : "Old map",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The obstacle picture as a data URL, or a sentence when it is too large / not sent. */
|
|
204
|
+
export function obstaclePicture(records: { obstacles?: TPart<Record<string, any>> } | null | undefined): { image: string | null; note: string } {
|
|
205
|
+
const o = items(records?.obstacles)[0];
|
|
206
|
+
if (o && typeof o.image === "string" && /^data:image\/(png|jpeg);base64,/.test(o.image)) return { image: o.image, note: "" };
|
|
207
|
+
return { image: null, note: o?.tooLarge ? "The obstacle picture is too large to show here." : "No obstacle picture was sent." };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Which records parts this brand sends (a section shows only when its part is there - capability-driven). */
|
|
211
|
+
export function recordParts(records: Record<string, any> | null | undefined): Set<string> {
|
|
212
|
+
return new Set(Object.keys(records ?? {}).filter((k) => records![k] && typeof records![k] === "object" && "items" in records![k]));
|
|
213
|
+
}
|