@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.
- package/README.md +6 -0
- package/README.zh-TW.md +6 -0
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/fleet-manager.d.ts +36 -1
- package/dist/fleet-manager.js +135 -43
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +21 -3
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
- package/dist/outbound-schemas.d.ts +14 -1
- package/dist/outbound-schemas.js +8 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +4 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/restart-progress.d.ts +16 -1
- package/dist/restart-progress.js +74 -15
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.js +4 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -1
- package/dist/settings-confirmation.js +2 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +2 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +11 -0
- package/dist/settings-http-confirmation.js +10 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/topic-commands.d.ts +10 -0
- package/dist/topic-commands.js +27 -15
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/effects-waker.js +70 -0
- package/dist/ui/fleet-canvas-layout.js +10 -4
- package/dist/ui/fleet-canvas.js +32 -2
- package/dist/ui/fleet-route-scene.js +207 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-traffic.js +27 -11
- package/dist/ui/fleet-usage.js +35 -1
- package/dist/ui/panel-chat.js +51 -24
- package/dist/ui/panel-fleet.js +3 -3
- package/dist/ui/settings-appearance.js +2 -1
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/shared/app-i18n.js +4 -0
- package/dist/ui/shared/app-prefs.js +12 -0
- package/dist/ui/shared/app-shell.js +38 -2
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +38 -3
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +11 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/web-authorize.js +47 -0
- package/dist/web-api.d.ts +0 -3
- package/dist/web-api.js +80 -14
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance.js +3 -1
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-effect.d.ts +1 -1
- package/dist/web-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.js.map +1 -0
- package/dist/web-owner-css-store.d.ts +3 -2
- package/dist/web-owner-css-store.js +9 -4
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-prefs.js +13 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-settings-tools.d.ts +5 -0
- package/dist/web-settings-tools.js +34 -6
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- 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
|
-
/**
|
|
5
|
-
|
|
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
|
|
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). */
|
package/dist/ui/fleet-canvas.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/dist/ui/fleet-traffic.js
CHANGED
|
@@ -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(-
|
|
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 {
|
|
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 {
|
|
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 } =
|
|
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
|
|
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
|
|
327
|
-
|
|
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
|
|
package/dist/ui/fleet-usage.js
CHANGED
|
@@ -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
|
-
})}${(
|
|
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} />
|