@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.14

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 (89) hide show
  1. package/README.md +6 -0
  2. package/README.zh-TW.md +6 -0
  3. package/dist/backend/codex-metadata.d.ts +8 -0
  4. package/dist/backend/codex-metadata.js +74 -17
  5. package/dist/backend/codex-metadata.js.map +1 -1
  6. package/dist/config.d.ts +13 -1
  7. package/dist/config.js +14 -5
  8. package/dist/config.js.map +1 -1
  9. package/dist/fleet-manager.d.ts +36 -1
  10. package/dist/fleet-manager.js +135 -43
  11. package/dist/fleet-manager.js.map +1 -1
  12. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  13. package/dist/general-knowledge/skills/web-appearance/SKILL.md +21 -3
  14. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
  15. package/dist/outbound-schemas.d.ts +14 -1
  16. package/dist/outbound-schemas.js +8 -2
  17. package/dist/outbound-schemas.js.map +1 -1
  18. package/dist/public-web-gateway.js +4 -0
  19. package/dist/public-web-gateway.js.map +1 -1
  20. package/dist/restart-progress.d.ts +16 -1
  21. package/dist/restart-progress.js +74 -15
  22. package/dist/restart-progress.js.map +1 -1
  23. package/dist/settings-api.js +4 -1
  24. package/dist/settings-api.js.map +1 -1
  25. package/dist/settings-confirmation.d.ts +3 -1
  26. package/dist/settings-confirmation.js +2 -0
  27. package/dist/settings-confirmation.js.map +1 -1
  28. package/dist/settings-effect.js +2 -1
  29. package/dist/settings-effect.js.map +1 -1
  30. package/dist/settings-http-confirmation.d.ts +11 -0
  31. package/dist/settings-http-confirmation.js +10 -0
  32. package/dist/settings-http-confirmation.js.map +1 -1
  33. package/dist/topic-commands.d.ts +10 -0
  34. package/dist/topic-commands.js +27 -15
  35. package/dist/topic-commands.js.map +1 -1
  36. package/dist/ui/chat-effects.js +10 -3
  37. package/dist/ui/chat-strings.js +2 -0
  38. package/dist/ui/effects-waker.js +70 -0
  39. package/dist/ui/fleet-canvas-layout.js +10 -4
  40. package/dist/ui/fleet-canvas.js +32 -2
  41. package/dist/ui/fleet-route-scene.js +207 -0
  42. package/dist/ui/fleet-route-worker.js +21 -0
  43. package/dist/ui/fleet-route.js +88 -0
  44. package/dist/ui/fleet-traffic.js +27 -11
  45. package/dist/ui/fleet-usage.js +35 -1
  46. package/dist/ui/panel-chat.js +51 -24
  47. package/dist/ui/panel-fleet.js +3 -3
  48. package/dist/ui/settings-appearance.js +2 -1
  49. package/dist/ui/settings-apply.js +3 -3
  50. package/dist/ui/settings-confirm.js +7 -2
  51. package/dist/ui/settings-owner-effects.js +6 -4
  52. package/dist/ui/shared/app-i18n.js +4 -0
  53. package/dist/ui/shared/app-prefs.js +12 -0
  54. package/dist/ui/shared/app-shell.js +38 -2
  55. package/dist/ui/shared/app-themes.js +116 -1
  56. package/dist/ui/shared/app.css +38 -3
  57. package/dist/ui/shared/card-strings.js +4 -0
  58. package/dist/ui/shared/identity-card.js +26 -8
  59. package/dist/ui/shared/identity-color.js +11 -7
  60. package/dist/ui/shared/ui-icons.js +1 -0
  61. package/dist/ui/shared/ui-menu.js +20 -7
  62. package/dist/ui/shared/web-authorize.js +47 -0
  63. package/dist/web-api.d.ts +0 -3
  64. package/dist/web-api.js +80 -14
  65. package/dist/web-api.js.map +1 -1
  66. package/dist/web-appearance.js +3 -1
  67. package/dist/web-appearance.js.map +1 -1
  68. package/dist/web-effect.d.ts +1 -1
  69. package/dist/web-host-guard.d.ts +11 -0
  70. package/dist/web-host-guard.js +11 -0
  71. package/dist/web-host-guard.js.map +1 -1
  72. package/dist/web-libavoid.d.ts +14 -0
  73. package/dist/web-libavoid.js +46 -0
  74. package/dist/web-libavoid.js.map +1 -0
  75. package/dist/web-owner-css-store.d.ts +3 -2
  76. package/dist/web-owner-css-store.js +9 -4
  77. package/dist/web-owner-css-store.js.map +1 -1
  78. package/dist/web-prefs.js +13 -0
  79. package/dist/web-prefs.js.map +1 -1
  80. package/dist/web-settings-tools.d.ts +5 -0
  81. package/dist/web-settings-tools.js +34 -6
  82. package/dist/web-settings-tools.js.map +1 -1
  83. package/dist/web-theme.d.ts +29 -0
  84. package/dist/web-theme.js +89 -1
  85. package/dist/web-theme.js.map +1 -1
  86. package/dist/yaml-shared-nodes.d.ts +25 -0
  87. package/dist/yaml-shared-nodes.js +185 -0
  88. package/dist/yaml-shared-nodes.js.map +1 -0
  89. package/package.json +2 -1
@@ -0,0 +1,70 @@
1
+ // #2193: the web effects runtime stays unloaded until there is something it could play for this viewer. Before that,
2
+ // this module is all there is: a few store subscriptions comparing fields the page already has (no observer, listener,
3
+ // timer, layer or request). The runtime itself (app-effects.js and everything it imports) is the reviewed code,
4
+ // unchanged; it is imported and started here, once, the first time one of these holds:
5
+ // - the open chat has Canvas Worker placements: its status row's effect_chat_v (the hash of its placements, taken
6
+ // before the server's region filtering — so it can only wake for nothing, never miss something) is not the hash of
7
+ // an empty list;
8
+ // - the signed-in person has a personal effect slot chosen (fx.page/header/sidebar/box/cursor — app-prefs);
9
+ // - an agent's "Try" offer is shown (wakeEffects() from the chat), or Settings saved a personal effect.
10
+ // Once started it stays for the page: the runtime is one episode per window (a disposed one cannot start again).
11
+ // Owner CSS is not the runtime's: owner-css.js is installed by the app shell and works with the runtime never loaded.
12
+ import { appStore } from "/assets/app-store.js";
13
+ import { navStore } from "/assets/app-nav.js";
14
+ import { ownerEffectsChosen, onOwnerEffectsChanged } from "/assets/app-prefs.js";
15
+
16
+ /** effect_chat_v of a chat with no placements: sha256(JSON.stringify([])), first 32 hex (fleet-manager's status row). */
17
+ export const EMPTY_CHAT_EFFECTS = "4f53cda18c2baa0c0354bb5f9a3ecbe5";
18
+
19
+ let mod = null, waking = null, chatStore = null, started = false;
20
+ const awakeListeners = new Set();
21
+
22
+ /** Whether `name`'s chat has Canvas Worker placements (as the status rows say). */
23
+ export function chatHasEffects(name, app = appStore.get()) {
24
+ const v = name ? app.instances?.find(i => i.name === name)?.effect_chat_v : undefined;
25
+ return typeof v === "string" && /^[a-f0-9]{32}$/.test(v) && v !== EMPTY_CHAT_EFFECTS;
26
+ }
27
+
28
+ /** The running effects owner, or null while dormant (or when it could not start: as getAppEffects always was). */
29
+ export function getAppEffects(host) { return mod ? mod.getAppEffects(host) : null; }
30
+
31
+ /** Whether the runtime has been asked to start on this page. */
32
+ export function effectsAwake() { return waking !== null; }
33
+ /** Whether its module has loaded (started, or found it could not: getAppEffects() then says which). */
34
+ export function effectsLoaded() { return mod !== null; }
35
+
36
+ /** Called once the runtime has started (or immediately when it already has). Returns the unsubscribe. */
37
+ export function onEffectsAwake(fn) {
38
+ if (typeof fn !== "function") return () => {};
39
+ if (mod) { try { fn(); } catch { /* a listener's error is its own */ } return () => {}; }
40
+ awakeListeners.add(fn); return () => awakeListeners.delete(fn);
41
+ }
42
+
43
+ /** Load and start the runtime (once per page). Resolves to the owner, or null when it could not start. */
44
+ export function wakeEffects() {
45
+ if (waking) return waking;
46
+ waking = import("./app-effects.js").then(m => {
47
+ mod = m;
48
+ const owner = chatStore ? m.startAppEffects({ store: chatStore }) : null;
49
+ for (const fn of [...awakeListeners]) { try { fn(); } catch { /* the rest still hear */ } }
50
+ awakeListeners.clear();
51
+ return owner;
52
+ }, () => null);
53
+ return waking;
54
+ }
55
+
56
+ /** Wake when the open chat has placements or the person has a personal effect. */
57
+ function check() {
58
+ if (waking) return;
59
+ const route = navStore.get().route;
60
+ if ((route?.panel === "chat" && chatHasEffects(route.instance)) || ownerEffectsChosen()) void wakeEffects();
61
+ }
62
+
63
+ /** Boot (once per page, with the Chat store the runtime binds to). Starts nothing but the watch. */
64
+ export function startEffectsWaker({ store }) {
65
+ chatStore = store;
66
+ if (started) return;
67
+ started = true;
68
+ appStore.subscribe(check); navStore.subscribe(check); onOwnerEffectsChanged(check);
69
+ check();
70
+ }
@@ -1,8 +1,14 @@
1
1
  // #1918: the org canvas's layout (fleet-canvas.js draws it; fleet-traffic.js routes lines through its empty lanes).
2
2
  // Fixed slots, no layout engine: the same structure always gives the same picture.
3
3
 
4
- /** Sizes in canvas units. NW×NH a member card, LW×LH a General card; FOLDED a folded (or empty) team's height. */
5
- export const OC = { NW: 184, NH: 56, GAP: 18, PAD: 24, HEAD: 34, TEAM_GAP: 56, ROW_GAP: 96, ROW_MAX: 1400, ROW_MAX_NARROW: 460, LW: 232, LH: 68, TOP_Y: 20, BUS: 44, FOLDED: 64, TRUNK: -30 };
4
+ /**
5
+ * Sizes in canvas units. NW×NH a member card, LW×LH a General card; FOLDED a folded (or empty) team's height. GAP and
6
+ * PAD (#2182): the gutters between cards and a box's padding are the traffic lines' channels inside a box — wide
7
+ * enough that the router (fleet-route-scene.js) keeps a few lines apart in each; a phone's narrow packing keeps the
8
+ * tighter GAP_NARROW and PAD_NARROW, so General and the first team still open at a readable size. A layout says which
9
+ * it used (L.gap, L.pad).
10
+ */
11
+ export const OC = { NW: 184, NH: 56, GAP: 28, PAD: 32, GAP_NARROW: 18, PAD_NARROW: 24, HEAD: 34, TEAM_GAP: 72, ROW_GAP: 96, ROW_MAX: 1400, ROW_MAX_NARROW: 460, LW: 232, LH: 68, TOP_Y: 20, BUS: 44, FOLDED: 64, TRUNK: -30 };
6
12
  const colsFor = (n, narrow) => (n <= 1 ? 1 : n <= 4 || narrow ? 2 : 3);
7
13
  /** The key a team folds by (per device): its name; the instances in no team have one of their own no team name can be. */
8
14
  export const REST_KEY = "\u0000rest";
@@ -13,7 +19,7 @@ export const REST_KEY = "\u0000rest";
13
19
  * size of the whole. `narrow`: a phone's packing.
14
20
  */
15
21
  export function canvasLayout(view, folded = new Set(), { narrow = false, label = (s, n) => `${n} ${s}` } = {}) {
16
- const { NW, NH, GAP, PAD, HEAD } = OC;
22
+ const { NW, NH, HEAD } = OC, GAP = narrow ? OC.GAP_NARROW : OC.GAP, PAD = narrow ? OC.PAD_NARROW : OC.PAD;
17
23
  const groups = [...view.teams.map((tm) => ({ key: tm.name, name: tm.name, loose: false, nodes: tm.nodes, counts: tm.counts })),
18
24
  ...(view.rest.nodes.length ? [{ key: REST_KEY, name: null, loose: true, nodes: view.rest.nodes, counts: view.rest.counts }] : [])];
19
25
  const rows = [];
@@ -49,7 +55,7 @@ export function canvasLayout(view, folded = new Set(), { narrow = false, label =
49
55
  const width = Math.max(rowsW, topW);
50
56
  const tops = view.top.map((node, i) => ({ node, key: `\u0000top/${node.name}`, x: width / 2 - topW / 2 + i * (OC.LW + GAP), y: OC.TOP_Y, w: OC.LW, h: OC.LH }));
51
57
  const height = boxes.length ? y - OC.ROW_GAP : tops.length ? OC.TOP_Y + OC.LH : 0;
52
- return { tops, boxes, width, height, tree: treeLines(tops, boxes) };
58
+ return { tops, boxes, width, height, tree: treeLines(tops, boxes), gap: GAP, pad: PAD };
53
59
  }
54
60
 
55
61
  /** A folded team's summary line height, and a label's width estimate (canvas units: wide for CJK, narrow otherwise). */
@@ -15,6 +15,8 @@
15
15
  // change). The live state is the app store's (fleet-org.js buildOrgView), so the canvas moves with the stream.
16
16
  // - Live traffic (fleet-traffic.js): the messages between instances, drawn between the team boxes and the cards (never
17
17
  // hidden under a box, never across a name). Live on or off per device; the list says it in words ("Sending: …").
18
+ // #2182: routed around the cards and through the boxes' doors by libavoid in a worker (fleet-route-scene.js), each
19
+ // line from card to card; the lanes are the fallback.
18
20
  import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
19
21
  import { appStore } from "/assets/app-store.js";
20
22
  import { t, register } from "/assets/app-i18n.js";
@@ -27,6 +29,8 @@ import { identityClasses, identityOf, paintIdentity, useIdentityColors } from "/
27
29
  import { buildOrgView, OrgChart, OrgSummary } from "./fleet-org.js";
28
30
  import { OC, REST_KEY, SUM_LINE, canvasLayout, openingRegion, summaryRows } from "./fleet-canvas-layout.js";
29
31
  import { createTrafficStore, FAR_K, POLL_MS, sendingBy, trafficEdges } from "./fleet-traffic.js";
32
+ import { routeScene } from "./fleet-route-scene.js";
33
+ import { createRouteClient } from "./fleet-route.js";
30
34
 
31
35
  register("orgc", {
32
36
  en: {
@@ -201,7 +205,7 @@ function TrafficLayer({ edges, q }) {
201
205
  const still = reducedMotion();
202
206
  return html`<g class="oc-flows" aria-hidden="true">${edges.map((e) => {
203
207
  const dim = q && !e.names.some((n) => n.toLowerCase().includes(q));
204
- return html`<g key=${e.key} class=${`oc-fl${dim ? " dim" : ""}`}>
208
+ return html`<g key=${e.key} class=${`oc-fl${dim ? " dim" : ""}`} data-routed=${e.routed ? "1" : undefined}>
205
209
  <path class="oc-flow-halo" d=${e.d} />
206
210
  <path class=${`oc-flow h-${e.hue} ph-${e.phase}`} d=${e.d} marker-start=${`url(#oc-src-${MARKER(e)})`} marker-end=${`url(#oc-ah-${MARKER(e)})`} />
207
211
  ${e.phase === "live" && !still ? html`<circle class=${`oc-pulse h-${e.hue}`} r="4"><animateMotion dur=${`${Math.max(1.6, e.pts.length * 0.3)}s`} repeatCount="indefinite" path=${e.d} /></circle>` : null}
@@ -269,6 +273,32 @@ function useTraffic(lease, on) {
269
273
  return useMemo(() => (on ? store.items() : []), [rev, on]);
270
274
  }
271
275
 
276
+ /**
277
+ * #2182: the traffic lines, routed around the canvas by libavoid in a worker (fleet-route.js) once it answers; until
278
+ * then, and zoomed out (one line per team pair), along the lanes (fleet-traffic.js route()). An answer is kept while the
279
+ * layout it was routed on is the one drawn: a line it has not seen yet takes its lane until the next answer.
280
+ */
281
+ let routeClient = null;
282
+ function useTrafficRoutes(traffic, L, far) {
283
+ const lanes = useMemo(() => (traffic ? trafficEdges(traffic, L, { far }) : []), [traffic, L, far]);
284
+ const scene = useMemo(() => (far || !lanes.length ? null : routeScene(L, lanes)), [lanes, L, far]);
285
+ const [answer, setAnswer] = useState(null); // { shapes, map }
286
+ // The layout as the router sees it: a status frame re-making the same layout keeps the answer (and asks nothing).
287
+ const shapes = scene ? JSON.stringify(scene.shapes) : null;
288
+ const ask = scene && scene.conns.length ? JSON.stringify(scene) : null;
289
+ useEffect(() => {
290
+ if (!ask) return undefined;
291
+ routeClient ??= createRouteClient();
292
+ if (!routeClient.available) return undefined;
293
+ let live = true;
294
+ routeClient.route(JSON.parse(ask)).then((map) => { if (live && map) setAnswer({ shapes, map }); });
295
+ return () => { live = false; };
296
+ }, [ask]);
297
+ useEffect(() => () => { routeClient?.close(); routeClient = null; }, []);
298
+ return useMemo(() => (answer && answer.shapes === shapes && traffic ? trafficEdges(traffic, L, { far, routed: answer.map }) : lanes),
299
+ [lanes, answer, shapes, L, far, traffic]);
300
+ }
301
+
272
302
  /** The canvas body: `view` buildOrgView's answer; `folded` the folded teams' keys; `q` the search needle; `traffic` the live items (null: Live is off). */
273
303
  function Canvas({ view, folded, onFold, q, jump, traffic }) {
274
304
  const [narrow] = useState(() => typeof matchMedia === "function" && matchMedia("(max-width: 640px)").matches);
@@ -308,7 +338,7 @@ function Canvas({ view, folded, onFold, q, jump, traffic }) {
308
338
  vp.current = narrow ? fitView(openingRegion(L), size(), { margin: 12, minK: 0.75 }) : fitView(whole(), size(), { widthOnly: true, maxK: 1, minK: 0.6 });
309
339
  setFar(vp.current.k < FAR_K);
310
340
  }, []);
311
- const edges = useMemo(() => (traffic ? trafficEdges(traffic, L, { far }) : []), [traffic, L, far]);
341
+ const edges = useTrafficRoutes(traffic, L, far);
312
342
  // Preact owns the markup, not the transform: put it back after every render.
313
343
  useLayoutEffect(apply);
314
344
  // A search's Enter: the first match, unfolded (by the panel) and then brought into view — once. It waits for the
@@ -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} />