@7365admin1/layer-common 4.45.0 → 4.47.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 +6 -0
- package/components/ClientMain.vue +2 -0
- package/components/Robot/BuildingMap.vue +25 -9
- package/components/Robot/Form.vue +7 -3
- package/components/Robot/MapEditor.vue +242 -5
- package/composables/useFloorPlanImages.ts +52 -0
- package/composables/useRobotBuilding.ts +18 -5
- package/composables/useRobots.ts +56 -0
- package/composables/useVerification.ts +7 -1
- package/package.json +1 -1
- package/utils/robot-floorplan.ts +132 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.47.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cde8389: `getVerifications` takes `origin`; the console Client List's Pending tab lists only invitations Seven365 sent.
|
|
8
|
+
|
|
9
|
+
## 4.46.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 4cf067f: Robot building map editor: floor plans per floor (upload / replace / remove, stored privately in the map; Set scale by measuring a known length; Fit), HERO Fleet Core import into the saved map, and downloads of the building file and route maps. The 3D building map draws saved floor plans. Edit map is also offered at HERO Fleet Core sites (it edits the site's saved copy). The Add robot note says "stored encrypted" only when the server reports it.
|
|
14
|
+
|
|
3
15
|
## 4.45.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1509,11 +1521,11 @@
|
|
|
1509
1521
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1510
1522
|
own rule from two endpoints the console already calls, unprojected:
|
|
1511
1523
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1524
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1525
|
+
GET /api/roles/id/:role that membership's role document
|
|
1514
1526
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1527
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1528
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1517
1529
|
|
|
1518
1530
|
`role.default` is the marker because it is the only property of a platform
|
|
1519
1531
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1043,6 +1043,12 @@
|
|
|
1043
1043
|
}
|
|
1044
1044
|
.hero-editor__plan:focus-visible { outline: 2px solid var(--hero-accent); outline-offset: 0; }
|
|
1045
1045
|
.hero-editor__room { position: absolute; border: 2px solid var(--hero-text); opacity: 0.4; pointer-events: none; }
|
|
1046
|
+
/* Floor plan image (stored privately in the map) under everything drawn; scale by measurement. */
|
|
1047
|
+
.hero-editor__planimg, .hero-bmap__planimg { position: absolute; max-width: none; pointer-events: none; user-select: none; opacity: 0.55; object-fit: fill; }
|
|
1048
|
+
.hero-editor__measure { stroke: var(--hero-accent); stroke-width: 2; stroke-dasharray: 4 3; }
|
|
1049
|
+
.hero-editor__mpoint { position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
|
|
1050
|
+
.hero-editor__measurebox { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px dashed var(--hero-accent); font-size: 12px; }
|
|
1051
|
+
.hero-editor__measurein { width: 110px; min-height: 32px; }
|
|
1046
1052
|
.hero-editor__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
|
1047
1053
|
.hero-editor__wall { stroke: var(--hero-text); stroke-width: 1.5; }
|
|
1048
1054
|
.hero-editor__zone-shape { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
|
|
@@ -636,6 +636,8 @@ async function fetchPending() {
|
|
|
636
636
|
const res = await getVerifications({
|
|
637
637
|
status: 'pending',
|
|
638
638
|
type: 'user-invite',
|
|
639
|
+
// Only what Seven365 sent - never a client's own invitations (owner, 2026-09-25).
|
|
640
|
+
origin: 'seven365',
|
|
639
641
|
page: page.value,
|
|
640
642
|
search: search.value,
|
|
641
643
|
email: search.value,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
|
|
19
19
|
Full screen
|
|
20
20
|
</v-btn>
|
|
21
|
-
<v-btn v-if="canEditMap
|
|
21
|
+
<v-btn v-if="canEditMap" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
|
|
22
22
|
{{ editing ? "Exit edit mode" : "Edit map" }}
|
|
23
23
|
</v-btn>
|
|
24
24
|
</div>
|
|
@@ -26,12 +26,15 @@
|
|
|
26
26
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
27
27
|
<div v-else-if="!loading && emptyNote" class="hero-notice">{{ emptyNote }}</div>
|
|
28
28
|
|
|
29
|
-
<div v-if="fleet" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds.
|
|
29
|
+
<div v-if="fleet && !editing" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. The fleet manages it; Edit map changes this site's saved copy (floor plans, import, downloads).</div>
|
|
30
|
+
<div v-else-if="fleet && editing" class="hero-notice" role="status">Editing this site's saved building map. The live HERO Fleet Core view is unchanged until you import.</div>
|
|
30
31
|
|
|
31
32
|
<RobotMapEditor
|
|
32
|
-
v-if="editing && canEditMap
|
|
33
|
+
v-if="editing && canEditMap"
|
|
33
34
|
ref="editor"
|
|
34
|
-
:map="
|
|
35
|
+
:map="saved"
|
|
36
|
+
:site="site"
|
|
37
|
+
:fleet-site="fleet"
|
|
35
38
|
:floors="floors"
|
|
36
39
|
:site-name="siteLabel"
|
|
37
40
|
:owner-robot-id="ownerRobotId"
|
|
@@ -107,7 +110,8 @@
|
|
|
107
110
|
class="hero-bmap__floor"
|
|
108
111
|
:style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
|
|
109
112
|
>
|
|
110
|
-
<
|
|
113
|
+
<img v-if="f.plan" class="hero-bmap__planimg" :src="f.plan.url" :style="f.plan.style" alt="" aria-hidden="true" draggable="false" />
|
|
114
|
+
<template v-if="!fleet && !f.plan">
|
|
111
115
|
<span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
|
|
112
116
|
<span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
|
|
113
117
|
<span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
|
|
@@ -201,6 +205,8 @@ import { computed, onBeforeUnmount, ref, toRef, watch } from "vue";
|
|
|
201
205
|
import RobotMapEditor from "./MapEditor.vue";
|
|
202
206
|
import RobotReconnecting from "./Reconnecting.vue";
|
|
203
207
|
import useRobotBuilding from "../../composables/useRobotBuilding";
|
|
208
|
+
import useFloorPlanImages from "../../composables/useFloorPlanImages";
|
|
209
|
+
import { floorPlanFor, planBox } from "../../utils/robot-floorplan";
|
|
204
210
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
205
211
|
import { openDoorNames } from "../../utils/robot-fleet-core";
|
|
206
212
|
import {
|
|
@@ -264,7 +270,7 @@ const emit = defineEmits<{
|
|
|
264
270
|
status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
|
|
265
271
|
}>();
|
|
266
272
|
|
|
267
|
-
const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
|
|
273
|
+
const { rows, map, saved, fleet, names, loading, error, reload } = useRobotBuilding(
|
|
268
274
|
toRef(props, "organization"),
|
|
269
275
|
toRef(props, "site"),
|
|
270
276
|
toRef(props, "type"),
|
|
@@ -307,9 +313,19 @@ const floorRoutes = (floorNo: number) => {
|
|
|
307
313
|
}
|
|
308
314
|
return [...seen.values()];
|
|
309
315
|
};
|
|
316
|
+
/** Each floor's saved plan image (kept privately in the map), drawn under the shapes. */
|
|
317
|
+
const { urls: planUrls } = useFloorPlanImages(saved);
|
|
318
|
+
function floorImage(floorNo: number) {
|
|
319
|
+
const p = floorPlanFor(saved.value, floorNo);
|
|
320
|
+
const url = planUrls.value[floorNo];
|
|
321
|
+
if (!p || !url) return null;
|
|
322
|
+
const b = planBox(p);
|
|
323
|
+
return { url, style: { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` } };
|
|
324
|
+
}
|
|
310
325
|
const floors3d = computed(() =>
|
|
311
326
|
floors.value.map((floorNo) => ({
|
|
312
327
|
floorNo,
|
|
328
|
+
plan: floorImage(floorNo),
|
|
313
329
|
dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
|
|
314
330
|
shapes: shapes.value.filter((s) => s.kind !== "lift" && s.floorNo === floorNo),
|
|
315
331
|
routes: props.type === "security" ? floorRoutes(floorNo) : [],
|
|
@@ -388,11 +404,11 @@ function fullscreen(el?: HTMLElement | null) {
|
|
|
388
404
|
else el?.requestFullscreen?.();
|
|
389
405
|
}
|
|
390
406
|
const editing = ref(false);
|
|
391
|
-
watch(
|
|
392
|
-
if (!can
|
|
407
|
+
watch(() => props.canEditMap, (can) => {
|
|
408
|
+
if (!can) editing.value = false;
|
|
393
409
|
});
|
|
394
410
|
/** The robot a first save is attached to (maps are stored per robot): one of this site's. */
|
|
395
|
-
const ownerRobotId = computed(() => String(
|
|
411
|
+
const ownerRobotId = computed(() => String(saved.value?.robotId ?? rows.value[0]?._id ?? ""));
|
|
396
412
|
function onSaved() {
|
|
397
413
|
reload();
|
|
398
414
|
}
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
/>
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
|
-
<div class="hero-dialog__note">{{
|
|
75
|
+
<div class="hero-dialog__note">{{ credentialsNote(credentialsEncrypted) }} Capability: {{ note }}</div>
|
|
76
76
|
<div v-if="error" class="hero-notice hero-notice--error" role="alert">{{ error }}</div>
|
|
77
77
|
<div class="hero-dialog__actions">
|
|
78
78
|
<v-btn variant="outlined" :disabled="saving" @click="emit('cancel')">Cancel</v-btn>
|
|
@@ -88,11 +88,11 @@ import {
|
|
|
88
88
|
capabilityNote,
|
|
89
89
|
credentialPatch,
|
|
90
90
|
credentialPlaceholder,
|
|
91
|
-
CREDENTIALS_NOTE,
|
|
92
91
|
robotErrorMessage,
|
|
93
92
|
typeLabel,
|
|
94
93
|
type TRobotType,
|
|
95
94
|
} from "../../utils/robot-display";
|
|
95
|
+
import { credentialsNote } from "../../utils/robot-floorplan";
|
|
96
96
|
|
|
97
97
|
const props = withDefaults(
|
|
98
98
|
defineProps<{
|
|
@@ -137,6 +137,8 @@ const saving = ref(false);
|
|
|
137
137
|
const error = ref("");
|
|
138
138
|
|
|
139
139
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
140
|
+
/** The server says it stores credentials encrypted; only then does the note say so. */
|
|
141
|
+
const credentialsEncrypted = ref(false);
|
|
140
142
|
const loadingBrands = ref(false);
|
|
141
143
|
const options = computed(() => {
|
|
142
144
|
const list = brandOptions(brands.value, type.value);
|
|
@@ -172,7 +174,9 @@ function onClient() {
|
|
|
172
174
|
onMounted(async () => {
|
|
173
175
|
loadingBrands.value = true;
|
|
174
176
|
try {
|
|
175
|
-
|
|
177
|
+
const res = await getRobotBrands();
|
|
178
|
+
brands.value = res?.items ?? [];
|
|
179
|
+
credentialsEncrypted.value = res?.credentialsEncrypted === true;
|
|
176
180
|
} catch {
|
|
177
181
|
brands.value = [];
|
|
178
182
|
} finally {
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
selects, Delete removes, Esc clears, Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z or
|
|
12
12
|
Ctrl+Y redo. Every drag is one undo step. Save checks in plain words
|
|
13
13
|
first, then writes the building map through the API (versioned).
|
|
14
|
+
Floor plan (per floor): upload / replace / remove an image kept privately
|
|
15
|
+
in the map, Set scale by measuring a known length, Fit. Building file:
|
|
16
|
+
import the HERO Fleet Core building, download the building file or a
|
|
17
|
+
route map.
|
|
14
18
|
-->
|
|
15
19
|
<div ref="root" class="hero-editor" :class="{ 'hero-editor--fs': isFs }">
|
|
16
20
|
<div class="hero-editor__bar">
|
|
@@ -59,9 +63,12 @@
|
|
|
59
63
|
@keydown="onKey"
|
|
60
64
|
@blur="keyboard = false"
|
|
61
65
|
>
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
66
|
+
<img v-if="floorPlan && planUrl" class="hero-editor__planimg" :src="planUrl" :style="planImgStyle" alt="" aria-hidden="true" draggable="false" />
|
|
67
|
+
<template v-else-if="!floorPlan">
|
|
68
|
+
<span class="hero-editor__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
|
|
69
|
+
<span class="hero-editor__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
|
|
70
|
+
<span class="hero-editor__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
|
|
71
|
+
</template>
|
|
65
72
|
<svg class="hero-editor__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
66
73
|
<template v-for="s in visible" :key="s.id">
|
|
67
74
|
<polygon v-if="s.kind === 'zone'" class="hero-editor__zone-shape" :class="{ 'hero-editor__sel': isSel(s.id) }" :points="pts(s.points)" vector-effect="non-scaling-stroke" />
|
|
@@ -72,7 +79,9 @@
|
|
|
72
79
|
</template>
|
|
73
80
|
</template>
|
|
74
81
|
<line v-if="rotateLine" class="hero-editor__rotline" :x1="rotateLine.x1" :y1="rotateLine.y1" :x2="rotateLine.x2" :y2="rotateLine.y2" vector-effect="non-scaling-stroke" />
|
|
82
|
+
<line v-if="measure.a && measure.b" class="hero-editor__measure" :x1="measure.a.x" :y1="measure.a.y" :x2="measure.b.x" :y2="measure.b.y" vector-effect="non-scaling-stroke" />
|
|
75
83
|
</svg>
|
|
84
|
+
<span v-for="(m, i) in [measure.a, measure.b].filter(Boolean)" :key="`m${i}`" class="hero-editor__mpoint" :style="{ left: `${m!.x}%`, top: `${m!.y}%` }" aria-hidden="true" />
|
|
76
85
|
<template v-for="s in visible" :key="`l-${s.id}`">
|
|
77
86
|
<div v-if="s.kind === 'zone'" class="hero-editor__label" :style="boxStyle(s)" :title="shapeTitle(s)">{{ s.name || "Unnamed zone" }}</div>
|
|
78
87
|
<div v-else-if="s.kind === 'lift'" class="hero-editor__lift" :class="{ 'hero-editor__sel': isSel(s.id) }" :style="boxStyle(s)" :title="shapeTitle(s)">Lift</div>
|
|
@@ -102,6 +111,35 @@
|
|
|
102
111
|
<span>{{ shapeTitle(s) }}</span><span class="hero-editor__pos">{{ shapeAnchor(s).x }}, {{ shapeAnchor(s).y }}</span>
|
|
103
112
|
</button>
|
|
104
113
|
<div v-if="!visible.length" class="hero-muted">Nothing drawn yet. Pick a tool and click the plan.</div>
|
|
114
|
+
|
|
115
|
+
<div class="hero-aside__title hero-editor__asidetitle">Floor plan · {{ floorName(floor) }}</div>
|
|
116
|
+
<div class="hero-editor__actions">
|
|
117
|
+
<button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="pickPlan">{{ planBusy ? "Saving…" : floorPlan ? "Replace plan" : "Upload plan" }}</button>
|
|
118
|
+
<template v-if="floorPlan">
|
|
119
|
+
<button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" :aria-pressed="measuring" @click="startMeasure">Set scale</button>
|
|
120
|
+
<button type="button" class="hero-editor__act" :disabled="planLocked" :title="planLockText" @click="fitPlanNow">Fit</button>
|
|
121
|
+
<button type="button" class="hero-editor__act hero-editor__act--del" :disabled="planLocked" :title="planLockText" @click="removePlanNow">Remove plan</button>
|
|
122
|
+
</template>
|
|
123
|
+
</div>
|
|
124
|
+
<input ref="planInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPlanFile" />
|
|
125
|
+
<div v-if="planLockText && !planBusy" class="hero-muted">{{ planLockText }}</div>
|
|
126
|
+
<div v-if="floorPlan" class="hero-muted">{{ scaleText(floorPlan) }}</div>
|
|
127
|
+
<div v-if="measuring" class="hero-editor__measurebox" role="group" aria-label="Set scale">
|
|
128
|
+
<span>{{ measure.b ? "Real length between the two points, in metres:" : measure.a ? "Click the other end." : "Click one end of a length you know on the plan (a wall, a doorway)." }}</span>
|
|
129
|
+
<div class="hero-editor__actions">
|
|
130
|
+
<input v-if="measure.b" v-model.number="measureM" type="number" min="0.01" step="0.01" class="hero-input hero-editor__measurein" aria-label="Real length in metres" placeholder="e.g. 12.5" @keydown.enter.prevent="applyMeasure" />
|
|
131
|
+
<button v-if="measure.b" type="button" class="hero-editor__act" @click="applyMeasure">Apply</button>
|
|
132
|
+
<button type="button" class="hero-editor__act" @click="cancelMeasure">Cancel</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="hero-aside__title hero-editor__asidetitle">Building file · HERO Fleet Core</div>
|
|
137
|
+
<div v-if="map?.rmf" class="hero-muted">{{ rmfLine(map.rmf) }}</div>
|
|
138
|
+
<div class="hero-editor__actions">
|
|
139
|
+
<button v-if="fleetSite" type="button" class="hero-editor__act" :disabled="planBusy || saving || state.dirty" :title="state.dirty ? 'Save your drawing first.' : ''" @click="importFleet">Import from HERO Fleet Core</button>
|
|
140
|
+
<button type="button" class="hero-editor__act" :disabled="!map?._id" @click="download('building')">Download building file</button>
|
|
141
|
+
<button v-for="g in graphs" :key="`g${g}`" type="button" class="hero-editor__act" @click="download('nav', g)">Download route map{{ graphs.length > 1 ? ` ${g}` : "" }}</button>
|
|
142
|
+
</div>
|
|
105
143
|
<div class="hero-editor__note">The plan image comes from the robot; everything here is drawn on top of it. Keyboard: Tab to the plan; arrow keys move the cursor (or nudge what is selected, Shift for 1 m); Enter places or selects; Delete removes; Esc clears the selection.</div>
|
|
106
144
|
</aside>
|
|
107
145
|
</div>
|
|
@@ -111,6 +149,23 @@
|
|
|
111
149
|
<script setup lang="ts">
|
|
112
150
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
113
151
|
import { floorName, MAP_TOOLS } from "../../utils/robot-display";
|
|
152
|
+
import useFloorPlanImages from "../../composables/useFloorPlanImages";
|
|
153
|
+
import {
|
|
154
|
+
compressSize,
|
|
155
|
+
exportFileName,
|
|
156
|
+
fitPlan,
|
|
157
|
+
fleetImportBody,
|
|
158
|
+
floorPlanFileProblem,
|
|
159
|
+
floorPlanFor,
|
|
160
|
+
FLOOR_PLAN_MAX_BYTES,
|
|
161
|
+
planBox,
|
|
162
|
+
replacePlacement,
|
|
163
|
+
rescaleByMeasure,
|
|
164
|
+
rmfGraphs,
|
|
165
|
+
rmfLine,
|
|
166
|
+
scaleText,
|
|
167
|
+
type TPlanPoint,
|
|
168
|
+
} from "../../utils/robot-floorplan";
|
|
114
169
|
import {
|
|
115
170
|
DEFAULT_GRID_M,
|
|
116
171
|
doorSwing,
|
|
@@ -155,11 +210,15 @@ const props = withDefaults(
|
|
|
155
210
|
ownerRobotId?: string;
|
|
156
211
|
/** The floor to open on. */
|
|
157
212
|
startFloor?: number | null;
|
|
213
|
+
/** The site, for the HERO Fleet Core import. */
|
|
214
|
+
site?: string;
|
|
215
|
+
/** The site has HERO Fleet Core robots: offer "Import from HERO Fleet Core". */
|
|
216
|
+
fleetSite?: boolean;
|
|
158
217
|
}>(),
|
|
159
|
-
{ map: null, siteName: "", ownerRobotId: "", startFloor: null },
|
|
218
|
+
{ map: null, siteName: "", ownerRobotId: "", startFloor: null, site: "", fleetSite: false },
|
|
160
219
|
);
|
|
161
220
|
const emit = defineEmits<{ saved: [] }>();
|
|
162
|
-
const { createBuildingMap, updateBuildingMap } = useRobots();
|
|
221
|
+
const { createBuildingMap, updateBuildingMap, saveFloorPlan, removeFloorPlan, exportBuildingMap, getFleetCoreBuildingData } = useRobots();
|
|
163
222
|
|
|
164
223
|
const state = ref<TEditorState>(emptyEditor(shapesFromMap(props.map)));
|
|
165
224
|
const dispatch = (a: TEditorAction) => (state.value = editorReduce(state.value, a));
|
|
@@ -240,6 +299,8 @@ function onPlanDown(e: PointerEvent) {
|
|
|
240
299
|
plan.value?.focus({ preventScroll: true });
|
|
241
300
|
keyboard.value = false;
|
|
242
301
|
const p = at(e);
|
|
302
|
+
// Set scale: the two clicks are the measured length, not drawing.
|
|
303
|
+
if (measuring.value) return void measureClick(p);
|
|
243
304
|
const target = hitShape(visible.value, p, tolerance(e.pointerType));
|
|
244
305
|
const t = tool.value;
|
|
245
306
|
if (t === "delete") {
|
|
@@ -464,6 +525,182 @@ async function save() {
|
|
|
464
525
|
}
|
|
465
526
|
}
|
|
466
527
|
|
|
528
|
+
/* ---- floor plan: stored in the map (not the public file store), per floor ---- */
|
|
529
|
+
const { urls: planUrls } = useFloorPlanImages(computed(() => props.map));
|
|
530
|
+
const floorPlan = computed(() => floorPlanFor(props.map, floor.value));
|
|
531
|
+
// (`plan` is the plan ELEMENT, for pointer maths; this floor's image record is `floorPlan`.)
|
|
532
|
+
const planUrl = computed(() => planUrls.value[floor.value] ?? "");
|
|
533
|
+
const planImgStyle = computed(() => {
|
|
534
|
+
const b = planBox(floorPlan.value!);
|
|
535
|
+
return { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%` };
|
|
536
|
+
});
|
|
537
|
+
const planBusy = ref(false);
|
|
538
|
+
const planLockText = computed(() =>
|
|
539
|
+
!props.map?._id ? "Save the map once, then add a floor plan." : state.value.dirty ? "Save your drawing first, then change the floor plan." : "",
|
|
540
|
+
);
|
|
541
|
+
const planLocked = computed(() => planBusy.value || saving.value || Boolean(planLockText.value));
|
|
542
|
+
const planInput = ref<HTMLInputElement | null>(null);
|
|
543
|
+
const note = (text: string, ok = false) => dispatch({ type: "notice", text, ok });
|
|
544
|
+
const errText = (e: any, fallback: string) => e?.data?.message || e?.message || fallback;
|
|
545
|
+
|
|
546
|
+
function pickPlan() {
|
|
547
|
+
if (!planLocked.value) planInput.value?.click();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const blobToDataUrl = (b: Blob) =>
|
|
551
|
+
new Promise<string>((ok, no) => {
|
|
552
|
+
const r = new FileReader();
|
|
553
|
+
r.onload = () => ok(String(r.result));
|
|
554
|
+
r.onerror = () => no(r.error);
|
|
555
|
+
r.readAsDataURL(b);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/** Shrink in the browser until it fits the 1 MB the server keeps: WebP (JPEG where WebP cannot be written), then smaller. */
|
|
559
|
+
async function compressPlan(file: File) {
|
|
560
|
+
const bmp = await createImageBitmap(file);
|
|
561
|
+
let { width, height } = compressSize(bmp.width, bmp.height);
|
|
562
|
+
const canvas = document.createElement("canvas");
|
|
563
|
+
try {
|
|
564
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
565
|
+
canvas.width = width;
|
|
566
|
+
canvas.height = height;
|
|
567
|
+
const ctx = canvas.getContext("2d")!;
|
|
568
|
+
ctx.fillStyle = "#fff";
|
|
569
|
+
ctx.fillRect(0, 0, width, height);
|
|
570
|
+
ctx.drawImage(bmp, 0, 0, width, height);
|
|
571
|
+
for (const q of [0.85, 0.7, 0.55]) {
|
|
572
|
+
let out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/webp", q));
|
|
573
|
+
if (out?.type !== "image/webp") out = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", q));
|
|
574
|
+
if (out && out.size <= FLOOR_PLAN_MAX_BYTES) return { image: await blobToDataUrl(out), widthPx: width, heightPx: height };
|
|
575
|
+
}
|
|
576
|
+
width = Math.max(1, Math.round(width * 0.8));
|
|
577
|
+
height = Math.max(1, Math.round(height * 0.8));
|
|
578
|
+
}
|
|
579
|
+
} finally {
|
|
580
|
+
bmp.close?.();
|
|
581
|
+
}
|
|
582
|
+
throw new Error("This floor plan cannot be made small enough. Try a simpler or smaller image.");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function onPlanFile(e: Event) {
|
|
586
|
+
const input = e.target as HTMLInputElement;
|
|
587
|
+
const file = input.files?.[0];
|
|
588
|
+
input.value = "";
|
|
589
|
+
if (!file || !props.map?._id) return;
|
|
590
|
+
const problem = floorPlanFileProblem(file);
|
|
591
|
+
if (problem) return void note(problem);
|
|
592
|
+
planBusy.value = true;
|
|
593
|
+
try {
|
|
594
|
+
const img = await compressPlan(file);
|
|
595
|
+
await saveFloorPlan(String(props.map._id), floor.value, {
|
|
596
|
+
version: Number(props.map.version) || 1,
|
|
597
|
+
...img,
|
|
598
|
+
...replacePlacement(floorPlan.value, img.widthPx, img.heightPx),
|
|
599
|
+
});
|
|
600
|
+
note(`Floor plan saved for ${floorName(floor.value)}. Use Set scale to measure a known length.`, true);
|
|
601
|
+
emit("saved");
|
|
602
|
+
} catch (err) {
|
|
603
|
+
note(errText(err, "Could not save the floor plan."));
|
|
604
|
+
} finally {
|
|
605
|
+
planBusy.value = false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function placePlan(next: { metersPerPixel: number; tx: number; ty: number }, done: string) {
|
|
610
|
+
const p = floorPlan.value;
|
|
611
|
+
if (!p || !props.map?._id) return;
|
|
612
|
+
planBusy.value = true;
|
|
613
|
+
try {
|
|
614
|
+
await saveFloorPlan(String(props.map._id), floor.value, { version: Number(props.map.version) || 1, widthPx: p.widthPx, heightPx: p.heightPx, ...next });
|
|
615
|
+
note(done, true);
|
|
616
|
+
emit("saved");
|
|
617
|
+
} catch (err) {
|
|
618
|
+
note(errText(err, "Could not save the floor plan scale."));
|
|
619
|
+
} finally {
|
|
620
|
+
planBusy.value = false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const fitPlanNow = () => floorPlan.value && placePlan(fitPlan(floorPlan.value.widthPx, floorPlan.value.heightPx), "Floor plan fitted to the drawing area.");
|
|
625
|
+
|
|
626
|
+
async function removePlanNow() {
|
|
627
|
+
if (!props.map?._id || !floorPlan.value) return;
|
|
628
|
+
if (!window.confirm(`Remove the floor plan of ${floorName(floor.value)}? Walls, doors and zones stay.`)) return;
|
|
629
|
+
planBusy.value = true;
|
|
630
|
+
try {
|
|
631
|
+
await removeFloorPlan(String(props.map._id), floor.value, Number(props.map.version) || 1);
|
|
632
|
+
note(`Floor plan removed from ${floorName(floor.value)}.`, true);
|
|
633
|
+
emit("saved");
|
|
634
|
+
} catch (err) {
|
|
635
|
+
note(errText(err, "Could not remove the floor plan."));
|
|
636
|
+
} finally {
|
|
637
|
+
planBusy.value = false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/* ---- Set scale: two clicks on a known length, then its real length ---- */
|
|
642
|
+
const measuring = ref(false);
|
|
643
|
+
const measure = ref<{ a: TPlanPoint | null; b: TPlanPoint | null }>({ a: null, b: null });
|
|
644
|
+
const measureM = ref<number | null>(null);
|
|
645
|
+
function startMeasure() {
|
|
646
|
+
measuring.value = !measuring.value;
|
|
647
|
+
measure.value = { a: null, b: null };
|
|
648
|
+
measureM.value = null;
|
|
649
|
+
}
|
|
650
|
+
function measureClick(p: TPlanPoint) {
|
|
651
|
+
measure.value = !measure.value.a || measure.value.b ? { a: p, b: null } : { ...measure.value, b: p };
|
|
652
|
+
}
|
|
653
|
+
function cancelMeasure() {
|
|
654
|
+
measuring.value = false;
|
|
655
|
+
measure.value = { a: null, b: null };
|
|
656
|
+
}
|
|
657
|
+
async function applyMeasure() {
|
|
658
|
+
const { a, b } = measure.value;
|
|
659
|
+
if (!a || !b || !floorPlan.value) return;
|
|
660
|
+
const r = rescaleByMeasure(floorPlan.value, a, b, Number(measureM.value));
|
|
661
|
+
if ("error" in r) return void note(r.error);
|
|
662
|
+
cancelMeasure();
|
|
663
|
+
await placePlan(r.plan, `Scale set: ${scaleText(r.plan)}.`);
|
|
664
|
+
}
|
|
665
|
+
watch(floor, cancelMeasure);
|
|
666
|
+
|
|
667
|
+
/* ---- building file: HERO Fleet Core import, downloads ---- */
|
|
668
|
+
const graphs = computed(() => rmfGraphs(props.map?.rmf));
|
|
669
|
+
|
|
670
|
+
async function importFleet() {
|
|
671
|
+
if (!props.site) return;
|
|
672
|
+
if (props.map?._id && !window.confirm("Replace this building's walls, doors, lifts and routes with the HERO Fleet Core building? Floor plans stay.")) return;
|
|
673
|
+
if (!props.map?._id && !props.ownerRobotId) return void note("Can't import yet: this site has no robot to keep the map with.");
|
|
674
|
+
planBusy.value = true;
|
|
675
|
+
try {
|
|
676
|
+
const res = await getFleetCoreBuildingData(props.site);
|
|
677
|
+
const body = fleetImportBody(props.map, res?.rmf ?? {}, props.siteName);
|
|
678
|
+
if (props.map?._id) await updateBuildingMap(String(props.map._id), { ...body, version: Number(props.map.version) || 1 });
|
|
679
|
+
else await createBuildingMap({ ...body, robotId: props.ownerRobotId });
|
|
680
|
+
note(`Imported from HERO Fleet Core: ${rmfLine(res?.rmf)}.`, true);
|
|
681
|
+
emit("saved");
|
|
682
|
+
} catch (err) {
|
|
683
|
+
note(errText(err, "Could not import the HERO Fleet Core building."));
|
|
684
|
+
} finally {
|
|
685
|
+
planBusy.value = false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function download(format: "building" | "nav", graph = 0) {
|
|
690
|
+
if (!props.map?._id) return;
|
|
691
|
+
try {
|
|
692
|
+
const blob = await exportBuildingMap(String(props.map._id), format, format === "nav" ? graph : undefined);
|
|
693
|
+
const url = URL.createObjectURL(blob);
|
|
694
|
+
const a = document.createElement("a");
|
|
695
|
+
a.href = url;
|
|
696
|
+
a.download = exportFileName(String(props.map.name ?? ""), format, graph);
|
|
697
|
+
a.click();
|
|
698
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
699
|
+
} catch (err) {
|
|
700
|
+
note(errText(err, "Could not download the file."));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
467
704
|
/* ---- drawing helpers + full screen ---- */
|
|
468
705
|
const pts = (list: Array<{ x: number; y: number }>) => list.map((p) => `${p.x},${p.y}`).join(" ");
|
|
469
706
|
function boxStyle(s: TEditorShape) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The floor-plan images of one building map, as object URLs by floor.
|
|
5
|
+
*
|
|
6
|
+
* The images sit behind the session (stored in the map record, not the public
|
|
7
|
+
* file store), so each is fetched with the session header and shown from a
|
|
8
|
+
* local object URL. A plan is fetched again only when its `updatedAt` changes;
|
|
9
|
+
* old URLs are released.
|
|
10
|
+
*/
|
|
11
|
+
export default function useFloorPlanImages(map: Ref<Record<string, any> | null | undefined>) {
|
|
12
|
+
const { getFloorPlanImage } = useRobots();
|
|
13
|
+
const urls = ref<Record<number, string>>({});
|
|
14
|
+
const keys = new Map<number, string>();
|
|
15
|
+
|
|
16
|
+
function release(floorNo: number) {
|
|
17
|
+
const u = urls.value[floorNo];
|
|
18
|
+
if (u) URL.revokeObjectURL(u);
|
|
19
|
+
const next = { ...urls.value };
|
|
20
|
+
delete next[floorNo];
|
|
21
|
+
urls.value = next;
|
|
22
|
+
keys.delete(floorNo);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
watch(
|
|
26
|
+
() => [map.value?._id, (map.value?.floorPlans ?? []).map((p: any) => `${p.floorNo}:${p.updatedAt}`).join("|")],
|
|
27
|
+
async () => {
|
|
28
|
+
const id = map.value?._id ? String(map.value._id) : "";
|
|
29
|
+
const plans: Array<Record<string, any>> = id ? map.value?.floorPlans ?? [] : [];
|
|
30
|
+
const wanted = new Map(plans.map((p) => [Number(p.floorNo), `${id}:${p.updatedAt}`]));
|
|
31
|
+
for (const f of [...keys.keys()]) if (wanted.get(f) !== keys.get(f)) release(f);
|
|
32
|
+
await Promise.all(
|
|
33
|
+
[...wanted].map(async ([floorNo, key]) => {
|
|
34
|
+
if (keys.get(floorNo) === key) return;
|
|
35
|
+
keys.set(floorNo, key);
|
|
36
|
+
try {
|
|
37
|
+
const blob = await getFloorPlanImage(id, floorNo);
|
|
38
|
+
if (keys.get(floorNo) !== key) return;
|
|
39
|
+
urls.value = { ...urls.value, [floorNo]: URL.createObjectURL(blob) };
|
|
40
|
+
} catch {
|
|
41
|
+
// No image drawn for that floor; the next change of the map tries again.
|
|
42
|
+
keys.delete(floorNo);
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
{ immediate: true },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
onBeforeUnmount(() => [...keys.keys()].forEach(release));
|
|
51
|
+
return { urls };
|
|
52
|
+
}
|
|
@@ -24,6 +24,12 @@ export default function useRobotBuilding(
|
|
|
24
24
|
/** Every robot at the site this app may see, both types where the app shows both. */
|
|
25
25
|
const all = ref<Array<Record<string, any>>>([]);
|
|
26
26
|
const map = ref<Record<string, any> | null>(null);
|
|
27
|
+
/**
|
|
28
|
+
* The site's SAVED map - what the editor edits (drawn walls, floor plans, an
|
|
29
|
+
* imported HERO Fleet Core building). At a fleet site `map` is the live fleet
|
|
30
|
+
* building instead, and this is the stored copy beside it.
|
|
31
|
+
*/
|
|
32
|
+
const saved = ref<Record<string, any> | null>(null);
|
|
27
33
|
const names = ref({ siteName: "", clientName: "" });
|
|
28
34
|
const loading = ref(true);
|
|
29
35
|
const error = ref("");
|
|
@@ -31,13 +37,17 @@ export default function useRobotBuilding(
|
|
|
31
37
|
/** The robots of the type on screen (scope isolation: the other type is never drawn). */
|
|
32
38
|
const rows = computed(() => all.value.filter((r) => r.type === type.value));
|
|
33
39
|
|
|
34
|
-
async function
|
|
40
|
+
async function loadSaved() {
|
|
41
|
+
const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
|
|
42
|
+
return newest ? await getBuildingMap(String(newest._id)) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadMap(stored: Record<string, any> | null) {
|
|
35
46
|
if (hasFleetRobots(all.value)) {
|
|
36
47
|
const building = await getFleetCoreBuilding(site.value).catch(() => null);
|
|
37
48
|
if (building) return building;
|
|
38
49
|
}
|
|
39
|
-
|
|
40
|
-
return newest ? await getBuildingMap(String(newest._id)) : null;
|
|
50
|
+
return stored;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
/** Live doors and lifts of the fleet building; a failed refresh keeps the last reading. */
|
|
@@ -63,13 +73,16 @@ export default function useRobotBuilding(
|
|
|
63
73
|
if (mine !== run) return;
|
|
64
74
|
all.value = fleet?.items ?? [];
|
|
65
75
|
names.value = { siteName: summary?.site?.name ?? "", clientName: summary?.client?.name ?? "" };
|
|
66
|
-
const
|
|
76
|
+
const stored = await loadSaved();
|
|
77
|
+
const found = await loadMap(stored);
|
|
67
78
|
if (mine !== run) return;
|
|
79
|
+
saved.value = stored;
|
|
68
80
|
map.value = found;
|
|
69
81
|
} catch (e: any) {
|
|
70
82
|
if (mine !== run) return;
|
|
71
83
|
all.value = [];
|
|
72
84
|
map.value = null;
|
|
85
|
+
saved.value = null;
|
|
73
86
|
error.value = e?.data?.message || "Could not load this building.";
|
|
74
87
|
} finally {
|
|
75
88
|
if (mine === run) loading.value = false;
|
|
@@ -78,5 +91,5 @@ export default function useRobotBuilding(
|
|
|
78
91
|
|
|
79
92
|
watch([organization, site], load, { immediate: true });
|
|
80
93
|
|
|
81
|
-
return { all, rows, map, fleet, names, loading, error, reload: load };
|
|
94
|
+
return { all, rows, map, saved, fleet, names, loading, error, reload: load };
|
|
82
95
|
}
|
package/composables/useRobots.ts
CHANGED
|
@@ -364,6 +364,57 @@ export default function useRobots() {
|
|
|
364
364
|
});
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The HERO Fleet Core building in stored form (lanes, waypoints, door types,
|
|
369
|
+
* lift cabin doors) for the editor's import. Nothing is saved until the
|
|
370
|
+
* editor saves it.
|
|
371
|
+
*/
|
|
372
|
+
function getFleetCoreBuildingData(site: string) {
|
|
373
|
+
return useNuxtApp().$api<Record<string, any>>("/api/robots/fleet-core/building", {
|
|
374
|
+
method: "GET",
|
|
375
|
+
query: { site, appSlug, format: "rmf" },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The map as a building file or one route graph, as a Blob to download. */
|
|
380
|
+
function exportBuildingMap(mapId: string, format: "building" | "nav", graph?: number) {
|
|
381
|
+
return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/export`, {
|
|
382
|
+
method: "GET",
|
|
383
|
+
query: { format, graph, appSlug },
|
|
384
|
+
responseType: "blob",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* One floor's plan image. A Blob, not a URL: the image sits behind the
|
|
390
|
+
* session like the map itself, and an <img src> cannot carry the header.
|
|
391
|
+
*/
|
|
392
|
+
function getFloorPlanImage(mapId: string, floorNo: number) {
|
|
393
|
+
return useNuxtApp().$api<Blob>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
|
|
394
|
+
method: "GET",
|
|
395
|
+
responseType: "blob",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Add / replace (with `image`) or re-scale (without) one floor's plan. Versioned like a map save. */
|
|
400
|
+
function saveFloorPlan(
|
|
401
|
+
mapId: string,
|
|
402
|
+
floorNo: number,
|
|
403
|
+
body: { version: number; image?: string; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number },
|
|
404
|
+
) {
|
|
405
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
|
|
406
|
+
method: "PUT",
|
|
407
|
+
body,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function removeFloorPlan(mapId: string, floorNo: number, version: number) {
|
|
412
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}/floor-plans/${floorNo}`, {
|
|
413
|
+
method: "DELETE",
|
|
414
|
+
query: { version },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
367
418
|
function deleteBuildingMap(mapId: string) {
|
|
368
419
|
return useNuxtApp().$api<Record<string, any>>(`/api/robots/building-maps/${mapId}`, {
|
|
369
420
|
method: "DELETE",
|
|
@@ -414,6 +465,11 @@ export default function useRobots() {
|
|
|
414
465
|
createBuildingMap,
|
|
415
466
|
updateBuildingMap,
|
|
416
467
|
deleteBuildingMap,
|
|
468
|
+
getFleetCoreBuildingData,
|
|
469
|
+
exportBuildingMap,
|
|
470
|
+
getFloorPlanImage,
|
|
471
|
+
saveFloorPlan,
|
|
472
|
+
removeFloorPlan,
|
|
417
473
|
getLiftQueue,
|
|
418
474
|
requestLift,
|
|
419
475
|
releaseLiftSession,
|
|
@@ -9,6 +9,8 @@ export default function useVerification() {
|
|
|
9
9
|
siteId = "",
|
|
10
10
|
orgId = "",
|
|
11
11
|
limit = 20,
|
|
12
|
+
// "seven365": only the invitations Seven365 sent (the console's lists).
|
|
13
|
+
origin = "",
|
|
12
14
|
} = {}): Promise<{
|
|
13
15
|
items: TMiniVerification[];
|
|
14
16
|
pages: number;
|
|
@@ -16,7 +18,11 @@ export default function useVerification() {
|
|
|
16
18
|
}> {
|
|
17
19
|
return useNuxtApp().$api("/api/verifications", {
|
|
18
20
|
method: "GET",
|
|
19
|
-
query: {
|
|
21
|
+
query: {
|
|
22
|
+
status, search, page, type, email, app, siteId, orgId, limit,
|
|
23
|
+
// Sent only when asked for: an older API refuses a key it does not know.
|
|
24
|
+
...(origin && { origin }),
|
|
25
|
+
},
|
|
20
26
|
});
|
|
21
27
|
}
|
|
22
28
|
|
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.47.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.",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floor plans and HERO Fleet Core building files in the map editor - the pure
|
|
3
|
+
* rules (placement, scale by measurement, import body, file names). The
|
|
4
|
+
* components only draw what these return.
|
|
5
|
+
*
|
|
6
|
+
* A floor plan is an image stored privately in the building map (never the
|
|
7
|
+
* public file store) and placed in the HERO plan frame (40 x 26 m, the same
|
|
8
|
+
* frame every drawn wall, door and lift uses): its top-left corner at
|
|
9
|
+
* (`tx`, `ty`) metres, `metersPerPixel` from a measured distance.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The HERO plan frame - `utils/robot-building.ts` PLAN_W_M / PLAN_H_M (decision 30). */
|
|
13
|
+
export const FRAME_W_M = 40;
|
|
14
|
+
export const FRAME_H_M = 26;
|
|
15
|
+
/** What the API stores per image, after compression here. */
|
|
16
|
+
export const FLOOR_PLAN_MAX_BYTES = 1_000_000;
|
|
17
|
+
/** Longest side the browser compresses a plan to. */
|
|
18
|
+
export const FLOOR_PLAN_MAX_PX = 2400;
|
|
19
|
+
export const FLOOR_PLAN_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
20
|
+
|
|
21
|
+
export type TFloorPlan = { floorNo: number; widthPx: number; heightPx: number; metersPerPixel: number; tx: number; ty: number; updatedAt?: string };
|
|
22
|
+
export type TPlanPoint = { x: number; y: number };
|
|
23
|
+
|
|
24
|
+
const r6 = (v: number) => Math.round(v * 1e6) / 1e6;
|
|
25
|
+
|
|
26
|
+
export const floorPlanFor = (map: Record<string, any> | null | undefined, floorNo: number): TFloorPlan | null =>
|
|
27
|
+
((map?.floorPlans ?? []) as TFloorPlan[]).find((p) => p.floorNo === floorNo) ?? null;
|
|
28
|
+
|
|
29
|
+
/** A new plan fitted inside the frame, top-left aligned, aspect kept. */
|
|
30
|
+
export function fitPlan(widthPx: number, heightPx: number): Pick<TFloorPlan, "metersPerPixel" | "tx" | "ty"> {
|
|
31
|
+
return { metersPerPixel: r6(Math.min(FRAME_W_M / widthPx, FRAME_H_M / heightPx)), tx: 0, ty: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A replacement image keeps the building's real size and place: same width in
|
|
36
|
+
* metres, same corner. (A re-drawn plan of the same floor should not need
|
|
37
|
+
* measuring again.)
|
|
38
|
+
*/
|
|
39
|
+
export function replacePlacement(old: TFloorPlan | null, widthPx: number, heightPx: number) {
|
|
40
|
+
if (!old) return fitPlan(widthPx, heightPx);
|
|
41
|
+
return { metersPerPixel: r6((old.widthPx * old.metersPerPixel) / widthPx), tx: old.tx, ty: old.ty };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where the image sits over the plan, as percentages of the frame (the editor draws everything in %). */
|
|
45
|
+
export function planBox(p: Pick<TFloorPlan, "widthPx" | "heightPx" | "metersPerPixel" | "tx" | "ty">) {
|
|
46
|
+
return {
|
|
47
|
+
left: (p.tx / FRAME_W_M) * 100,
|
|
48
|
+
top: (p.ty / FRAME_H_M) * 100,
|
|
49
|
+
width: ((p.widthPx * p.metersPerPixel) / FRAME_W_M) * 100,
|
|
50
|
+
height: ((p.heightPx * p.metersPerPixel) / FRAME_H_M) * 100,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Metres between two points given in plan percentages. */
|
|
55
|
+
export function planDistanceM(a: TPlanPoint, b: TPlanPoint) {
|
|
56
|
+
return Math.hypot(((b.x - a.x) / 100) * FRAME_W_M, ((b.y - a.y) / 100) * FRAME_H_M);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scale by measurement: two points the person clicked on the plan (in plan %)
|
|
61
|
+
* and the real distance between them. The image grows or shrinks about its
|
|
62
|
+
* top-left corner. Null (with a reason) when it cannot be done.
|
|
63
|
+
*/
|
|
64
|
+
export function rescaleByMeasure(p: TFloorPlan, a: TPlanPoint, b: TPlanPoint, realM: number): { plan: TFloorPlan } | { error: string } {
|
|
65
|
+
const drawn = planDistanceM(a, b);
|
|
66
|
+
if (!(realM > 0) || !Number.isFinite(realM)) return { error: "Enter the real distance in metres (more than 0)." };
|
|
67
|
+
if (drawn < 0.05) return { error: "Pick two points further apart." };
|
|
68
|
+
const metersPerPixel = r6(p.metersPerPixel * (realM / drawn));
|
|
69
|
+
if (metersPerPixel < 0.0001 || metersPerPixel > 10) return { error: "That distance gives an impossible scale. Check the two points and the number." };
|
|
70
|
+
return { plan: { ...p, metersPerPixel } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** "1 px = 4.2 cm · plan 33.6 x 25.2 m" */
|
|
74
|
+
export function scaleText(p: TFloorPlan | null) {
|
|
75
|
+
if (!p) return "";
|
|
76
|
+
const cm = p.metersPerPixel * 100;
|
|
77
|
+
const w = p.widthPx * p.metersPerPixel;
|
|
78
|
+
const h = p.heightPx * p.metersPerPixel;
|
|
79
|
+
const big = w > FRAME_W_M + 0.05 || h > FRAME_H_M + 0.05;
|
|
80
|
+
return `1 px = ${cm >= 10 ? cm.toFixed(0) : cm.toFixed(1)} cm · plan ${w.toFixed(1)} × ${h.toFixed(1)} m${big ? " (larger than the 40 × 26 m drawing area - the edge is cut off)" : ""}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Plain-language reason a picked file cannot be used, or null. */
|
|
84
|
+
export function floorPlanFileProblem(file: { type: string; size: number }) {
|
|
85
|
+
if (!FLOOR_PLAN_TYPES.includes(file.type)) return "Choose a PNG, JPEG or WebP image of the floor plan.";
|
|
86
|
+
if (file.size > 40_000_000) return "That image is too large to open (40 MB at most).";
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The size to compress to: longest side at most FLOOR_PLAN_MAX_PX, never upscaled. */
|
|
91
|
+
export function compressSize(widthPx: number, heightPx: number, maxPx = FLOOR_PLAN_MAX_PX) {
|
|
92
|
+
const s = Math.min(1, maxPx / Math.max(widthPx, heightPx));
|
|
93
|
+
return { width: Math.max(1, Math.round(widthPx * s)), height: Math.max(1, Math.round(heightPx * s)) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The save body for an import from HERO Fleet Core: the fleet's building is the
|
|
98
|
+
* truth (`rmf`) and the server derives the drawn walls, doors and lifts from
|
|
99
|
+
* it. Floor plans and the map's name stay.
|
|
100
|
+
*/
|
|
101
|
+
export function fleetImportBody(map: Record<string, any> | null | undefined, rmf: Record<string, any>, siteName = "") {
|
|
102
|
+
return { name: String(map?.name || `${siteName || "Site"} building`).slice(0, 60), levels: [], doors: [], lifts: [], rmf };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** "Tower B" -> "tower-b.building.yaml" / "tower-b-nav-0.yaml" - the names the API sends too. */
|
|
106
|
+
export function exportFileName(name: string, format: "building" | "nav", graph = 0) {
|
|
107
|
+
const slug = (name || "building").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "building";
|
|
108
|
+
return format === "building" ? `${slug}.building.yaml` : `${slug}-nav-${graph}.yaml`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** "3 floors · 42 lanes · 18 named points · 19 doors · 2 lifts" for a stored rmf part. */
|
|
112
|
+
export function rmfLine(rmf: Record<string, any> | null | undefined) {
|
|
113
|
+
if (!rmf?.levels) return "";
|
|
114
|
+
const levels = rmf.levels as Array<Record<string, any>>;
|
|
115
|
+
const n = (f: (l: Record<string, any>) => number) => levels.reduce((s, l) => s + f(l), 0);
|
|
116
|
+
const parts = [
|
|
117
|
+
`${levels.length} floor${levels.length === 1 ? "" : "s"}`,
|
|
118
|
+
`${n((l) => l.lanes?.length ?? 0)} lanes`,
|
|
119
|
+
`${n((l) => (l.vertices ?? []).filter((v: any) => v.name).length)} named points`,
|
|
120
|
+
`${n((l) => l.doors?.length ?? 0)} doors`,
|
|
121
|
+
`${rmf.lifts?.length ?? 0} lifts`,
|
|
122
|
+
];
|
|
123
|
+
return parts.join(" · ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The route graphs a download can offer. */
|
|
127
|
+
export const rmfGraphs = (rmf: Record<string, any> | null | undefined): number[] =>
|
|
128
|
+
((rmf?.graphs ?? []) as Array<{ idx: number }>).map((g) => g.idx).sort((a, b) => a - b);
|
|
129
|
+
|
|
130
|
+
/** The Add robot dialog's credentials note - it says "encrypted" only when this server really encrypts. */
|
|
131
|
+
export const credentialsNote = (encrypted: boolean) =>
|
|
132
|
+
encrypted ? "Credentials are stored encrypted and never shown again after saving." : "Credentials are never shown again after saving.";
|