@7365admin1/layer-common 4.49.0 → 4.51.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,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.51.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac247e2: Members: a banner "N members here have a role from another site. Assign each one a role for this site." with a one-click filter to list them (server `roleState=other-site`); disappears at 0. `useMember().getAll` accepts `roleState`.
8
+
9
+ ## 4.50.0
10
+
11
+ ### Minor Changes
12
+
13
+ - d7f1732: HERO map editor - Fleet Core (Open-RMF) parity: Waypoint, Lane, Fiducial and Measure tools; lanes on route graphs 0-8 (graph picker, names, robot brands), two-way / one-way, speed limit, facing, shared-space group; waypoint settings (charger, parking, holding, pass-through, cleaning zone, dock name, spawn, pick-up / drop-off, merge radius); waypoints drag their lanes, deleting one deletes its lanes; the four door types with hinge / swing / degrees / leaf ratio; lift shafts at any angle with cabin doors per floor (which door opens where) and size fields; fiducial level alignment and scale by measurement (plan and drawing); plain-language checks (duplicate names, lanes through walls, charger without a dock, lanes that lost a waypoint, lift floors with no door); zoom and pan; the drawing area is the building's real size (plans larger than 40 x 26 m are no longer cut off; turned plans); import a building file or a route map into a graph. The editor now saves the map's `rmf` part. The 3D view draws door types, cabin doors, turned lifts, the building's real shape and a Routes toggle; the mini-map shows robot lanes.
14
+
3
15
  ## 4.49.0
4
16
 
5
17
  ### Minor Changes
@@ -1533,11 +1545,11 @@
1533
1545
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1534
1546
  own rule from two endpoints the console already calls, unprojected:
1535
1547
 
1536
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1537
- GET /api/roles/id/:role that membership's role document
1548
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1549
+ GET /api/roles/id/:role that membership's role document
1538
1550
 
1539
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1540
- staff = member.type === "admin" && role.type === "admin"
1551
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1552
+ staff = member.type === "admin" && role.type === "admin"
1541
1553
 
1542
1554
  `role.default` is the marker because it is the only property of a platform
1543
1555
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -741,6 +741,13 @@
741
741
  .hero-mini__foot { font-size: 11px; opacity: 0.6; display: flex; gap: 12px; white-space: nowrap; }
742
742
  .hero-mini__foot > span:first-child { overflow: hidden; text-overflow: ellipsis; }
743
743
  .hero-mini__legend { display: inline-block; width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); vertical-align: middle; margin-right: 5px; }
744
+ .hero-mini__legend--lane { border-top-style: solid; border-top-color: rgb(var(--v-theme-info)); }
745
+ .hero-mini__toggle { font: inherit; font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; padding: 4px 8px; min-height: 28px; color: var(--hero-text); background: transparent; border: 1px solid var(--hero-divider); cursor: pointer; }
746
+ .hero-mini__toggle[aria-pressed="true"] { background: var(--hero-text); color: var(--hero-bg); }
747
+ .hero-mini__lane { stroke: rgb(var(--v-theme-info)); stroke-width: 1.5; opacity: 0.8; }
748
+ .hero-mini__lane--oneway { stroke-dasharray: 5 3; }
749
+ .hero-mini__wp { position: absolute; width: 6px; height: 6px; margin: -3px 0 0 -3px; border-radius: 50%; background: rgb(var(--v-theme-info)); outline: 1.5px solid var(--hero-bg); pointer-events: none; }
750
+ .hero-mini__wp--charger { background: rgb(var(--v-theme-success)); border-radius: 1px; }
744
751
  .hero-mini__count { margin-left: auto; font-variant-numeric: tabular-nums; }
745
752
 
746
753
  /* Robot detail. */
@@ -952,6 +959,14 @@
952
959
  .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
960
  .hero-bmap__doorline { stroke: var(--hero-accent); stroke-width: 3; }
954
961
  .hero-bmap__doorline--open { stroke: rgb(var(--v-theme-success)); stroke-dasharray: 2 2; }
962
+ .hero-bmap__doorswing { fill: none; stroke: var(--hero-accent); stroke-width: 1; stroke-dasharray: 3 2; opacity: 0.75; }
963
+ .hero-bmap__cabindoor { stroke: rgb(var(--v-theme-success)); stroke-width: 3; }
964
+ .hero-bmap__lane { stroke: var(--hero-accent); stroke-width: 1.5; opacity: 0.85; }
965
+ .hero-bmap__lane--oneway { stroke-dasharray: 5 3; }
966
+ .hero-bmap__wp { position: absolute; width: 6px; height: 6px; margin: -3px 0 0 -3px; border-radius: 50%; background: var(--hero-accent); outline: 1.5px solid var(--hero-bg); }
967
+ .hero-bmap__wp--charger { background: rgb(var(--v-theme-success)); border-radius: 1px; width: 8px; height: 8px; margin: -4px 0 0 -4px; }
968
+ .hero-bmap__wp--parking { background: rgb(var(--v-theme-info)); }
969
+ .hero-bmap__wpname { position: absolute; left: 6px; top: -6px; transform-origin: 0 0; white-space: nowrap; font-size: 9px; font-weight: 700; padding: 0 4px; background: var(--hero-bg); color: var(--hero-text); }
955
970
  .hero-bmap__anchor { position: absolute; width: 0; height: 0; transform-style: preserve-3d; }
956
971
  .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; }
957
972
  .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); }
@@ -990,6 +1005,7 @@
990
1005
  .hero-bmap__key--shaft { background: color-mix(in srgb, var(--hero-accent) 30%, transparent); border: 1px solid var(--hero-accent); box-sizing: border-box; }
991
1006
  .hero-bmap__key--bot { background: var(--hero-text); }
992
1007
  .hero-bmap__key--route { width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); }
1008
+ .hero-bmap__key--lane { width: 12px; height: 0; border-top: 2px solid var(--hero-accent); }
993
1009
  .hero-bmap__aside { display: flex; flex-direction: column; gap: 2px; font-size: 12px; flex: 1 1 260px; max-width: 360px; min-width: 0; }
994
1010
  .hero-bmap__asidetitle { border-top: 2px solid var(--hero-divider); padding-top: 8px; margin-bottom: 6px; }
995
1011
  .hero-bmap__legendrow { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--hero-divider); }
@@ -1020,17 +1036,23 @@
1020
1036
  align-self: flex-start; /* a taller side list must not stretch the plan under the pointer */
1021
1037
  min-width: 0;
1022
1038
  position: relative;
1023
- min-height: max(480px, calc(100vh - 330px));
1024
- background-color: var(--hero-surface);
1025
- background-image: linear-gradient(var(--hero-divider) 1px, transparent 1px), linear-gradient(90deg, var(--hero-divider) 1px, transparent 1px);
1026
- background-size: 5% 8.9%;
1027
- background-repeat: repeat; /* Vuetify resets every element to no-repeat */
1039
+ height: max(480px, calc(100vh - 330px));
1040
+ background-color: color-mix(in srgb, var(--hero-surface) 60%, var(--hero-bg));
1028
1041
  border: 1px solid var(--hero-divider);
1029
1042
  overflow: hidden;
1030
1043
  cursor: crosshair;
1031
1044
  touch-action: none; /* a finger drags shapes, not the page */
1032
1045
  user-select: none;
1033
1046
  }
1047
+ /* The building's frame at its real shape, fitted into the viewport; zoom and pan resize / move it. */
1048
+ .hero-editor__canvas {
1049
+ position: absolute;
1050
+ background-color: var(--hero-surface);
1051
+ background-image: linear-gradient(var(--hero-divider) 1px, transparent 1px), linear-gradient(90deg, var(--hero-divider) 1px, transparent 1px);
1052
+ background-repeat: repeat; /* Vuetify resets every element to no-repeat */
1053
+ outline: 1px solid var(--hero-divider);
1054
+ }
1055
+ .hero-editor__scale { position: absolute; right: 8px; bottom: 6px; font-size: 11px; opacity: 0.6; pointer-events: none; font-variant-numeric: tabular-nums; }
1034
1056
  .hero-editor__plan--default { cursor: default; }
1035
1057
  .hero-editor__plan--pointer { cursor: pointer; }
1036
1058
  .hero-editor__plan--move { cursor: move; }
@@ -1050,7 +1072,7 @@
1050
1072
  .hero-editor__measurebox { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px dashed var(--hero-accent); font-size: 12px; }
1051
1073
  .hero-editor__measurein { width: 110px; min-height: 32px; }
1052
1074
  .hero-editor__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
1053
- .hero-editor__wall { stroke: var(--hero-text); stroke-width: 1.5; }
1075
+ .hero-editor__wall { stroke: var(--hero-text); stroke-width: 3; stroke-linecap: square; }
1054
1076
  .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; }
1055
1077
  line.hero-editor__sel { stroke: var(--hero-accent); }
1056
1078
  polygon.hero-editor__sel { stroke: var(--hero-accent); stroke-width: 2; stroke-dasharray: none; }
@@ -1080,6 +1102,49 @@ line.hero-editor__door.hero-editor__sel { stroke: var(--hero-text); }
1080
1102
  .hero-editor__act:hover { background: color-mix(in srgb, var(--hero-text) 7%, transparent); }
1081
1103
  .hero-editor__act:disabled { opacity: 0.45; cursor: not-allowed; }
1082
1104
  .hero-editor__act--del { color: var(--hero-accent); }
1105
+ /* Robot routes: lanes, waypoints, fiducials, measurements (HERO Fleet Core building data). */
1106
+ .hero-editor__lane { stroke: var(--hero-accent); stroke-width: 3; stroke-linecap: round; opacity: 0.9; }
1107
+ .hero-editor__lane--other { stroke: var(--hero-text); stroke-width: 1.5; stroke-dasharray: 4 3; opacity: 0.45; }
1108
+ .hero-editor__lane--preview { stroke-dasharray: 5 4; opacity: 0.7; }
1109
+ line.hero-editor__lane.hero-editor__sel { stroke: var(--hero-text); stroke-width: 4; opacity: 1; }
1110
+ .hero-editor__arrow { position: absolute; width: 0; height: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-left: 11px solid var(--hero-accent); pointer-events: none; }
1111
+ .hero-editor__arrow--other { border-left-color: color-mix(in srgb, var(--hero-text) 45%, transparent); }
1112
+ .hero-editor__wp { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; box-sizing: border-box; border-radius: 50%; background: var(--hero-surface); border: 2.5px solid var(--hero-accent); pointer-events: none; }
1113
+ .hero-editor__wp--charger { background: rgb(var(--v-theme-success)); border-color: var(--hero-text); border-radius: 2px; }
1114
+ .hero-editor__wp--parking { background: rgb(var(--v-theme-info)); border-color: var(--hero-text); }
1115
+ .hero-editor__wp--holding { background: var(--hero-accent); }
1116
+ .hero-editor__wp--from { box-shadow: 0 0 0 4px color-mix(in srgb, var(--hero-accent) 35%, transparent); }
1117
+ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { outline: 2px solid var(--hero-text); outline-offset: 2px; }
1118
+ .hero-editor__wpname { position: absolute; left: 12px; top: -9px; white-space: nowrap; font-size: 10.5px; font-weight: 700; line-height: 1.4; padding: 0 4px; background: var(--hero-surface); color: var(--hero-text); border: 1px solid var(--hero-divider); }
1119
+ .hero-editor__fid { position: absolute; width: 16px; height: 16px; margin: -8px 0 0 -8px; box-sizing: border-box; border: 2px solid var(--hero-text); border-radius: 50%; pointer-events: none; background: linear-gradient(var(--hero-text), var(--hero-text)) center / 2px 100% no-repeat, linear-gradient(var(--hero-text), var(--hero-text)) center / 100% 2px no-repeat; }
1120
+ .hero-editor__mlabel { position: absolute; transform: translate(-50%, -130%); white-space: nowrap; font-size: 10.5px; font-weight: 700; padding: 0 4px; background: var(--hero-accent); color: #fff; pointer-events: none; }
1121
+ .hero-editor__cabindoor { position: absolute; height: 4px; background: rgb(var(--v-theme-success)); pointer-events: none; }
1122
+ .hero-editor__cabindoor--shut { background: color-mix(in srgb, var(--hero-text) 30%, transparent); }
1123
+ .hero-editor__lift { transform-origin: center; }
1124
+ .hero-editor__lift--passes { opacity: 0.45; border-style: dashed; }
1125
+ .hero-editor__graphpick { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
1126
+ .hero-editor__zoomread { min-width: 56px; font-variant-numeric: tabular-nums; }
1127
+ .hero-editor__field { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; }
1128
+ .hero-editor__field > span { opacity: 0.75; }
1129
+ .hero .hero-editor__field .hero-input { min-height: 32px; width: 100%; }
1130
+ .hero-editor__row3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: 8px; }
1131
+ .hero-editor__sub { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.7; margin-top: 4px; }
1132
+ .hero-editor__cabinrow { display: grid; grid-template-columns: 1fr 1fr auto; gap: 4px; align-items: center; padding-bottom: 6px; border-bottom: 1px dashed var(--hero-divider); }
1133
+ .hero-editor__cabinw { display: flex; align-items: center; gap: 4px; font-size: 11.5px; }
1134
+ .hero .hero-editor__cabinrow .hero-input { min-height: 30px; padding: 3px 6px; width: 100%; min-width: 0; }
1135
+ .hero-editor__grid2 { border-collapse: collapse; font-size: 11.5px; }
1136
+ .hero-editor__grid2 th, .hero-editor__grid2 td { padding: 3px 6px; border-bottom: 1px solid var(--hero-divider); text-align: center; }
1137
+ .hero-editor__grid2 th:first-child, .hero-editor__grid2 td:first-child { text-align: left; font-weight: 700; }
1138
+ .hero-editor__checks { display: flex; flex-direction: column; gap: 4px; }
1139
+ .hero-editor__checks label, .hero-editor__check2 { display: flex; gap: 6px; align-items: center; cursor: pointer; }
1140
+ .hero-editor__check { font: inherit; font-size: 12px; text-align: left; padding: 6px 8px; color: inherit; background: color-mix(in srgb, rgb(var(--v-theme-warning)) 12%, transparent); border: 0; border-left: 3px solid rgb(var(--v-theme-warning)); cursor: pointer; }
1141
+ .hero-editor__check--error { background: color-mix(in srgb, rgb(var(--v-theme-error)) 12%, transparent); border-left-color: rgb(var(--v-theme-error)); }
1142
+ .hero-editor__graphrow { display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 4px; align-items: center; padding-left: 4px; border-left: 3px solid transparent; }
1143
+ .hero-editor__graphrow--on { border-left-color: var(--hero-accent); }
1144
+ .hero .hero-editor__graphrow .hero-input { min-height: 30px; padding: 3px 6px; width: 100%; min-width: 0; }
1145
+ .hero-editor__details summary { cursor: pointer; font-size: 11.5px; opacity: 0.8; }
1146
+ .hero-editor__details[open] { display: flex; flex-direction: column; gap: 6px; }
1147
+ .hero-editor__list { display: flex; flex-direction: column; max-height: 260px; overflow-y: auto; }
1083
1148
  .hero-editor__cursor { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border: 2px solid var(--hero-accent); pointer-events: none; }
1084
1149
  .hero-editor__aside { font-size: 12px; display: flex; flex-direction: column; gap: 8px; flex: 1 1 260px; max-width: 360px; min-width: 0; align-self: stretch; }
1085
1150
  .hero-editor__asidetitle { border-top: 2px solid var(--hero-divider); padding-top: 8px; }
@@ -27,6 +27,28 @@
27
27
  </template>
28
28
  </PageHeader>
29
29
 
30
+ <!-- Owner decision 2026-09-25: members holding ANOTHER site's role keep
31
+ their access; the company assigns each one a role for this site.
32
+ Shown while any remain (server count), gone at 0. -->
33
+ <v-alert
34
+ v-if="props.assignRole && otherSiteBanner"
35
+ type="warning"
36
+ variant="tonal"
37
+ density="compact"
38
+ class="mb-3"
39
+ data-test="member-other-site-banner"
40
+ >
41
+ <div class="d-flex align-center flex-wrap ga-2">
42
+ <span class="flex-grow-1">{{ otherSiteBanner }}</span>
43
+ <AppButton
44
+ data-test="member-other-site-filter"
45
+ @click="toggleOtherSite()"
46
+ >
47
+ {{ onlyOtherSite ? "Show all members" : "Show them" }}
48
+ </AppButton>
49
+ </div>
50
+ </v-alert>
51
+
30
52
  <AppCard class="table-card">
31
53
  <CardToolbar :count="pageRange">
32
54
  <template #tabs>
@@ -278,7 +300,7 @@
278
300
  </template>
279
301
 
280
302
  <script setup lang="ts">
281
- import { memberRoleChip, withCurrentRole } from "../utils/role";
303
+ import { memberRoleChip, otherSiteBannerText, withCurrentRole } from "../utils/role";
282
304
 
283
305
  const props = defineProps({
284
306
  /** The design's page title. Every app that mounts this screen calls it
@@ -372,6 +394,7 @@ const props = defineProps({
372
394
  });
373
395
 
374
396
  const items = ref<Array<Record<string, any>>>([]);
397
+ const onlyOtherSite = ref(false);
375
398
  const page = ref(1);
376
399
  const pages = ref(10);
377
400
  const pageRange = ref("-- - -- of --");
@@ -444,12 +467,47 @@ const {
444
467
  page: page.value,
445
468
  type: props.type,
446
469
  siteId: props.siteId,
470
+ roleState: onlyOtherSite.value ? "other-site" : "",
447
471
  }),
448
472
  {
449
- watch: [() => props.status, () => props.orgId, () => props.siteId, headerSearch],
473
+ watch: [() => props.status, () => props.orgId, () => props.siteId, headerSearch, onlyOtherSite],
450
474
  }
451
475
  );
452
476
 
477
+ /*
478
+ * "N members here have a role from another site" — the count is the server's
479
+ * (`roleState=other-site`, `total`), for this org / site / app, so it is right
480
+ * however many pages the list has. Refreshed with the list (after an Assign
481
+ * Role the list refreshes, and so does this).
482
+ */
483
+ const { data: otherSiteReq, refresh: refreshOtherSite } = useAsyncData(
484
+ `members-other-site-${props.type}-${props.orgId}-${props.siteId}-${props.status}`,
485
+ () =>
486
+ props.assignRole && props.orgId
487
+ ? _getAll({
488
+ status: props.status,
489
+ org: props.orgId,
490
+ page: 1,
491
+ limit: 1,
492
+ type: props.type,
493
+ siteId: props.siteId,
494
+ roleState: "other-site",
495
+ }).catch(() => null)
496
+ : Promise.resolve(null),
497
+ { watch: [() => props.status, () => props.orgId, () => props.siteId] }
498
+ );
499
+ const otherSiteBanner = computed(() =>
500
+ otherSiteBannerText(otherSiteReq.value?.total)
501
+ );
502
+ watch(otherSiteBanner, (text) => {
503
+ // the last one fixed: back to the full list
504
+ if (!text && onlyOtherSite.value) onlyOtherSite.value = false;
505
+ });
506
+ function toggleOtherSite() {
507
+ page.value = 1;
508
+ onlyOtherSite.value = !onlyOtherSite.value;
509
+ }
510
+
453
511
  const loading = computed(() => getAllReqStatus.value === "pending");
454
512
 
455
513
  watch(headerSearch, () => {
@@ -567,7 +625,7 @@ async function updateMemberRole() {
567
625
  props.orgId
568
626
  );
569
627
  await setMember({ mode: "assign-role" });
570
- await getAll();
628
+ await Promise.all([getAll(), refreshOtherSite()]);
571
629
  } catch (error: any) {
572
630
  // `message.value` alone sets the snackbar's TEXT and never opens it, so a
573
631
  // refused role change said nothing at all - the dialog just closed and the
@@ -78,6 +78,9 @@
78
78
  <div class="hero-bmap__seg" role="group" aria-label="View angle">
79
79
  <button v-for="(a, id) in MAP_ANGLES" :key="id" type="button" :aria-pressed="cam.angle === id" @click="cam = presetCamera(cam, id)">{{ a.label }}</button>
80
80
  </div>
81
+ <div class="hero-bmap__seg" role="group" aria-label="Show">
82
+ <button type="button" :aria-pressed="showRoutes" title="Show the robots' lanes and waypoints" @click="showRoutes = !showRoutes">Routes</button>
83
+ </div>
81
84
  <div class="hero-bmap__seg" role="group" aria-label="Floor">
82
85
  <button type="button" :aria-pressed="focus === null" @click="focus = null">All</button>
83
86
  <button v-for="f in floors" :key="f" type="button" :aria-pressed="focus === f" @click="focus = f">{{ floorName(f) }}</button>
@@ -98,6 +101,8 @@
98
101
  <div
99
102
  class="hero-bmap__stack"
100
103
  :style="{
104
+ width: `${plane.w}px`,
105
+ height: `${plane.h}px`,
101
106
  '--shift': `${stack.shift}px`,
102
107
  transform: `rotateX(${cam.tilt}deg) rotateZ(${cam.turn}deg) translateZ(${stack.shift}px)`,
103
108
  transition: drag ? 'none' : 'transform .6s ease',
@@ -120,13 +125,26 @@
120
125
  <span class="hero-bmap__door" style="left: 42%; top: 22%" aria-hidden="true" />
121
126
  <span class="hero-bmap__door" style="left: 58%; top: 30%" aria-hidden="true" />
122
127
  </template>
123
- <svg v-if="f.shapes.length" class="hero-plan__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
128
+ <svg v-if="f.shapes.length || f.cabin.length" class="hero-plan__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
124
129
  <template v-for="s in f.shapes" :key="s.id">
125
130
  <polygon v-if="s.kind === 'zone'" class="hero-bmap__zone" :points="svgPoints(s)" vector-effect="non-scaling-stroke" />
126
131
  <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" />
127
- <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" />
132
+ <template v-else-if="s.kind === 'door'">
133
+ <polyline v-for="(g, gi) in f.glyphs[s.id] ?? []" :key="gi" class="hero-bmap__doorswing" :points="svgPoints({ points: g })" vector-effect="non-scaling-stroke" />
134
+ <line 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" />
135
+ </template>
128
136
  </template>
137
+ <line v-for="(c, ci) in f.cabin" :key="`c${ci}`" class="hero-bmap__cabindoor" :x1="c.a.x" :y1="c.a.y" :x2="c.b.x" :y2="c.b.y" vector-effect="non-scaling-stroke" />
129
138
  </svg>
139
+ <template v-if="showRoutes && (f.routes2.lanes.length || f.routes2.waypoints.length)">
140
+ <svg class="hero-plan__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
141
+ <line v-for="(l, li) in f.routes2.lanes" :key="`ln${li}`" class="hero-bmap__lane" :class="{ 'hero-bmap__lane--oneway': l.oneWay }" :x1="l.a.x" :y1="l.a.y" :x2="l.b.x" :y2="l.b.y" vector-effect="non-scaling-stroke" />
142
+ </svg>
143
+ <span v-for="(w, wi) in f.routes2.waypoints" :key="`wp${wi}`" class="hero-bmap__wp" :class="`hero-bmap__wp--${w.kind}`" :style="{ left: `${w.x}%`, top: `${w.y}%` }" :title="w.name" aria-hidden="true" />
144
+ <div v-for="(w, wi) in f.routes2.waypoints.filter((x) => x.name && x.kind !== 'point')" :key="`wn${wi}`" class="hero-bmap__anchor" :style="{ left: `${w.x}%`, top: `${w.y}%` }">
145
+ <div class="hero-bmap__wpname" :style="{ transform: `translateZ(6px) ${unskew}` }">{{ w.name }}</div>
146
+ </div>
147
+ </template>
130
148
 
131
149
  <template v-if="type === 'security'">
132
150
  <svg class="hero-plan__svg" viewBox="0 0 400 300" preserveAspectRatio="none" aria-hidden="true">
@@ -163,7 +181,7 @@
163
181
  v-for="s in shafts"
164
182
  :key="s.id"
165
183
  class="hero-bmap__shaft"
166
- :style="{ left: `${s.x}%`, top: `${s.y}%`, transform: `translateZ(${s.z}px)`, opacity: look.shaftOpacity }"
184
+ :style="{ left: `${s.x}px`, top: `${s.y}px`, transform: `translateZ(${s.z}px) rotateZ(${s.rot}deg)`, opacity: look.shaftOpacity }"
167
185
  >
168
186
  <span class="hero-bmap__shaftwall" :style="{ width: `${s.w}px`, height: `${s.h}px` }" />
169
187
  <span class="hero-bmap__shaftwall hero-bmap__shaftwall--y" :style="{ width: `${s.h}px`, height: `${s.d}px` }" />
@@ -177,6 +195,7 @@
177
195
  <span><i class="hero-bmap__key hero-bmap__key--shaft" />Lift shaft</span>
178
196
  <span><i class="hero-bmap__key hero-bmap__key--bot" />Robot (live)</span>
179
197
  <span v-if="type === 'security'"><i class="hero-bmap__key hero-bmap__key--route" />Patrol route · numbered checkpoints</span>
198
+ <span v-if="showRoutes && hasLanes"><i class="hero-bmap__key hero-bmap__key--lane" />Robot lanes · waypoints</span>
180
199
  </div>
181
200
  </div>
182
201
 
@@ -206,7 +225,8 @@ import RobotMapEditor from "./MapEditor.vue";
206
225
  import RobotReconnecting from "./Reconnecting.vue";
207
226
  import useRobotBuilding from "../../composables/useRobotBuilding";
208
227
  import useFloorPlanImages from "../../composables/useFloorPlanImages";
209
- import { floorPlanFor, planBox } from "../../utils/robot-floorplan";
228
+ import { floorPlanFor } from "../../utils/robot-floorplan";
229
+ import { floorAspect, floorDoors, placementInWorld, planBoxIn, routeOverlay, viewerLifts } from "../../utils/rmf-editor";
210
230
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
211
231
  import { openDoorNames } from "../../utils/robot-fleet-core";
212
232
  import {
@@ -228,6 +248,7 @@ import {
228
248
  import {
229
249
  buildingFloors,
230
250
  cameraReadout,
251
+ doorGlyph,
231
252
  defaultCamera,
232
253
  dragCamera,
233
254
  dragMode,
@@ -237,7 +258,6 @@ import {
237
258
  planeLook,
238
259
  presetCamera,
239
260
  resetCamera,
240
- shapeBox,
241
261
  shapesFromMap,
242
262
  stackMetrics,
243
263
  startDrag,
@@ -319,34 +339,66 @@ function floorImage(floorNo: number) {
319
339
  const p = floorPlanFor(saved.value, floorNo);
320
340
  const url = planUrls.value[floorNo];
321
341
  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}%` } };
342
+ // The plan sits in the floor's own frame (world metres); older maps use the 40 x 26 m frame.
343
+ const pf = saved.value?.rmf?.levels?.find((l: any) => l.floorNo === floorNo)?.planFrame;
344
+ const f = pf ? { minX: pf.minX, maxY: pf.maxY, w: pf.maxX - pf.minX, h: pf.maxY - pf.minY } : { minX: 0, maxY: 0, w: 40, h: 26 };
345
+ const b = planBoxIn(f, placementInWorld(p as typeof p & { space?: string }, pf));
346
+ return { url, style: { left: `${b.left}%`, top: `${b.top}%`, width: `${b.width}%`, height: `${b.height}%`, transform: `rotate(${b.rotateDeg}deg)`, transformOrigin: "0 0" } };
347
+ }
348
+ /** Each floor's doors with their real type (the saved map's rmf part), for the swing / slide drawing, and the lift cabin doors that open there. */
349
+ function floorDoorParts(floorNo: number) {
350
+ const fd = fleet.value ? null : floorDoors(map.value, floorNo);
351
+ const own = shapes.value.filter((s) => s.kind !== "lift" && s.floorNo === floorNo);
352
+ if (!fd) return { shapes: own, glyphs: {} as Record<string, Array<Array<{ x: number; y: number }>>>, cabin: [] as Array<{ a: { x: number; y: number }; b: { x: number; y: number } }> };
353
+ const size = { w: fd.frame.w, h: fd.frame.h };
354
+ return { shapes: [...own.filter((s) => s.kind !== "door"), ...fd.doors], glyphs: Object.fromEntries(fd.doors.map((d) => [d.id, doorGlyph(d, size)])), cabin: fd.cabin };
324
355
  }
325
356
  const floors3d = computed(() =>
326
- floors.value.map((floorNo) => ({
327
- floorNo,
328
- plan: floorImage(floorNo),
329
- dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
330
- shapes: shapes.value.filter((s) => s.kind !== "lift" && s.floorNo === floorNo),
331
- routes: props.type === "security" ? floorRoutes(floorNo) : [],
332
- robots: mapRobots(rows.value, states.value, floorNo),
333
- })),
357
+ floors.value.map((floorNo) => {
358
+ const parts = floorDoorParts(floorNo);
359
+ return {
360
+ floorNo,
361
+ plan: floorImage(floorNo),
362
+ dim: floorOpacity(cam.value.tilt, focus.value, floorNo),
363
+ shapes: parts.shapes,
364
+ glyphs: parts.glyphs,
365
+ cabin: parts.cabin,
366
+ routes: props.type === "security" ? floorRoutes(floorNo) : [],
367
+ routes2: routeOverlay(map.value, floorNo),
368
+ robots: mapRobots(rows.value, states.value, floorNo),
369
+ };
370
+ }),
334
371
  );
372
+ /** Lanes and waypoints: shown by default once the building has any. */
373
+ const hasLanes = computed(() => floors3d.value.some((f) => f.routes2.lanes.length || f.routes2.waypoints.length));
374
+ const showRoutes = ref(true);
375
+ /** The floor plane keeps the building's real shape (inside 400 x 300 px). */
376
+ const plane = computed(() => {
377
+ const a = fleet.value ? 40 / 26 : floorAspect(map.value);
378
+ return a >= 4 / 3 ? { w: 400, h: Math.max(60, Math.round(400 / a)) } : { w: Math.max(60, Math.round(300 * a)), h: 300 };
379
+ });
335
380
  const legend = computed(() => [...floors3d.value].reverse());
336
381
  /** The editor opens on the busiest floor (the handoff's "best floor"). */
337
382
  const startFloor = computed(() => pickFloor(floors.value, null, ids.value, states.value));
338
383
 
339
- /** Each saved lift shaft: two red walls from its lowest served floor to its highest. */
384
+ /**
385
+ * Each saved lift shaft: two red walls from its lowest served floor to its
386
+ * highest, at its real size and angle - placed by its top-left corner turned
387
+ * round its centre, so the shaft turns on the spot.
388
+ */
340
389
  const shafts = computed(() =>
341
- shapes.value
342
- .filter((s) => s.kind === "lift")
343
- .map((s) => {
344
- const served = (s.floors ?? []).map((f) => floors.value.indexOf(f)).filter((i) => i >= 0);
345
- const lo = served.length ? Math.min(...served) : 0;
346
- const hi = served.length ? Math.max(...served) : floors.value.length - 1;
347
- const b = shapeBox(s);
348
- return { id: s.id, x: b.x, y: b.y, z: lo * FLOOR_GAP_PX, w: Math.max(12, (b.w / 100) * 400), d: Math.max(12, (b.h / 100) * 260), h: (hi - lo) * FLOOR_GAP_PX + 8 };
349
- }),
390
+ viewerLifts(map.value).map((l, i) => {
391
+ const served = l.floors.map((f) => floors.value.indexOf(f)).filter((k) => k >= 0);
392
+ const lo = served.length ? Math.min(...served) : 0;
393
+ const hi = served.length ? Math.max(...served) : floors.value.length - 1;
394
+ const w = Math.max(12, (l.w / 100) * plane.value.w);
395
+ const d = Math.max(12, (l.d / 100) * plane.value.h);
396
+ const rot = -l.yawDeg;
397
+ const t = (rot * Math.PI) / 180;
398
+ const cx = (l.x / 100) * plane.value.w;
399
+ const cy = (l.y / 100) * plane.value.h;
400
+ return { id: `${l.name}-${i}`, x: cx + (-w / 2) * Math.cos(t) - (-d / 2) * Math.sin(t), y: cy + (-w / 2) * Math.sin(t) + (-d / 2) * Math.cos(t), rot, z: lo * FLOOR_GAP_PX, w, d, h: (hi - lo) * FLOOR_GAP_PX + 8 };
401
+ }),
350
402
  );
351
403
 
352
404
  const unplaced = computed(() => unplacedRobots(rows.value, states.value));
@@ -394,7 +446,7 @@ function onMove(e: MouseEvent) {
394
446
  if (d) cam.value = dragCamera(d.start, d.mode, e.clientX - d.x, e.clientY - d.y);
395
447
  }
396
448
 
397
- const svgPoints = (s: TEditorShape) => s.points.map((p) => `${p.x},${p.y}`).join(" ");
449
+ const svgPoints = (s: { points: Array<{ x: number; y: number }> }) => s.points.map((p) => `${p.x},${p.y}`).join(" ");
398
450
 
399
451
  /* ---- full screen + edit ---- */
400
452
  const viewer = ref<HTMLElement | null>(null);