@7365admin1/layer-common 4.122.1 → 4.122.3

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.122.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d9f5ad: Lift queue: when no robot ride is being drawn and the lift's read-only gateway is connected, the car in the shaft now follows the real lift - it moves to the lift's current floor, the doors open and close with the lift's door state, the car shows an up/down arrow while moving, and it turns the alert colour in fire/emergency modes. Before, the card text updated but the car only ever moved for robot rides.
8
+
9
+ ## 4.122.2
10
+
11
+ ### Patch Changes
12
+
13
+ - ad65b64: Map editor: Add floor beside the floor list adds a floor no robot reports yet (saved as an empty level), so a lift shaft can stop on two floors at a site whose robots are all on one floor. The one-floor lift message now says how.
14
+
3
15
  ## 4.122.1
4
16
 
5
17
  ### Patch Changes
@@ -3234,11 +3246,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3234
3246
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3235
3247
  own rule from two endpoints the console already calls, unprojected:
3236
3248
 
3237
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3238
- GET /api/roles/id/:role that membership's role document
3249
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3250
+ GET /api/roles/id/:role that membership's role document
3239
3251
 
3240
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3241
- staff = member.type === "admin" && role.type === "admin"
3252
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3253
+ staff = member.type === "admin" && role.type === "admin"
3242
3254
 
3243
3255
  `role.default` is the marker because it is the only property of a platform
3244
3256
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -85,10 +85,13 @@
85
85
  <div
86
86
  class="hero-lift__carbox"
87
87
  :class="`hero-lift__carbox--${l.carTone}`"
88
- :style="{ height: `${l.carH}px`, top: `${l.fromTop}px`, '--from': `${l.fromTop}px`, '--to': `${l.toTop}px`, animation: l.riding ? 'heroLift 6s ease-in-out infinite alternate' : 'none' }"
88
+ :style="{ height: `${l.carH}px`, top: `${l.fromTop}px`, '--from': `${l.fromTop}px`, '--to': `${l.toTop}px`, animation: l.riding ? 'heroLift 6s ease-in-out infinite alternate' : 'none', transition: l.live ? 'top 1.8s linear' : 'none' }"
89
89
  >
90
90
  <span class="hero-lift__carlabel">{{ l.carLabel }}</span>
91
- <div class="hero-lift__doors" :style="{ animation: l.riding ? 'heroDoors 6s ease-in-out infinite' : 'none' }" />
91
+ <div
92
+ class="hero-lift__doors"
93
+ :style="l.live ? { transform: `scaleX(${1 - l.doors})`, transition: 'transform 0.8s ease' } : { animation: l.riding ? 'heroDoors 6s ease-in-out infinite' : 'none' }"
94
+ />
92
95
  </div>
93
96
  </div>
94
97
  </div>
@@ -39,6 +39,9 @@
39
39
  <select v-model.number="floor" class="hero-input hero-select hero-editor__floor" aria-label="Floor">
40
40
  <option v-for="f in allFloors" :key="f" :value="f">{{ floorName(f) }}</option>
41
41
  </select>
42
+ <div class="hero-seg" role="group" aria-label="Floors">
43
+ <button type="button" class="hero-seg__opt" title="Add a floor no robot is on yet (e.g. a lift's other stops)" @click="addFloor">Add floor</button>
44
+ </div>
42
45
  <label class="hero-editor__graphpick">
43
46
  <span>Route graph</span>
44
47
  <select v-model.number="graph" class="hero-input hero-select hero-editor__floor" aria-label="Route graph">
@@ -408,6 +411,7 @@ import {
408
411
  LIFT_MIN_M,
409
412
  NUDGE_BIG_M,
410
413
  NUDGE_M,
414
+ newFloorNo,
411
415
  ROUTE_TOOLS,
412
416
  routeProblems,
413
417
  saveErrorText,
@@ -1062,6 +1066,20 @@ function setLevel(key: "name" | "elevationM", value: string | number) {
1062
1066
  levels.value = { ...levels.value, [String(floor.value)]: { ...cur, [key]: value } };
1063
1067
  state.value = { ...state.value, dirty: true };
1064
1068
  }
1069
+ /** A floor with nothing on it yet (no robot reports it): saved as an empty level so a lift can stop there. */
1070
+ function addFloor() {
1071
+ const answer = window.prompt("Floor number to add (1 = L1, 0 = G, -1 = B1)");
1072
+ if (answer === null) return;
1073
+ const r = newFloorNo(answer, allFloors.value);
1074
+ if ("error" in r) return void dispatch({ type: "notice", text: r.error, ok: false });
1075
+ const fl = r.floorNo;
1076
+ levels.value = { ...levels.value, [String(fl)]: { name: floorName(fl), elevationM: fl * 4, floorPolys: [], holePolys: [] } };
1077
+ // A selected lift stays selected (its Stops table now lists the floor); otherwise go to the new floor.
1078
+ const liftPicked = single.value?.kind === "lift";
1079
+ if (!liftPicked) floor.value = fl;
1080
+ state.value = { ...state.value, dirty: true };
1081
+ dispatch({ type: "notice", text: `${floorName(fl)} added.${liftPicked ? ` Tick Stops for ${floorName(fl)} below,` : " Select the lift shaft, tick Stops for it,"} then Save.`, ok: true });
1082
+ }
1065
1083
  function alignNow() {
1066
1084
  const r = alignFloor(state.value.shapes, state.value.frame, floor.value, alignTo.value);
1067
1085
  if ("error" in r) return void dispatch({ type: "notice", text: r.error!, ok: false });
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.122.1",
5
+ "version": "4.122.3",
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.",
@@ -1190,6 +1190,24 @@ export function editorReduce(state: TEditorState, action: TEditorAction): TEdito
1190
1190
  }
1191
1191
  }
1192
1192
 
1193
+ /** Floor numbers the API accepts (robot-building-map floorNo). */
1194
+ export const FLOOR_MIN = -20;
1195
+ export const FLOOR_MAX = 200;
1196
+
1197
+ /**
1198
+ * The editor's "Add floor" answer, checked: a whole number (1 = L1, 0 = G,
1199
+ * -1 = B1) in the API's range and not already a floor - or the reason not.
1200
+ */
1201
+ export function newFloorNo(input: string | null | undefined, existing: number[]): { floorNo: number } | { error: string } {
1202
+ const t = String(input ?? "").trim().toUpperCase();
1203
+ const m = /^(?:([LB])(\d+)|(-?\d+))$/.exec(t);
1204
+ const n = t === "G" ? 0 : m ? (m[3] !== undefined ? Number(m[3]) : m[1] === "B" ? -Number(m[2]) : Number(m[2])) : NaN;
1205
+ if (!Number.isInteger(n)) return { error: `"${String(input ?? "").trim()}" is not a floor. Type a whole number: 1 for L1, 0 for G, -1 for B1.` };
1206
+ if (n < FLOOR_MIN || n > FLOOR_MAX) return { error: `Floors go from ${floorName(FLOOR_MIN)} to ${floorName(FLOOR_MAX)}.` };
1207
+ if (existing.includes(n)) return { error: `${floorName(n)} is already a floor - pick it in the floor list.` };
1208
+ return { floorNo: n };
1209
+ }
1210
+
1193
1211
  /**
1194
1212
  * Why Save must wait, in plain words - or null. The handoff's sentence for
1195
1213
  * an unnamed zone; then what the API would refuse (a lift on one floor,
@@ -1205,7 +1223,7 @@ export function editorProblem(state: TEditorState): string | null {
1205
1223
  const lonely = state.shapes.find((s) => s.kind === "lift" && (s.floors?.length ?? 0) < 2);
1206
1224
  if (lonely) {
1207
1225
  const a = shapeAnchor(lonely);
1208
- return `Can't save yet: the lift shaft at (${a.x}, ${a.y}) serves only one floor. A lift needs at least two floors - this building's robots report only ${floorName(lonely.floorNo)}.`;
1226
+ return `Can't save yet: the lift shaft at (${a.x}, ${a.y}) serves only one floor. A lift needs at least two floors - select it and tick Stops for another floor (use Add floor beside the floor list if that floor is not there yet).`;
1209
1227
  }
1210
1228
  const long = state.shapes.find((s) => (s.kind === "zone" || s.kind === "waypoint" || s.kind === "fiducial") && (s.name ?? "").length > 60);
1211
1229
  if (long) return `Can't save yet: the name "${long.name!.slice(0, 20)}…" is too long. Use 60 characters or fewer.`;
@@ -1442,6 +1460,21 @@ export function liftQueueRows(lift: Record<string, any>, now = Date.now()): TLif
1442
1460
  const ROW_H = 44;
1443
1461
  const CAR_H = ROW_H - 14;
1444
1462
 
1463
+ /**
1464
+ * A floor as the real lift shows it ("B1", "L34", "34", "G") -> the car's y on the rails. Between two rails
1465
+ * (a floor the map has no rail for) the car sits proportionally between them; past the ends it sits on the end.
1466
+ */
1467
+ export function liftFloorTop(display: unknown, floors: number[], topOf: (f: number) => number): number | null {
1468
+ const parsed = newFloorNo(String(display ?? "").replace(/\s+/g, ""), []);
1469
+ if (!("floorNo" in parsed) || !floors.length) return null;
1470
+ const v = parsed.floorNo;
1471
+ if (v <= floors[0]!) return topOf(floors[0]!);
1472
+ if (v >= floors.at(-1)!) return topOf(floors.at(-1)!);
1473
+ const i = floors.findIndex((f) => f >= v);
1474
+ const lo = floors[i - 1]!, hi = floors[i]!;
1475
+ return topOf(lo) + ((v - lo) / (hi - lo)) * (topOf(hi) - topOf(lo));
1476
+ }
1477
+
1445
1478
  /** The whole card: header words, car status, shaft geometry, rows (README §5). */
1446
1479
  export function liftCard(lift: Record<string, any>, now = Date.now()) {
1447
1480
  const rows = liftQueueRows(lift, now);
@@ -1454,23 +1487,37 @@ export function liftCard(lift: Record<string, any>, now = Date.now()) {
1454
1487
  const rest = floors.includes(idleFloor) ? idleFloor : floors[0] ?? 1;
1455
1488
  const from = riding ? riding.fromNo : rest;
1456
1489
  const to = riding ? riding.toNo : rest;
1490
+ // The real lift (read-only gateway), when no robot ride is drawn: the car follows its floor, doors and direction.
1491
+ const gw = lift?.gateway;
1492
+ const real = !riding && gw?.status === "connected" && gw.state ? gw.state : null;
1493
+ const realTop = real ? liftFloorTop(real.currentFloor, floors, topOf) : null;
1494
+ const live = realTop != null;
1495
+ const realAlarm = live && [3, 4, 5].includes(Number(real.currentMode));
1496
+ const arrow = live ? (real.motionState === 1 ? "▲" : real.motionState === 2 ? "▼" : "") : "";
1457
1497
  return {
1458
1498
  name: String(lift?.liftName ?? "Lift"),
1459
1499
  sub: rows.length ? `${riding ? 1 : 0} riding · ${waiting.length} waiting` : "idle",
1460
- carText: riding ? `Car · ${riding.robot} · ${riding.from} → ${riding.to}` : "Car idle",
1461
- carLabel: riding ? riding.robot.replace(/^HERO-/, "") : "",
1462
- carTone: riding ? "ink" : rows.some((r) => r.state === "timed out") ? "accent" : "neutral",
1500
+ carText: riding
1501
+ ? `Car · ${riding.robot} · ${riding.from} → ${riding.to}`
1502
+ : live
1503
+ ? `Car · floor ${real.currentFloor}${MOTION_TEXT[real.motionState] ? ` · ${MOTION_TEXT[real.motionState]!.toLowerCase()}` : ""}`
1504
+ : "Car idle",
1505
+ carLabel: riding ? riding.robot.replace(/^HERO-/, "") : arrow,
1506
+ carTone: riding ? "ink" : realAlarm || rows.some((r) => r.state === "timed out") ? "accent" : live ? "ink" : "neutral",
1507
+ /** Real lift drawn live: the car glides between readings; doors open (1) / half (0.5, moving) / shut (0). */
1508
+ live,
1509
+ doors: live ? (real.doorState === 2 ? 1 : real.doorState === 1 ? 0.5 : 0) : 0,
1463
1510
  riding: !!riding,
1464
1511
  empty: !rows.length,
1465
1512
  shaftH: (n - 1) * ROW_H + 24,
1466
1513
  carH: CAR_H,
1467
- fromTop: topOf(from) - CAR_H / 2,
1468
- toTop: topOf(to) - CAR_H / 2,
1514
+ fromTop: (live ? realTop! : topOf(from)) - CAR_H / 2,
1515
+ toTop: (live ? realTop! : topOf(to)) - CAR_H / 2,
1469
1516
  rails: [...floors].reverse().map((f) => ({
1470
1517
  floorNo: f,
1471
1518
  name: floorName(f),
1472
1519
  top: topOf(f),
1473
- lit: !!riding && (f === riding.fromNo || f === riding.toNo),
1520
+ lit: riding ? f === riding.fromNo || f === riding.toNo : live && realTop === topOf(f),
1474
1521
  waiting: waiting.filter((w) => w.fromNo === f).map((w) => w.robot),
1475
1522
  })),
1476
1523
  rows,