@7365admin1/layer-common 4.36.0 → 4.37.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ccdca4: HERO building map and lift queue show the HERO Fleet Core building at sites with fleet robots: walls, doors (open doors highlighted) and lift shafts from the fleet, refreshed every 5 s, read-only (no Edit map); the lift queue shows each fleet lift's car floor and the robot holding it. The map editor's duplicate "Full screen" button is removed - the page header's one full-screens the viewer or the editor.
8
+
3
9
  ## 4.36.0
4
10
 
5
11
  ### Minor Changes
@@ -1424,11 +1430,11 @@
1424
1430
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1425
1431
  own rule from two endpoints the console already calls, unprojected:
1426
1432
 
1427
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1428
- GET /api/roles/id/:role that membership's role document
1433
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1434
+ GET /api/roles/id/:role that membership's role document
1429
1435
 
1430
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1431
- staff = member.type === "admin" && role.type === "admin"
1436
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1437
+ staff = member.type === "admin" && role.type === "admin"
1432
1438
 
1433
1439
  `role.default` is the marker because it is the only property of a platform
1434
1440
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -951,6 +951,7 @@
951
951
  .hero-bmap__wall { stroke: var(--hero-text); stroke-width: 3; opacity: 0.8; }
952
952
  .hero-bmap__zone { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
953
953
  .hero-bmap__doorline { stroke: var(--hero-accent); stroke-width: 3; }
954
+ .hero-bmap__doorline--open { stroke: rgb(var(--v-theme-success)); stroke-dasharray: 2 2; }
954
955
  .hero-bmap__anchor { position: absolute; width: 0; height: 0; transform-style: preserve-3d; }
955
956
  .hero-bmap__routetag { position: absolute; left: -6px; top: -30px; transform-origin: 0 0; white-space: nowrap; font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 800; padding: 1px 6px; background: var(--hero-accent); color: #fff; }
956
957
  .hero-bmap__cp { position: absolute; left: -6px; top: -6px; width: 12px; height: 12px; box-sizing: border-box; border: 2px solid var(--hero-accent); background: var(--hero-bg); display: grid; place-items: center; font-size: 7px; font-weight: 800; color: var(--hero-accent); }
@@ -985,6 +986,7 @@
985
986
  .hero-bmap__legend { position: absolute; left: 14px; bottom: 12px; z-index: 3; font-size: 11px; opacity: 0.65; display: flex; gap: 14px; white-space: nowrap; }
986
987
  .hero-bmap__key { display: inline-block; width: 8px; height: 8px; vertical-align: middle; margin-right: 5px; }
987
988
  .hero-bmap__key--door { background: var(--hero-accent); }
989
+ .hero-bmap__key--dooropen { background: rgb(var(--v-theme-success)); }
988
990
  .hero-bmap__key--shaft { background: color-mix(in srgb, var(--hero-accent) 30%, transparent); border: 1px solid var(--hero-accent); box-sizing: border-box; }
989
991
  .hero-bmap__key--bot { background: var(--hero-text); }
990
992
  .hero-bmap__key--route { width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); }
@@ -1010,8 +1012,6 @@
1010
1012
  .hero .hero-input.hero-editor__floor { width: auto; min-height: 32px; padding: 4px 26px 4px 8px; }
1011
1013
  .hero .hero-input.hero-editor__zone { width: 220px; min-height: 32px; }
1012
1014
  .hero-editor__hint { font-size: 12px; opacity: 0.7; order: 1; flex-basis: 100%; } /* own line: switching tools never moves the plan */
1013
- .hero .v-btn.hero-editor__fs { margin-left: auto; }
1014
- .hero .v-btn.hero-editor__fs .v-btn__content { gap: 8px; }
1015
1015
  .hero-editor__notice { padding: 10px 12px; font-size: 12.5px; border-left: 2px solid var(--hero-accent); background: var(--hero-surface); }
1016
1016
  .hero-editor__notice--ok { border-left-color: rgb(var(--v-theme-success)); }
1017
1017
  .hero-editor__split { display: flex; flex-wrap: wrap; gap: 24px; align-items: stretch; flex: 1; }
@@ -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" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
21
+ <v-btn v-if="canEditMap && !fleet" :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,8 +26,10 @@
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. It is managed by the fleet, so it cannot be edited here.</div>
30
+
29
31
  <RobotMapEditor
30
- v-if="editing && canEditMap"
32
+ v-if="editing && canEditMap && !fleet"
31
33
  ref="editor"
32
34
  :map="map"
33
35
  :floors="floors"
@@ -105,9 +107,11 @@
105
107
  class="hero-bmap__floor"
106
108
  :style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
107
109
  >
108
- <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
109
- <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
110
- <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
110
+ <template v-if="!fleet">
111
+ <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
112
+ <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
113
+ <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
114
+ </template>
111
115
  <template v-if="!map">
112
116
  <span class="hero-bmap__door" style="left: 42%; top: 22%" aria-hidden="true" />
113
117
  <span class="hero-bmap__door" style="left: 58%; top: 30%" aria-hidden="true" />
@@ -116,7 +120,7 @@
116
120
  <template v-for="s in f.shapes" :key="s.id">
117
121
  <polygon v-if="s.kind === 'zone'" class="hero-bmap__zone" :points="svgPoints(s)" vector-effect="non-scaling-stroke" />
118
122
  <line v-else-if="s.kind === 'wall'" class="hero-bmap__wall" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
119
- <line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
123
+ <line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :class="{ 'hero-bmap__doorline--open': openDoors.has(String(s.meta?.name)) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
120
124
  </template>
121
125
  </svg>
122
126
 
@@ -165,6 +169,7 @@
165
169
 
166
170
  <div class="hero-bmap__legend">
167
171
  <span><i class="hero-bmap__key hero-bmap__key--door" />Door</span>
172
+ <span v-if="fleet"><i class="hero-bmap__key hero-bmap__key--dooropen" />Door open</span>
168
173
  <span><i class="hero-bmap__key hero-bmap__key--shaft" />Lift shaft</span>
169
174
  <span><i class="hero-bmap__key hero-bmap__key--bot" />Robot (live)</span>
170
175
  <span v-if="type === 'security'"><i class="hero-bmap__key hero-bmap__key--route" />Patrol route · numbered checkpoints</span>
@@ -197,6 +202,7 @@ import RobotMapEditor from "./MapEditor.vue";
197
202
  import RobotReconnecting from "./Reconnecting.vue";
198
203
  import useRobotBuilding from "../../composables/useRobotBuilding";
199
204
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
205
+ import { openDoorNames } from "../../utils/robot-fleet-core";
200
206
  import {
201
207
  brandName,
202
208
  floorName,
@@ -258,11 +264,12 @@ const emit = defineEmits<{
258
264
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
259
265
  }>();
260
266
 
261
- const { rows, map, names, loading, error, reload } = useRobotBuilding(
267
+ const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
262
268
  toRef(props, "organization"),
263
269
  toRef(props, "site"),
264
270
  toRef(props, "type"),
265
271
  );
272
+ const openDoors = computed(() => openDoorNames(map.value));
266
273
  const siteLabel = computed(() => props.siteName || names.value.siteName || "This site");
267
274
 
268
275
  /* ---- live states + patrol routes of the robots on screen ---- */
@@ -381,8 +388,8 @@ function fullscreen(el?: HTMLElement | null) {
381
388
  else el?.requestFullscreen?.();
382
389
  }
383
390
  const editing = ref(false);
384
- watch(() => props.canEditMap, (can) => {
385
- if (!can) editing.value = false;
391
+ watch([() => props.canEditMap, fleet], ([can, isFleet]) => {
392
+ if (!can || isFleet) editing.value = false;
386
393
  });
387
394
  /** The robot a first save is attached to (maps are stored per robot): one of this site's. */
388
395
  const ownerRobotId = computed(() => String(map.value?.robotId ?? rows.value[0]?._id ?? ""));
@@ -16,6 +16,7 @@
16
16
  </div>
17
17
 
18
18
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
19
+ <div v-else-if="fleet" class="hero-notice" role="status">Live lifts from HERO Fleet Core - the fleet calls and releases lifts itself.</div>
19
20
  <div v-else-if="!connected" class="hero-notice" role="status">Reconnecting… the lift queue shows its last reading.</div>
20
21
 
21
22
  <div v-if="loading && !cards.length" class="hero-lifts" role="status" aria-label="Loading the lift queue">
@@ -75,7 +76,7 @@
75
76
  <span class="hero-lift-row__wait">{{ q.wait }}</span>
76
77
  <span class="hero-chip" :class="`hero-tone--${liftState(q.state).tone}`">{{ liftState(q.state).label }}</span>
77
78
  <button
78
- v-if="canCommand"
79
+ v-if="canCommand && !fleet"
79
80
  type="button"
80
81
  class="hero-lift-row__release"
81
82
  :style="{ visibility: q.state === 'riding' || q.state === 'requested' ? 'visible' : 'hidden' }"
@@ -88,7 +89,7 @@
88
89
  </div>
89
90
  </div>
90
91
 
91
- <form v-if="canCommand && demoRobots.length" class="hero-lift__ask" @submit.prevent="ask(l)">
92
+ <form v-if="canCommand && demoRobots.length && !fleet" class="hero-lift__ask" @submit.prevent="ask(l)">
92
93
  <select v-model="askRobot[l.name]" class="hero-input hero-select hero-select--sm" aria-label="Robot">
93
94
  <option value="" disabled>Robot</option>
94
95
  <option v-for="r in demoRobots" :key="String(r._id)" :value="String(r._id)">{{ r.name }}</option>
@@ -115,6 +116,7 @@ import RobotToast from "./Toast.vue";
115
116
  import useRobotBuilding from "../../composables/useRobotBuilding";
116
117
  import { floorName, liftState, robotErrorMessage } from "../../utils/robot-display";
117
118
  import { liftCard, type TLiftRowView } from "../../utils/robot-building";
119
+ import { fleetLiftItems } from "../../utils/robot-fleet-core";
118
120
 
119
121
  const props = withDefaults(
120
122
  defineProps<{
@@ -137,7 +139,7 @@ const emit = defineEmits<{
137
139
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
138
140
  }>();
139
141
 
140
- const { rows, map, names, loading: mapLoading, error: mapError } = useRobotBuilding(
142
+ const { rows, all, map, fleet, names, loading: mapLoading, error: mapError } = useRobotBuilding(
141
143
  toRef(props, "organization"),
142
144
  toRef(props, "site"),
143
145
  toRef(props, "type"),
@@ -154,6 +156,8 @@ const loading = computed(() => mapLoading.value || queueLoading.value);
154
156
  const error = computed(() => mapError.value);
155
157
 
156
158
  async function poll() {
159
+ // The fleet building has no lift sessions of ours: its lifts come live with the building (5 s refresh).
160
+ if (fleet.value) return void (items.value = fleetLiftItems(map.value, all.value), (queueLoading.value = false));
157
161
  const id = map.value?._id;
158
162
  if (!id) return void (items.value = []);
159
163
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
@@ -169,9 +173,10 @@ async function poll() {
169
173
  }
170
174
  let timer: ReturnType<typeof setInterval> | undefined;
171
175
  watch(
172
- () => map.value?._id,
176
+ () => (fleet.value ? map.value : map.value?._id),
173
177
  (id) => {
174
178
  clearInterval(timer);
179
+ if (fleet.value) return void poll();
175
180
  items.value = [];
176
181
  if (!id) return;
177
182
  queueLoading.value = true;
@@ -38,10 +38,6 @@
38
38
  @input="dispatch({ type: 'rename', name: zoneName })"
39
39
  />
40
40
  <span class="hero-editor__hint">{{ TOOL_HINTS[tool] }}</span>
41
- <v-btn variant="outlined" class="hero-editor__fs" @click="fullscreen">
42
- <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>
43
- Full screen
44
- </v-btn>
45
41
  </div>
46
42
 
47
43
  <div v-if="state.notice" class="hero-editor__notice" :class="{ 'hero-editor__notice--ok': state.notice.ok }" role="status" aria-live="polite">{{ state.notice.text }}</div>
@@ -482,9 +478,6 @@ const isFs = ref(false);
482
478
  const onFs = () => (isFs.value = document.fullscreenElement === root.value);
483
479
  onMounted(() => document.addEventListener("fullscreenchange", onFs));
484
480
  onBeforeUnmount(() => document.removeEventListener("fullscreenchange", onFs));
485
- function fullscreen() {
486
- if (document.fullscreenElement) document.exitFullscreen?.();
487
- else root.value?.requestFullscreen?.();
488
- }
481
+ // Full screen is the page header's one button (BuildingMap), which full-screens `root` while editing.
489
482
  defineExpose({ root });
490
483
  </script>
@@ -1,4 +1,5 @@
1
- import { computed, ref, watch, type Ref } from "vue";
1
+ import { computed, onBeforeUnmount, ref, watch, type Ref } from "vue";
2
+ import { hasFleetRobots, isFleetBuilding } from "../utils/robot-fleet-core";
2
3
 
3
4
  /**
4
5
  * One site's building for the HERO building map and lift queue: the robots
@@ -7,6 +8,10 @@ import { computed, ref, watch, type Ref } from "vue";
7
8
  * SITE (`/building-maps?site=`), whichever robot type it was saved from: the
8
9
  * building belongs to the site, so a map drawn in Hygiene shows in Security.
9
10
  *
11
+ * A site with HERO Fleet Core robots draws the FLEET building instead (read
12
+ * through from the fleet server, in the same plan frame as those robots,
13
+ * read-only), refreshed every 5 s so doors and lifts move.
14
+ *
10
15
  * ponytail: the first 50 robots at the site; the map read is one call.
11
16
  */
12
17
  export default function useRobotBuilding(
@@ -14,7 +19,7 @@ export default function useRobotBuilding(
14
19
  site: Ref<string>,
15
20
  type: Ref<"cleaning" | "security">,
16
21
  ) {
17
- const { getSiteFleet, getSiteSummary, listSiteBuildingMaps, getBuildingMap } = useRobots();
22
+ const { getSiteFleet, getSiteSummary, listSiteBuildingMaps, getBuildingMap, getFleetCoreBuilding } = useRobots();
18
23
 
19
24
  /** Every robot at the site this app may see, both types where the app shows both. */
20
25
  const all = ref<Array<Record<string, any>>>([]);
@@ -27,10 +32,24 @@ export default function useRobotBuilding(
27
32
  const rows = computed(() => all.value.filter((r) => r.type === type.value));
28
33
 
29
34
  async function loadMap() {
35
+ if (hasFleetRobots(all.value)) {
36
+ const building = await getFleetCoreBuilding(site.value).catch(() => null);
37
+ if (building) return building;
38
+ }
30
39
  const newest = (await listSiteBuildingMaps(site.value, { limit: 1 }))?.items?.[0];
31
40
  return newest ? await getBuildingMap(String(newest._id)) : null;
32
41
  }
33
42
 
43
+ /** Live doors and lifts of the fleet building; a failed refresh keeps the last reading. */
44
+ const fleet = computed(() => isFleetBuilding(map.value));
45
+ const timer = setInterval(async () => {
46
+ if (!fleet.value || (typeof document !== "undefined" && document.visibilityState === "hidden")) return;
47
+ const mine = run;
48
+ const next = await getFleetCoreBuilding(site.value).catch(() => null);
49
+ if (next && mine === run) map.value = next;
50
+ }, 5000);
51
+ onBeforeUnmount(() => clearInterval(timer));
52
+
34
53
  let run = 0;
35
54
  async function load() {
36
55
  const mine = ++run;
@@ -59,5 +78,5 @@ export default function useRobotBuilding(
59
78
 
60
79
  watch([organization, site], load, { immediate: true });
61
80
 
62
- return { all, rows, map, names, loading, error, reload: load };
81
+ return { all, rows, map, fleet, names, loading, error, reload: load };
63
82
  }
@@ -293,6 +293,18 @@ export default function useRobots() {
293
293
  });
294
294
  }
295
295
 
296
+ /**
297
+ * The HERO Fleet Core building at a site: levels, walls, doors, lifts (in the
298
+ * HERO plan frame) with live door and lift states, read through from the
299
+ * fleet server - nothing stored. 404 where the server has no fleet.
300
+ */
301
+ function getFleetCoreBuilding(site: string) {
302
+ return useNuxtApp().$api<Record<string, any>>("/api/robots/fleet-core/building", {
303
+ method: "GET",
304
+ query: { site, appSlug },
305
+ });
306
+ }
307
+
296
308
  /* ------------------------------------------ alerts + activity log */
297
309
 
298
310
  /**
@@ -394,6 +406,7 @@ export default function useRobots() {
394
406
  return {
395
407
  listBuildingMaps,
396
408
  listSiteBuildingMaps,
409
+ getFleetCoreBuilding,
397
410
  getSiteFaults,
398
411
  getSiteActivity,
399
412
  getNetworkActivity,
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.36.0",
5
+ "version": "4.37.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.",
@@ -36,7 +36,7 @@
36
36
  "build": "nuxt build .playground",
37
37
  "generate": "nuxt generate .playground",
38
38
  "preview": "nuxt preview .playground",
39
- "test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
39
+ "test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
40
40
  "test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
41
41
  "release": "yarn run build && changeset publish"
42
42
  },
@@ -0,0 +1,57 @@
1
+ /**
2
+ * HERO Fleet Core building helpers (pure, tested in test/robot-fleet-core.test.mjs).
3
+ *
4
+ * The fleet building (`GET /api/robots/fleet-core/building`) comes in the same
5
+ * shape as a saved building map - levels / doors / lifts in the HERO plan frame -
6
+ * plus live `doorStates` and `liftStates` in the lift / door contract shape.
7
+ * Not part of the mobile-synced rules (`robot-display.ts`), so web only for now.
8
+ */
9
+
10
+ export const FLEET_CORE_BRAND = "fleet-core";
11
+
12
+ /** Is this building the read-only fleet building rather than a saved map? */
13
+ export const isFleetBuilding = (map: Record<string, any> | null | undefined) => map?.source === FLEET_CORE_BRAND;
14
+
15
+ /** Does the site have fleet robots (then their building is the one to draw)? */
16
+ export const hasFleetRobots = (rows: Array<Record<string, any>>) => rows.some((r) => r?.brand === FLEET_CORE_BRAND);
17
+
18
+ /** "L2" -> 2, "B1" -> -1, "G" -> 0 (the fleet names its floors). */
19
+ export function fleetFloorNo(name: unknown): number | null {
20
+ if (typeof name !== "string") return null;
21
+ const m = /^(L|B)(\d{1,3})$/i.exec(name.trim());
22
+ if (m) return m[1]!.toUpperCase() === "B" ? -Number(m[2]) : Number(m[2]);
23
+ return /^G$/i.test(name.trim()) ? 0 : null;
24
+ }
25
+
26
+ /** Names of the doors that read OPEN (door contract mode 2). */
27
+ export function openDoorNames(map: Record<string, any> | null | undefined): Set<string> {
28
+ return new Set((map?.doorStates ?? []).filter((d: any) => d?.currentMode === 2).map((d: any) => String(d.doorName)));
29
+ }
30
+
31
+ /**
32
+ * The fleet's lifts as lift-queue items (the shape `liftCard` reads): the car's
33
+ * floor from the live state, and the robot holding the lift (its session is
34
+ * "<fleet>/<robot>", our robotSn) as the one riding, from its floor to the
35
+ * lift's destination. The fleet runs no waiting queue we can read.
36
+ */
37
+ export function fleetLiftItems(map: Record<string, any> | null | undefined, rows: Array<Record<string, any>>) {
38
+ if (!isFleetBuilding(map)) return [];
39
+ const states = new Map<string, any>((map!.liftStates ?? []).map((s: any) => [String(s.liftName), s]));
40
+ return (map!.lifts ?? []).map((lift: any) => {
41
+ const st = states.get(String(lift.name));
42
+ const current = fleetFloorNo(st?.currentFloor);
43
+ const dest = fleetFloorNo(st?.destinationFloor);
44
+ const holder = st?.sessionId ? rows.find((r) => r?.robotSn === st.sessionId) : null;
45
+ return {
46
+ liftName: String(lift.name),
47
+ floors: lift.floors ?? [],
48
+ state: { currentFloor: current },
49
+ active: holder
50
+ ? { _id: `fleet-${lift.name}`, robotId: String(holder._id), robotName: holder.name, fromFloor: current ?? dest, toFloor: dest ?? current }
51
+ : null,
52
+ queue: [],
53
+ recent: [],
54
+ simulated: false,
55
+ };
56
+ });
57
+ }