@songsid/agend 2.3.0-beta.11 → 2.3.0-beta.13

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.
Files changed (111) hide show
  1. package/README.md +6 -0
  2. package/README.zh-TW.md +6 -0
  3. package/dist/agent-whats-new.js +14 -9
  4. package/dist/agent-whats-new.js.map +1 -1
  5. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  6. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  7. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  8. package/dist/channel/adapters/discord.d.ts +2 -0
  9. package/dist/channel/adapters/discord.js +14 -4
  10. package/dist/channel/adapters/discord.js.map +1 -1
  11. package/dist/cli.js +12 -79
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +12 -0
  14. package/dist/config.js +13 -4
  15. package/dist/config.js.map +1 -1
  16. package/dist/daemon.d.ts +3 -0
  17. package/dist/daemon.js +25 -0
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/fleet-manager.d.ts +44 -2
  20. package/dist/fleet-manager.js +229 -63
  21. package/dist/fleet-manager.js.map +1 -1
  22. package/dist/fleet-process-handlers.d.ts +18 -0
  23. package/dist/fleet-process-handlers.js +69 -0
  24. package/dist/fleet-process-handlers.js.map +1 -0
  25. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  26. package/dist/general-knowledge/skills/web-appearance/SKILL.md +20 -3
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +27 -5
  28. package/dist/instance-lifecycle.d.ts +2 -0
  29. package/dist/instance-lifecycle.js +11 -6
  30. package/dist/instance-lifecycle.js.map +1 -1
  31. package/dist/outbound-schemas.d.ts +14 -1
  32. package/dist/outbound-schemas.js +8 -2
  33. package/dist/outbound-schemas.js.map +1 -1
  34. package/dist/public-web-gateway.js +4 -0
  35. package/dist/public-web-gateway.js.map +1 -1
  36. package/dist/restart-progress.d.ts +16 -1
  37. package/dist/restart-progress.js +74 -15
  38. package/dist/restart-progress.js.map +1 -1
  39. package/dist/settings-api.d.ts +4 -0
  40. package/dist/settings-api.js +27 -1
  41. package/dist/settings-api.js.map +1 -1
  42. package/dist/settings-confirmation.d.ts +3 -1
  43. package/dist/settings-confirmation.js +2 -0
  44. package/dist/settings-confirmation.js.map +1 -1
  45. package/dist/settings-effect.js +2 -1
  46. package/dist/settings-effect.js.map +1 -1
  47. package/dist/settings-http-confirmation.d.ts +11 -0
  48. package/dist/settings-http-confirmation.js +10 -0
  49. package/dist/settings-http-confirmation.js.map +1 -1
  50. package/dist/ui/app-effects.js +13 -1
  51. package/dist/ui/chat-effects.js +10 -3
  52. package/dist/ui/chat-store.js +32 -10
  53. package/dist/ui/chat-strings.js +18 -0
  54. package/dist/ui/chat-thread.js +5 -2
  55. package/dist/ui/effects-waker.js +70 -0
  56. package/dist/ui/fleet-canvas-layout.js +10 -4
  57. package/dist/ui/fleet-canvas.js +32 -2
  58. package/dist/ui/fleet-route-scene.js +207 -0
  59. package/dist/ui/fleet-route-worker.js +21 -0
  60. package/dist/ui/fleet-route.js +88 -0
  61. package/dist/ui/fleet-traffic.js +27 -11
  62. package/dist/ui/fleet-usage.js +35 -1
  63. package/dist/ui/panel-chat.js +67 -26
  64. package/dist/ui/panel-fleet.js +3 -3
  65. package/dist/ui/panel-settings.js +24 -0
  66. package/dist/ui/settings-appearance.js +2 -1
  67. package/dist/ui/settings-apply.js +3 -3
  68. package/dist/ui/settings-confirm.js +7 -2
  69. package/dist/ui/settings-owner-effects.js +6 -4
  70. package/dist/ui/settings-strings.js +2 -0
  71. package/dist/ui/shared/app-i18n.js +4 -0
  72. package/dist/ui/shared/app-prefs.js +12 -0
  73. package/dist/ui/shared/app-shell.js +38 -2
  74. package/dist/ui/shared/app-themes.js +116 -1
  75. package/dist/ui/shared/app.css +47 -3
  76. package/dist/ui/shared/card-strings.js +4 -0
  77. package/dist/ui/shared/effect-owner-source.js +8 -1
  78. package/dist/ui/shared/effect-registry.js +10 -2
  79. package/dist/ui/shared/identity-card.js +24 -6
  80. package/dist/ui/shared/identity-color.js +11 -7
  81. package/dist/ui/shared/owner-css.js +30 -1
  82. package/dist/ui/shared/ui-icons.js +2 -0
  83. package/dist/ui/shared/ui-menu.js +9 -3
  84. package/dist/ui/shared/web-authorize.js +47 -0
  85. package/dist/web-api.d.ts +6 -2
  86. package/dist/web-api.js +68 -15
  87. package/dist/web-api.js.map +1 -1
  88. package/dist/web-appearance.js +3 -1
  89. package/dist/web-appearance.js.map +1 -1
  90. package/dist/web-effect.d.ts +1 -1
  91. package/dist/web-host-guard.d.ts +11 -0
  92. package/dist/web-host-guard.js +11 -0
  93. package/dist/web-host-guard.js.map +1 -1
  94. package/dist/web-libavoid.d.ts +14 -0
  95. package/dist/web-libavoid.js +46 -0
  96. package/dist/web-libavoid.js.map +1 -0
  97. package/dist/web-owner-css-store.d.ts +44 -1
  98. package/dist/web-owner-css-store.js +70 -9
  99. package/dist/web-owner-css-store.js.map +1 -1
  100. package/dist/web-prefs.js +13 -0
  101. package/dist/web-prefs.js.map +1 -1
  102. package/dist/web-settings-tools.d.ts +5 -0
  103. package/dist/web-settings-tools.js +38 -7
  104. package/dist/web-settings-tools.js.map +1 -1
  105. package/dist/web-theme.d.ts +29 -0
  106. package/dist/web-theme.js +89 -1
  107. package/dist/web-theme.js.map +1 -1
  108. package/dist/yaml-shared-nodes.d.ts +25 -0
  109. package/dist/yaml-shared-nodes.js +185 -0
  110. package/dist/yaml-shared-nodes.js.map +1 -0
  111. package/package.json +2 -1
@@ -0,0 +1,207 @@
1
+ // #2182: the org canvas's traffic lines routed around the canvas, not along fixed lanes. libavoid (Adaptagrams, through
2
+ // libavoid-js 0.4.5, LGPL-2.1-or-later, an npm dependency served unmodified from /ui/vendor/) routes each line between
3
+ // fixed obstacles; this module says what the obstacles are (pure: the worker, the page and the tests share it).
4
+ //
5
+ // - Obstacles: every card (General's and the members'), a folded team's box, each open box's header band (its name and
6
+ // count are drawn over the lines) and its border as wall pieces in front of its cards. The gaps between the pieces —
7
+ // the box's padding and the gutters between its rows and columns (L.pad, L.gap: wide enough for a few lines each)
8
+ // — are the only doors, so a line crosses a border at right angles, through a door, and never runs along it.
9
+ // - Ends: pins on each card's sides (several on the long sides, one on the short), facing out; a line ends on a card,
10
+ // never on a box. A card with more lines than pins gets more, so two lines never start at one point.
11
+ // - Separation: libavoid nudges lines that share a run ROUTE.NUDGE apart; the same two ends and kind are one line (×N)
12
+ // before they get here (fleet-traffic.js).
13
+ // A route that comes back otherwise (not orthogonal, not ending on its two shapes) is not drawn: that line keeps the
14
+ // lane route (fleet-traffic.js route()).
15
+ import { OC } from "./fleet-canvas-layout.js";
16
+
17
+ /** Buffer around every obstacle, the distance nudging keeps between lines, the cost of a bend, pins on a long side. */
18
+ export const ROUTE = { BUF: 5, NUDGE: 8, SEG: 60, PINS: 4, MAX_PINS: 64, MAX_POINTS: 64, MAX_SHAPES: 600, MAX_CONNS: 120 };
19
+ /** libavoid's ConnDirFlags: the way a line leaves a pin. */
20
+ const UP = 1, DOWN = 2, LEFT = 4, RIGHT = 8;
21
+ const PINS_PER_CARD = ROUTE.PINS * 2 + 2;
22
+
23
+ /** The shape a line's end is on: a card (General's or a member's) or a folded team's box. Null: not routed here. */
24
+ function endShape(end) {
25
+ if (!end) return null;
26
+ if (end.kind === "top" || end.kind === "cell") return { key: end.key, x: end.c.x, y: end.c.y, w: end.c.w, h: end.c.h };
27
+ if (end.kind === "box" && end.b.folded) return { key: end.key, x: end.b.x, y: end.b.y, w: end.b.w, h: end.b.h };
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * The scene for layout `L` and the lines `lines` ([{ key, a, z }], a and z ends as fleet-traffic.js endOf gives them):
33
+ * { shapes: [{ x, y, w, h, pins }], conns: [{ key, a, z }], boxes: [{ x, y, w, h }] } — `pins` 0 none (an obstacle
34
+ * only), 1 the usual pins, more: that many on each long side (a busy card); every pin takes one line; `a`/`z` index
35
+ * shapes; `boxes` the open teams' outlines (separate() keeps lines off them). A line whose end is not a card or a folded box is left out (it keeps its lane).
36
+ */
37
+ export function routeScene(L, lines) {
38
+ const shapes = [], boxes = [], at = new Map();
39
+ const add = (s, pins) => { shapes.push({ x: s.x, y: s.y, w: s.w, h: s.h, pins }); return shapes.length - 1; };
40
+ for (const c of L.tops) at.set(c.key, add(c, 1));
41
+ const { NW, NH, HEAD } = OC, PAD = L.pad ?? OC.PAD, GAP = L.gap ?? OC.GAP;
42
+ for (const b of L.boxes) {
43
+ if (b.folded) { at.set(`box:${b.key}`, add(b, 1)); continue; }
44
+ if (!b.cells.length) { add(b, 0); continue; }
45
+ boxes.push({ x: b.x, y: b.y, w: b.w, h: b.h });
46
+ for (const c of b.cells) at.set(c.key, add(c, 1));
47
+ add({ x: b.x - 1, y: b.y - 1, w: b.w + 2, h: HEAD + 1 }, 0);
48
+ const lines = Math.ceil(b.cells.length / b.cols);
49
+ for (let col = 0; col < b.cols; col++) add({ x: b.x + PAD + col * (NW + GAP), y: b.y + b.h - 1, w: NW, h: 2 }, 0);
50
+ for (const x of [b.x - 1, b.x + b.w - 1]) for (let row = 0; row < lines; row++) add({ x, y: b.y + HEAD + PAD + row * (NH + GAP), w: 2, h: NH }, 0);
51
+ }
52
+ const conns = [], demand = new Map();
53
+ for (const ln of lines) {
54
+ const a = endShape(ln.a), z = endShape(ln.z);
55
+ if (!a || !z || !at.has(a.key) || !at.has(z.key) || a.key === z.key) continue;
56
+ const ia = at.get(a.key), iz = at.get(z.key);
57
+ conns.push({ key: ln.key, a: ia, z: iz });
58
+ for (const i of [ia, iz]) demand.set(i, (demand.get(i) || 0) + 1);
59
+ }
60
+ // A line each: a shape with more lines than pins gets more pins on its long sides (two lines never leave one point:
61
+ // libavoid cannot nudge apart runs that start at the same point).
62
+ for (const [i, n] of demand) if (n > PINS_PER_CARD) shapes[i].pins = Math.min(ROUTE.MAX_PINS, Math.ceil((n - 2) / 2));
63
+ return { shapes, conns, boxes };
64
+ }
65
+
66
+ /** A scene from anywhere else (the worker's message): only numbers in range, only indexes it has. Null: refused. */
67
+ export function sceneOk(s) {
68
+ if (!s || !Array.isArray(s.shapes) || !Array.isArray(s.conns) || !Array.isArray(s.boxes) || s.shapes.length > ROUTE.MAX_SHAPES || s.conns.length > ROUTE.MAX_CONNS
69
+ || s.boxes.length > ROUTE.MAX_SHAPES) return null;
70
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < 1e6;
71
+ for (const b of s.boxes) if (!b || ![b.x, b.y, b.w, b.h].every(num) || b.w <= 0 || b.h <= 0) return null;
72
+ for (const sh of s.shapes) if (!sh || ![sh.x, sh.y, sh.w, sh.h].every(num) || sh.w <= 0 || sh.h <= 0 || !Number.isInteger(sh.pins) || sh.pins < 0 || sh.pins > ROUTE.MAX_PINS) return null;
73
+ for (const c of s.conns) {
74
+ if (!c || typeof c.key !== "string" || c.key.length > 2048) return null;
75
+ for (const i of [c.a, c.z]) if (!Number.isInteger(i) || i < 0 || i >= s.shapes.length || !s.shapes[i].pins) return null;
76
+ }
77
+ return s;
78
+ }
79
+
80
+ const onEdge = (p, s) => (Math.abs(p.x - s.x) < 0.5 || Math.abs(p.x - s.x - s.w) < 0.5) && p.y >= s.y - 0.5 && p.y <= s.y + s.h + 0.5
81
+ || (Math.abs(p.y - s.y) < 0.5 || Math.abs(p.y - s.y - s.h) < 0.5) && p.x >= s.x - 0.5 && p.x <= s.x + s.w + 0.5;
82
+ /**
83
+ * A route for connection `c` of `scene`, or null: at most MAX_POINTS finite points, every segment horizontal or
84
+ * vertical, starting on its first shape's edge and ending on its second's.
85
+ */
86
+ export function routeOk(pts, scene, c) {
87
+ if (!Array.isArray(pts) || pts.length < 2 || pts.length > ROUTE.MAX_POINTS) return null;
88
+ for (const p of pts) if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
89
+ for (let i = 1; i < pts.length; i++) if (pts[i].x !== pts[i - 1].x && pts[i].y !== pts[i - 1].y) return null;
90
+ if (!onEdge(pts[0], scene.shapes[c.a]) || !onEdge(pts[pts.length - 1], scene.shapes[c.z])) return null;
91
+ return pts.map((p) => ({ x: p.x, y: p.y }));
92
+ }
93
+
94
+ /** The worker's answer to a route request `m`: its scene checked (sceneOk) before libavoid sees any of it. */
95
+ export function answerRoute(A, m) {
96
+ const scene = sceneOk(m && m.scene);
97
+ return { type: "routes", id: m.id, routes: scene ? [...routeWith(A, scene)] : null };
98
+ }
99
+
100
+ /**
101
+ * libavoid nudges lines that share a run apart, but not always: in a channel shared by many lines it packs them closer
102
+ * than ROUTE.NUDGE, and lines fanning out of one card can come back on the very same run. This pass moves the middle
103
+ * segments of such lines (never a line's first or last, which end on a pin) sideways to the nearest place at least
104
+ * SEPARATE from every parallel line along them — only where the moved segment and the two it joins stay clear of
105
+ * every shape's buffer and keep their direction. What cannot move stays as libavoid left it.
106
+ */
107
+ export const SEPARATE = 6;
108
+ export function separate(routes, scene) {
109
+ const B = ROUTE.BUF - 0.5, OVER = 8;
110
+ const segsOf = (r, p) => p.slice(1).map((q, i) => ({ r, i, h: p[i].y === q.y }));
111
+ const coord = (p, s) => (s.h ? p[s.i].y : p[s.i].x);
112
+ const span = (p, s) => (s.h ? [Math.min(p[s.i].x, p[s.i + 1].x), Math.max(p[s.i].x, p[s.i + 1].x)] : [Math.min(p[s.i].y, p[s.i + 1].y), Math.max(p[s.i].y, p[s.i + 1].y)]);
113
+ /**
114
+ * A segment from a to b clear of every shape's buffer (but `own`, the card a line's first or last run starts on at its
115
+ * pin), and not along a box's border (through a door, say).
116
+ */
117
+ const clear = (a, b, own = -1) => {
118
+ const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x), y0 = Math.min(a.y, b.y), y1 = Math.max(a.y, b.y), h = y0 === y1;
119
+ return scene.shapes.every((sh, k) => k === own || x1 <= sh.x - B || x0 >= sh.x + sh.w + B || y1 <= sh.y - B || y0 >= sh.y + sh.h + B)
120
+ && (scene.boxes ?? []).every((bx) => (h ? [bx.y, bx.y + bx.h].every((v) => Math.abs(v - y0) >= SEPARATE + 1) || Math.min(x1, bx.x + bx.w) - Math.max(x0, bx.x) <= 12
121
+ : [bx.x, bx.x + bx.w].every((v) => Math.abs(v - x0) >= SEPARATE + 1) || Math.min(y1, bx.y + bx.h) - Math.max(y0, bx.y) <= 12));
122
+ };
123
+ const keys = [...routes.keys()];
124
+ const connOf = new Map(scene.conns.map((c) => [c.key, c]));
125
+ const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
126
+ /** The segments of other lines (and this one's) parallel to segment s of line r, overlapping it, closer than SEPARATE. */
127
+ const near = (r, s, c, lo, hi) => {
128
+ const out = [];
129
+ pts.forEach((p, r2) => segsOf(r2, p).forEach((t) => {
130
+ if (t.h !== s.h || (r2 === r && t.i === s.i)) return;
131
+ const [l2, h2] = span(p, t);
132
+ if (Math.min(hi, h2) - Math.max(lo, l2) > OVER) out.push(coord(p, t));
133
+ }));
134
+ return out.filter((c2) => Math.abs(c2 - c) < SEPARATE);
135
+ };
136
+ for (let pass = 0; pass < 8; pass++) {
137
+ let moved = false;
138
+ pts.forEach((p, r) => segsOf(r, p).forEach((s) => {
139
+ if (s.i === 0 || s.i + 1 >= p.length - 1) return; // a first or last segment ends on a pin
140
+ const c = coord(p, s), [lo, hi] = span(p, s);
141
+ const close = near(r, s, c, lo, hi);
142
+ if (!close.length) return;
143
+ // Try the nearest places SEPARATE from the lines it is close to, both ways.
144
+ const tries = [...new Set(close.flatMap((c2) => [1, 2, 3, 4, 5, 6].flatMap((m) => [c2 + m * SEPARATE, c2 - m * SEPARATE])))].sort((u, v) => Math.abs(u - c) - Math.abs(v - c));
145
+ for (const c2 of tries) {
146
+ const a = { ...p[s.i] }, b = { ...p[s.i + 1] };
147
+ if (s.h) { a.y = c2; b.y = c2; } else { a.x = c2; b.x = c2; }
148
+ const prev = p[s.i - 1], next = p[s.i + 2];
149
+ // The joining segments keep their direction (never shrink through zero).
150
+ const dir = (u, v) => Math.sign(s.h ? v.y - u.y : v.x - u.x);
151
+ if (dir(prev, a) !== dir(prev, p[s.i]) || dir(b, next) !== dir(p[s.i + 1], next) || dir(prev, a) === 0 || dir(b, next) === 0) continue;
152
+ const c0 = connOf.get(keys[r]);
153
+ if (!clear(a, b) || !clear(prev, a, s.i - 1 === 0 && c0 ? c0.a : -1) || !clear(b, next, s.i + 2 === p.length - 1 && c0 ? c0.z : -1)) continue;
154
+ const old = [p[s.i], p[s.i + 1]];
155
+ p[s.i] = a; p[s.i + 1] = b;
156
+ const ok = !near(r, s, c2, lo, hi).length && [s.i - 1, s.i + 1].every((j) => { const t = { r, i: j, h: !s.h }; const [l3, h3] = span(p, t); return !near(r, t, coord(p, t), l3, h3).length; });
157
+ if (ok) { moved = true; break; }
158
+ [p[s.i], p[s.i + 1]] = old;
159
+ }
160
+ }));
161
+ if (!moved) break;
162
+ }
163
+ return new Map(keys.map((k, i) => [k, pts[i]]));
164
+ }
165
+
166
+ /**
167
+ * Route `scene` with libavoid instance `A` (libavoid-js 0.4.5's: its constants are numbers on `A`). A Map of
168
+ * connection key → points, for the routes that pass routeOk only.
169
+ */
170
+ export function routeWith(A, scene) {
171
+ const r = new A.Router(A.OrthogonalRouting);
172
+ try {
173
+ r.setRoutingParameter(A.shapeBufferDistance, ROUTE.BUF);
174
+ r.setRoutingParameter(A.idealNudgingDistance, ROUTE.NUDGE);
175
+ r.setRoutingParameter(A.segmentPenalty, ROUTE.SEG);
176
+ r.setRoutingOption(A.nudgeOrthogonalSegmentsConnectedToShapes, true);
177
+ r.setRoutingOption(A.nudgeSharedPathsWithCommonEndPoint, true);
178
+ for (const [k, v] of Object.entries(ROUTE.EXTRA_OPT ?? {})) r.setRoutingOption(A[k], v);
179
+ for (const [k, v] of Object.entries(ROUTE.EXTRA_PARAM ?? {})) r.setRoutingParameter(A[k], v);
180
+ // The router owns its shapes, pins and connectors (A.destroy(r) frees them); the points, rectangles and ends it
181
+ // copies are freed here, or the wasm heap grows with every route.
182
+ const temp = (o) => { made.push(o); return o; }, made = [];
183
+ const refs = scene.shapes.map((s) => {
184
+ const ref = new A.ShapeRef(r, temp(new A.Rectangle(temp(new A.Point(s.x, s.y)), temp(new A.Point(s.x + s.w, s.y + s.h)))));
185
+ if (s.pins) {
186
+ const k = s.pins === 1 ? ROUTE.PINS : s.pins;
187
+ const pin = (x, y, dir) => new A.ShapeConnectionPin(ref, 1, x, y, true, 0, dir).setExclusive(true);
188
+ for (let i = 1; i <= k; i++) { pin(i / (k + 1), 0, UP); pin(i / (k + 1), 1, DOWN); }
189
+ pin(0, 0.5, LEFT); pin(1, 0.5, RIGHT);
190
+ }
191
+ return ref;
192
+ });
193
+ const conns = scene.conns.map((c) => new A.ConnRef(r, temp(new A.ConnEnd(refs[c.a], 1)), temp(new A.ConnEnd(refs[c.z], 1))));
194
+ for (const o of made.splice(0)) A.destroy(o);
195
+ r.processTransaction();
196
+ const out = new Map();
197
+ scene.conns.forEach((c, i) => {
198
+ const line = conns[i].displayRoute(), pts = [];
199
+ for (let k = 0; k < line.size() && k <= ROUTE.MAX_POINTS; k++) { const p = line.get_ps(k); pts.push({ x: p.x, y: p.y }); }
200
+ const ok = routeOk(pts, scene, c);
201
+ if (ok) out.set(c.key, ok);
202
+ });
203
+ return ROUTE.SEPARATE === false ? out : separate(out, scene);
204
+ } finally {
205
+ A.destroy(r);
206
+ }
207
+ }
@@ -0,0 +1,21 @@
1
+ // #2182: the worker that routes the org canvas's traffic lines (fleet-route-scene.js says what to route). It is served
2
+ // with a policy of its own (web-host-guard.ts ROUTE_WORKER_CSP): WebAssembly may run here, as it may not on the page,
3
+ // and it may reach nothing — the page fetches libavoid.wasm and hands the bytes over; they are instantiated from a
4
+ // blob: URL. It receives numbers and answers numbers; the page checks both.
5
+ import { AvoidLib } from "/ui/vendor/libavoid-0.4.5/libavoid.js";
6
+ import { answerRoute } from "./fleet-route-scene.js";
7
+
8
+ let ready = null;
9
+ self.onmessage = (ev) => {
10
+ const m = ev.data;
11
+ if (!m || typeof m !== "object") return;
12
+ if (m.type === "init" && !ready && m.wasm instanceof ArrayBuffer) {
13
+ const url = URL.createObjectURL(new Blob([m.wasm], { type: "application/wasm" }));
14
+ ready = AvoidLib.load(url).then(() => AvoidLib.getInstance()).finally(() => URL.revokeObjectURL(url));
15
+ ready.then(() => self.postMessage({ type: "ready" }), () => self.postMessage({ type: "failed" }));
16
+ return;
17
+ }
18
+ if (m.type === "route" && ready && Number.isInteger(m.id)) {
19
+ ready.then((A) => self.postMessage(answerRoute(A, m))).catch(() => self.postMessage({ type: "routes", id: m.id, routes: null }));
20
+ }
21
+ };
@@ -0,0 +1,88 @@
1
+ // #2182: the page's side of the traffic router — one worker (fleet-route-worker.js), started the first time Live
2
+ // traffic is drawn, fed the libavoid.wasm bytes the page fetched. One scene at a time: a newer one replaces any still
3
+ // waiting (the older one's answer would be stale anyway). A worker that fails, or takes longer than TIMEOUT_MS, is
4
+ // ended; after FAIL_LIMIT failures in a row this page stops asking, and every line keeps its lane route (the caller's
5
+ // fallback for a null answer).
6
+ import { routeOk } from "./fleet-route-scene.js";
7
+
8
+ export const WORKER_URL = "/ui/js/fleet-route-worker.js";
9
+ export const WASM_URL = "/ui/vendor/libavoid-0.4.5/libavoid.wasm";
10
+ export const TIMEOUT_MS = 4000;
11
+ const FAIL_LIMIT = 2;
12
+
13
+ /**
14
+ * A client: route(scene) → a Promise of a Map (connection key → points, checked against the scene) or null (not
15
+ * routed: superseded, failed, unavailable). `deps` replaces Worker, fetch and the clock in tests.
16
+ */
17
+ export function createRouteClient(deps = {}) {
18
+ const W = deps.Worker ?? (typeof Worker === "function" ? Worker : null);
19
+ const get = deps.fetch ?? ((...a) => fetch(...a));
20
+ const timers = deps.timers ?? { set: (f, ms) => setTimeout(f, ms), clear: (t) => clearTimeout(t) };
21
+ let worker = null, wasm = null, fails = 0, nextId = 1;
22
+ let busy = null, waiting = null; // { id, scene, resolve, timer }
23
+ const end = () => { try { worker?.terminate(); } catch { /* gone */ } worker = null; };
24
+ const fail = () => {
25
+ fails++;
26
+ end();
27
+ if (busy) { timers.clear(busy.timer); busy.resolve(null); busy = null; }
28
+ if (fails >= FAIL_LIMIT && waiting) { waiting.resolve(null); waiting = null; }
29
+ pump();
30
+ };
31
+ async function start(job) {
32
+ if (!wasm) {
33
+ const res = await get(WASM_URL, { credentials: "same-origin" });
34
+ if (!res.ok) throw new Error(`libavoid.wasm: ${res.status}`);
35
+ wasm = await res.arrayBuffer();
36
+ }
37
+ // #2194 review: closed, timed out or replaced while the .wasm was on its way — start nothing (a worker made now
38
+ // would be nobody's, and a later start would make another).
39
+ if (busy !== job || worker) return;
40
+ const w = new W(WORKER_URL, { type: "module" });
41
+ w.onmessage = (ev) => {
42
+ const m = ev.data;
43
+ if (!m || typeof m !== "object" || w !== worker) return;
44
+ if (m.type === "failed") { fail(); return; }
45
+ if (m.type !== "routes" || !busy || m.id !== busy.id) return;
46
+ const { scene, resolve, timer } = busy;
47
+ busy = null;
48
+ timers.clear(timer);
49
+ if (!Array.isArray(m.routes)) { fail(); resolve(null); return; }
50
+ fails = 0;
51
+ const out = new Map();
52
+ for (const pair of m.routes.slice(0, scene.conns.length)) {
53
+ if (!Array.isArray(pair) || typeof pair[0] !== "string") continue;
54
+ const c = scene.conns.find((x) => x.key === pair[0]);
55
+ const pts = c && routeOk(pair[1], scene, c);
56
+ if (pts) out.set(c.key, pts);
57
+ }
58
+ resolve(out);
59
+ pump();
60
+ };
61
+ w.onerror = (e) => { e?.preventDefault?.(); if (w === worker) fail(); };
62
+ worker = w;
63
+ w.postMessage({ type: "init", wasm: wasm.slice(0) });
64
+ }
65
+ function pump() {
66
+ if (busy || !waiting) return;
67
+ if (fails >= FAIL_LIMIT || !W) { waiting.resolve(null); waiting = null; return; }
68
+ busy = waiting; waiting = null;
69
+ const job = busy;
70
+ job.timer = timers.set(() => { if (busy === job) fail(); }, TIMEOUT_MS);
71
+ (worker ? Promise.resolve() : start(job)).then(() => {
72
+ if (busy === job) worker.postMessage({ type: "route", id: job.id, scene: job.scene });
73
+ }, () => { if (busy === job) fail(); });
74
+ }
75
+ return {
76
+ route(scene) {
77
+ if (waiting) waiting.resolve(null);
78
+ return new Promise((resolve) => { waiting = { id: nextId++, scene, resolve, timer: null }; pump(); });
79
+ },
80
+ /** Off: the worker ended; what was waiting gets null. */
81
+ close() {
82
+ if (waiting) { waiting.resolve(null); waiting = null; }
83
+ if (busy) { timers.clear(busy.timer); busy.resolve(null); busy = null; }
84
+ end();
85
+ },
86
+ get available() { return !!W && fails < FAIL_LIMIT; },
87
+ };
88
+ }
@@ -179,7 +179,9 @@ function endOf(L, name) {
179
179
  // ── Routing: lanes the layout keeps empty ──
180
180
 
181
181
  /** A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards. */
182
- const laneOffset = (o) => Math.max(-OC.PAD / 2 + 6, Math.min(OC.PAD / 2 - 6, o * 0.5));
182
+ const laneOffset = (o, PAD = OC.PAD) => Math.max(-PAD / 2 + 6, Math.min(PAD / 2 - 6, o * 0.5));
183
+ /** The layout's own box padding and gutter (a phone's are narrower). */
184
+ const spacing = (L) => ({ PAD: L.pad ?? OC.PAD, GAP: L.gap ?? OC.GAP });
183
185
 
184
186
  const firstBusOf = (L) => Math.min(...L.boxes.map((b) => b.busY));
185
187
  /** From an end up to its row's bus lane: the points, and the lane's y. `o` spreads lines sharing a lane. */
@@ -194,9 +196,9 @@ function outward(L, end, o) {
194
196
  const x = b.x + b.w / 2 + o * 1.5;
195
197
  return { pts: [{ x, y: b.y }, { x, y: b.busY + o }], bus: b.busY + o };
196
198
  }
197
- const { PAD, GAP, NW, NH } = OC;
199
+ const { NW, NH } = OC, { PAD, GAP } = spacing(L);
198
200
  const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
199
- const io = laneOffset(o);
201
+ const io = laneOffset(o, PAD);
200
202
  const leftLane = b.x + PAD / 2 + io, rightLane = b.x + b.w - PAD / 2 + io;
201
203
  let pts;
202
204
  if (col === 0 || col === b.cols - 1) {
@@ -217,9 +219,9 @@ function outward(L, end, o) {
217
219
  * else down into the gutter under its row (no card is there) and across. The last point is on the lane.
218
220
  */
219
221
  function toLane(L, end, lane, o) {
220
- const { PAD, GAP, NW, NH } = OC;
222
+ const { NW, NH } = OC, { PAD, GAP } = spacing(L);
221
223
  const b = end.b, card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
222
- const io = laneOffset(o);
224
+ const io = laneOffset(o, PAD);
223
225
  const left = lane < b.x + b.w / 2;
224
226
  if ((left && col === 0) || (!left && col === b.cols - 1)) {
225
227
  const cy = card.y + NH / 2 + io;
@@ -230,8 +232,8 @@ function toLane(L, end, lane, o) {
230
232
  }
231
233
  /** Two cards in one open box: joined inside it, through the side lane nearer to them both (never out to the bus). */
232
234
  function inBox(L, a, z, o) {
233
- const b = a.b, { PAD } = OC;
234
- const io = laneOffset(o);
235
+ const b = a.b, { PAD } = spacing(L);
236
+ const io = laneOffset(o, PAD);
235
237
  const mid = ((a.i % b.cols) + (z.i % b.cols)) / 2;
236
238
  const lane = mid <= (b.cols - 1) / 2 ? b.x + PAD / 2 + io : b.x + b.w - PAD / 2 + io;
237
239
  return [...toLane(L, a, lane, o), ...toLane(L, z, lane, o).reverse()];
@@ -289,9 +291,12 @@ const settled = (phase) => (phase === "fading" || phase === "stale" ? "fading" :
289
291
 
290
292
  /**
291
293
  * The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
292
- * { key, hue, phase, n, from, to, d, pts, badge } — `from`/`to` the instance (or team) names it joins, for search.
294
+ * { key, hue, phase, n, from, to, d, pts, badge, a, z, routed } — `from`/`to` the instance (or team) names it joins, for
295
+ * search; `a`/`z` its two ends (fleet-route-scene.js routes the ones on cards). `routed` (#2182): the router's points by
296
+ * line key (fleet-route.js); a line it has none for keeps its lane route — or, while a new answer is on its way, the
297
+ * points the same two ends and kind had in the last one (a line changing phase keeps its place).
293
298
  */
294
- export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
299
+ export function trafficEdges(items, L, { far = false, max = MAX_EDGES, routed = null } = {}) {
295
300
  if (!L.boxes.length && L.tops.length < 2) return [];
296
301
  // A top's way out is the first row's bus: with no box there is none, so only top ↔ top lines (betweenTops) are drawn.
297
302
  const groups = new Map();
@@ -321,10 +326,21 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES } = {}) {
321
326
  add(`${a.key}>${z.key}|${hue(it.kind)}|${phase}`, { a, z, hue: hue(it.kind), phase, n: 1, names });
322
327
  }
323
328
  const list = [...groups.entries()].sort((x, y) => RANK[x[1].phase] - RANK[y[1].phase]).slice(0, max);
329
+ const lent = new Set();
330
+ const routedBy = (key) => {
331
+ if (!routed || far) return null;
332
+ const exact = routed.get(key);
333
+ if (exact) return exact;
334
+ const kin = key.slice(0, key.lastIndexOf("|") + 1);
335
+ for (const [k, pts] of routed) if (k.startsWith(kin) && !groups.has(k) && !lent.has(k)) { lent.add(k); return pts; }
336
+ return null;
337
+ };
324
338
  return list.map(([key, g], i) => {
325
339
  const o = ((i % 7) - 3) * 7; // neighbours 7 px apart (within the ±22 px bus lane)
326
- const pts = route(L, g.a, g.z, o);
327
- return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d: pathOf(pts), pts, badge: g.n > 1 ? badgeAt(pts) : null };
340
+ const viaRouter = routedBy(key);
341
+ const pts = viaRouter ?? route(L, g.a, g.z, o);
342
+ return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d: pathOf(pts), pts, badge: g.n > 1 ? badgeAt(pts) : null,
343
+ a: g.a, z: g.z, routed: !!viaRouter };
328
344
  });
329
345
  }
330
346
 
@@ -183,6 +183,38 @@ export function zonesChanged(report) {
183
183
  return zones.length > 1 || (zones.length === 1 && zones[0] !== report.zone) ? zones : null;
184
184
  }
185
185
 
186
+ /** Estimated pixel width of one character in the 9 px `.us-axis` label. */
187
+ const LABEL_CHAR_W = 5.5;
188
+
189
+ /**
190
+ * The 0-based indices of dates that should receive an x-axis label.
191
+ * Keeps the first (0) and last (n-1) when they are far enough apart not to
192
+ * overlap. When first and last are too close (n-1 < step), only the last
193
+ * (most recent) is shown — overlap-free takes precedence over keeping both
194
+ * ends. Intermediate labels are thinned so that no two consecutive shown
195
+ * labels are closer than `labelWidthPx` pixels apart.
196
+ *
197
+ * @param {number} n Number of data points (dates).
198
+ * @param {number} slotPx Pixels per slot (bar + gap).
199
+ * @param {number} labelWidthPx Estimated rendered width of one label.
200
+ * @returns {Set<number>}
201
+ */
202
+ export function thinXLabels(n, slotPx, labelWidthPx) {
203
+ if (n <= 0) return new Set();
204
+ if (n === 1) return new Set([0]);
205
+ // Minimum slot steps between label centres (+ 2 px breathing room).
206
+ const step = Math.max(1, Math.ceil((labelWidthPx + 2) / slotPx));
207
+ // If first and last are too close to both be shown without overlap, keep
208
+ // only the last (most recent). The caller's "always first+last" contract
209
+ // is relaxed here: overlap-free takes precedence.
210
+ if (n - 1 < step) return new Set([n - 1]);
211
+ const idx = [0];
212
+ // Add intermediates that are at least `step` slots away from n-1.
213
+ for (let i = step; i < n - step; i += step) idx.push(i);
214
+ idx.push(n - 1); // always include last
215
+ return new Set(idx);
216
+ }
217
+
186
218
  /** The trend's series: the top `TOP` agents by the measure over the range, then "others"; one value per date. */
187
219
  export function trendSeries(report, dates) {
188
220
  const priced = report.totals && report.totals.amount !== null && report.totals.amount > 0;
@@ -230,6 +262,8 @@ function Trend({ report, dates }) {
230
262
  const max = Math.max(...sums, 0) || 1;
231
263
  const what = s.priced ? tu("amount") : tu("trendTokens"), others = s.names.length > TOP;
232
264
  const label = others ? tu("trendLabel", what, TOP, dates[0], dates[dates.length - 1]) : tu("trendLabelAll", what, s.names.length, dates[0], dates[dates.length - 1]);
265
+ // Thin x-axis labels: "MM-DD" (5 chars) × LABEL_CHAR_W ≈ 27.5 px at 9 px sans-serif.
266
+ const shownLabels = thinXLabels(dates.length, slot, 5 * LABEL_CHAR_W);
233
267
  return html`<section class="us-trend card" aria-labelledby="us-trend-h">
234
268
  <h3 id="us-trend-h">${tu("trend")} <span class="note">· ${s.priced ? tu("amount") : tu("trendTokens")}</span></h3>
235
269
  <p class="note us-scale">${tu("scale", fmtMeasure(s.priced, max))}</p>
@@ -243,7 +277,7 @@ function Trend({ report, dates }) {
243
277
  const h = v[i] > 0 ? Math.max(1, ((base - top) * v[i]) / max) : 0;
244
278
  y -= h;
245
279
  return h ? html`<rect key=${k} class=${`us-s${k}`} x=${left + i * slot + 2} y=${y} width=${slot - 4} height=${h}><title>${`${date} · ${s.names[k]}: ${fmtMeasure(s.priced, v[i])}`}</title></rect>` : null;
246
- })}${(dates.length - 1 - i) % 7 === 0 || dates.length <= 8 ? html`<text class="us-axis" x=${left + i * slot + slot / 2} y=${H - 6} text-anchor="middle">${date.slice(5)}</text>` : null}</g>`;
280
+ })}${shownLabels.has(i) ? html`<text class="us-axis us-xlabel" x=${left + i * slot + slot / 2} y=${H - 6} text-anchor="middle">${date.slice(5)}</text>` : null}</g>`;
247
281
  })}
248
282
  </svg></div>
249
283
  <${EarlierHint} wide=${wide} />