@caddis/cli 0.4.49 → 0.4.50
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +4 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +18 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +6 -1
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +16 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/bars.test.ts +90 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/bars.ts +53 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +8 -3
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +17 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +2 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +4 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +61 -4
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +56 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +4 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +18 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +6 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +16 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.test.ts +90 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.ts +53 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +8 -3
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +2 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +4 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +61 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +56 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -184,6 +184,11 @@
|
|
|
184
184
|
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
185
185
|
.node .s.has-spark { padding-right:60px; }
|
|
186
186
|
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
187
|
+
.node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
|
|
188
|
+
.node svg.bars rect { fill:var(--muted); opacity:.6; }
|
|
189
|
+
.node svg.bars rect.last { fill:var(--accent); opacity:1; }
|
|
190
|
+
.node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
|
|
191
|
+
.node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
|
|
187
192
|
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
188
193
|
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
189
194
|
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|
|
@@ -69,6 +69,8 @@ function validateModel(input) {
|
|
|
69
69
|
const metric = n.metrics;
|
|
70
70
|
const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
|
|
71
71
|
if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
|
|
72
|
+
const badBars = n.bars !== void 0 && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some((b) => !b || typeof b.key !== "string" || b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0)));
|
|
73
|
+
if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
|
|
72
74
|
if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
|
|
73
75
|
}
|
|
74
76
|
for (const n of nodes) {
|
|
@@ -749,6 +751,7 @@ function on(ctx, target, type, fn, opts) {
|
|
|
749
751
|
|
|
750
752
|
// src/layout.ts
|
|
751
753
|
var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
754
|
+
var BARS_EXTRA = 26;
|
|
752
755
|
var LANE_PAD_TOP = 38;
|
|
753
756
|
var LANE_PAD_BOTTOM = 14;
|
|
754
757
|
function computeGeometry(m, opt = {}) {
|
|
@@ -762,7 +765,7 @@ function computeGeometry(m, opt = {}) {
|
|
|
762
765
|
const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
|
|
763
766
|
const nodes = {};
|
|
764
767
|
for (const n of m.nodes) {
|
|
765
|
-
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
768
|
+
const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
|
|
766
769
|
if (m.layout === "free") {
|
|
767
770
|
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
768
771
|
continue;
|
|
@@ -884,6 +887,47 @@ function sparkline(metric, width = 56, height = 12) {
|
|
|
884
887
|
return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
|
|
885
888
|
}
|
|
886
889
|
|
|
890
|
+
// src/bars.ts
|
|
891
|
+
var BAR_UNIT = 4;
|
|
892
|
+
function barsHtml(bars, height = 22) {
|
|
893
|
+
if (!bars || bars.length === 0) return "";
|
|
894
|
+
const width = bars.length * BAR_UNIT;
|
|
895
|
+
const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
|
|
896
|
+
const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
|
|
897
|
+
const rects = bars.map((b, i) => {
|
|
898
|
+
const x = i * BAR_UNIT;
|
|
899
|
+
const w = BAR_UNIT - 1;
|
|
900
|
+
const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
|
|
901
|
+
if (b.value === null) {
|
|
902
|
+
return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
|
|
903
|
+
}
|
|
904
|
+
const h = Math.max(1, b.value / max * (height - 5));
|
|
905
|
+
const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
|
|
906
|
+
return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
|
|
907
|
+
});
|
|
908
|
+
const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
|
|
909
|
+
return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
|
|
910
|
+
}
|
|
911
|
+
function markKey(root, key) {
|
|
912
|
+
for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
|
|
913
|
+
if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
|
|
914
|
+
else r.removeAttribute("data-hl");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function installBars(ctx) {
|
|
918
|
+
const set = (key) => {
|
|
919
|
+
if (ctx.hoverKey === key) return;
|
|
920
|
+
ctx.hoverKey = key;
|
|
921
|
+
markKey(ctx.els.world, key);
|
|
922
|
+
ctx.onHoverKey?.(key);
|
|
923
|
+
};
|
|
924
|
+
on(ctx, ctx.els.world, "mouseover", (e) => {
|
|
925
|
+
const bar = e.target?.closest?.("rect[data-key]");
|
|
926
|
+
set(bar ? bar.getAttribute("data-key") : null);
|
|
927
|
+
});
|
|
928
|
+
on(ctx, ctx.els.world, "mouseleave", () => set(null));
|
|
929
|
+
}
|
|
930
|
+
|
|
887
931
|
// src/render.ts
|
|
888
932
|
function loopRoute(ctx, fromId, toId, from, to, channel) {
|
|
889
933
|
const detourFor = (end, other) => {
|
|
@@ -920,7 +964,7 @@ function nodeHtml(ctx, n) {
|
|
|
920
964
|
const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
|
|
921
965
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
|
|
922
966
|
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
|
|
923
|
-
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
|
|
967
|
+
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
|
|
924
968
|
}
|
|
925
969
|
function build(ctx) {
|
|
926
970
|
const { model, els } = ctx;
|
|
@@ -955,7 +999,7 @@ function build(ctx) {
|
|
|
955
999
|
for (const n of model.nodes) {
|
|
956
1000
|
const p = ctx.geom.nodes[n.id];
|
|
957
1001
|
if (!p) continue;
|
|
958
|
-
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
1002
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
|
|
959
1003
|
d.id = "n_" + n.id;
|
|
960
1004
|
d.tabIndex = 0;
|
|
961
1005
|
d.setAttribute("role", "button");
|
|
@@ -1090,6 +1134,7 @@ function paint(ctx, fromSwitch = false) {
|
|
|
1090
1134
|
d.style.height = r.h + "px";
|
|
1091
1135
|
}
|
|
1092
1136
|
}
|
|
1137
|
+
markKey(ctx.els.world, ctx.hoverKey);
|
|
1093
1138
|
ctx.fn.buildChrome();
|
|
1094
1139
|
ctx.fn.trace(ctx.selected);
|
|
1095
1140
|
ctx.fn.renderInspector();
|
|
@@ -2310,6 +2355,8 @@ function mount(root, model, opts = {}) {
|
|
|
2310
2355
|
root,
|
|
2311
2356
|
els,
|
|
2312
2357
|
onSelect: opts.onSelect,
|
|
2358
|
+
onHoverKey: opts.onHoverKey,
|
|
2359
|
+
hoverKey: null,
|
|
2313
2360
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
2314
2361
|
nodes: new Map(model.nodes.map((n) => [n.id, n])),
|
|
2315
2362
|
links: model.links,
|
|
@@ -2352,6 +2399,7 @@ function mount(root, model, opts = {}) {
|
|
|
2352
2399
|
style.textContent = stateCss(model.states);
|
|
2353
2400
|
install(ctx);
|
|
2354
2401
|
install2(ctx);
|
|
2402
|
+
installBars(ctx);
|
|
2355
2403
|
install3(ctx);
|
|
2356
2404
|
install4(ctx);
|
|
2357
2405
|
install5(ctx);
|
|
@@ -2398,6 +2446,10 @@ function mount(root, model, opts = {}) {
|
|
|
2398
2446
|
ctx.fn.fit(animate);
|
|
2399
2447
|
},
|
|
2400
2448
|
select: (id) => ctx.fn.select(id),
|
|
2449
|
+
highlightKey: (key) => {
|
|
2450
|
+
ctx.hoverKey = key;
|
|
2451
|
+
markKey(ctx.els.world, key);
|
|
2452
|
+
},
|
|
2401
2453
|
selected: () => ctx.selected,
|
|
2402
2454
|
mode: () => ctx.mode,
|
|
2403
2455
|
setMode: (id) => ctx.fn.setMode(id),
|
|
@@ -2423,7 +2475,7 @@ function mount(root, model, opts = {}) {
|
|
|
2423
2475
|
const changes = diffModels(ctx.model, prepared);
|
|
2424
2476
|
const saved = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter((n) => n.kind === "group").map((n) => n.id)), playback: playback.playback() };
|
|
2425
2477
|
api.destroy();
|
|
2426
|
-
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
2478
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
|
|
2427
2479
|
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
2428
2480
|
Object.assign(api, replacement);
|
|
2429
2481
|
window.FlowStudio = api;
|
|
@@ -80,7 +80,7 @@ columns [{id, title, short?, width, phase?, continues?}] stages, le
|
|
|
80
80
|
lanes [{id, title, badge?, badgeTone?, note?, gap?}] rows; gap true = dashed amber lane
|
|
81
81
|
states {id: {word, tone, border, edge, motion, bucket}} tone: ok|warn|crit|info|muted|accent; bucket: good|neutral|gap
|
|
82
82
|
nodes [{id, kind, lane?, col?, colSpan?, row?, x?, y?, w?, h?, state, title, subtitle?, members?, weight?,
|
|
83
|
-
ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?}]
|
|
83
|
+
ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?, bars?}]
|
|
84
84
|
links [{id, from, to, state, kind?, label?, stateIn?, visibleIn?}] kind: forward | loop-back
|
|
85
85
|
views [{id, label, default?}] first view serves as baseline
|
|
86
86
|
inspector {relations [up to 3], actions [{id, label, dir, staleText}]}
|
|
@@ -75,7 +75,10 @@
|
|
|
75
75
|
"sla": { "type": "number", "exclusiveMinimum": 0, "description": "Target service level agreement in seconds" },
|
|
76
76
|
"metrics": { "type": "object", "required": ["value"], "additionalProperties": false,
|
|
77
77
|
"properties": { "value": { "type": "number" }, "max": { "type": ["number", "null"], "description": "Upper bound for the badge fill bar; null means there is no maximum" }, "unit": { "type": "string" }, "label": { "type": "string" },
|
|
78
|
-
"series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } }
|
|
78
|
+
"series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } },
|
|
79
|
+
"bars": { "type": "array", "maxItems": 120, "description": "A small bar chart on the node, one bar per key. A null value is a missing key (a red tick, never zero). Hovering a bar marks the same key on every chart.",
|
|
80
|
+
"items": { "type": "object", "required": ["key", "value"], "additionalProperties": false,
|
|
81
|
+
"properties": { "key": { "type": "string" }, "value": { "type": ["number", "null"], "minimum": 0 }, "kind": { "enum": ["low"] }, "label": { "type": "string" } } } }
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
},
|
|
@@ -7,15 +7,17 @@ const lifecycle = vi.hoisted(() => ({
|
|
|
7
7
|
alive: 0, updates: [] as Model[], mounts: 0, listenerCount: 0,
|
|
8
8
|
notes: [] as (NoteDef[] | undefined)[],
|
|
9
9
|
onSelect: undefined as ((id: string | null) => void) | undefined,
|
|
10
|
+
onHoverKey: undefined as ((key: string | null) => void) | undefined,
|
|
10
11
|
}));
|
|
11
12
|
vi.mock('../templates/flow-studio.lib.mjs', () => ({
|
|
12
13
|
SHELL_BODY_HTML: '<div id="viewport"></div>',
|
|
13
|
-
mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void }) => {
|
|
14
|
+
mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; onHoverKey?: (key: string | null) => void }) => {
|
|
14
15
|
lifecycle.mounts++;
|
|
15
16
|
lifecycle.alive++;
|
|
16
17
|
lifecycle.listenerCount++;
|
|
17
18
|
lifecycle.notes.push(options.notes);
|
|
18
19
|
lifecycle.onSelect = options.onSelect;
|
|
20
|
+
lifecycle.onHoverKey = options.onHoverKey;
|
|
19
21
|
return {
|
|
20
22
|
update: (model: Model) => { lifecycle.updates.push(model); return { ok: true, issues: [] }; },
|
|
21
23
|
destroy: () => { lifecycle.alive--; lifecycle.listenerCount--; },
|
|
@@ -56,6 +58,21 @@ describe('FlowStudioView', () => {
|
|
|
56
58
|
expect(globals.flowStudioMount).toBe(previousMount);
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
it('forwards the hovered bar key to the latest onHoverKey, without remounting', () => {
|
|
62
|
+
lifecycle.mounts = 0;
|
|
63
|
+
const first = vi.fn();
|
|
64
|
+
const second = vi.fn();
|
|
65
|
+
const rendered = render(<FlowStudioView model={model} onHoverKey={first} />);
|
|
66
|
+
lifecycle.onHoverKey?.('2026-09-02');
|
|
67
|
+
expect(first).toHaveBeenCalledWith('2026-09-02');
|
|
68
|
+
rendered.rerender(<FlowStudioView model={model} onHoverKey={second} />);
|
|
69
|
+
expect(lifecycle.mounts).toBe(1);
|
|
70
|
+
lifecycle.onHoverKey?.(null);
|
|
71
|
+
expect(second).toHaveBeenCalledWith(null);
|
|
72
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
73
|
+
rendered.unmount();
|
|
74
|
+
});
|
|
75
|
+
|
|
59
76
|
it('passes notes at mount and ignores later notes-only changes', () => {
|
|
60
77
|
lifecycle.updates = [];
|
|
61
78
|
lifecycle.notes = [];
|
|
@@ -7,6 +7,8 @@ export interface FlowStudioViewProps {
|
|
|
7
7
|
/** Read once at mount; later changes to this prop are ignored. For notes that change with the data, put them on `model.notes` — `update` forwards them. */
|
|
8
8
|
notes?: NoteDef[];
|
|
9
9
|
onSelect?: (id: string | null) => void;
|
|
10
|
+
/** The bar key under the pointer (a node's `bars`), or null when it leaves. The latest function is always called. */
|
|
11
|
+
onHoverKey?: (key: string | null) => void;
|
|
10
12
|
className?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -17,16 +19,18 @@ let mountedViews = 0;
|
|
|
17
19
|
let previousGlobalMount: typeof mount | undefined;
|
|
18
20
|
|
|
19
21
|
/** Mounts the finished engine in React. The shell stays outside React's child tree. */
|
|
20
|
-
export function FlowStudioView({ model, notes, onSelect, className }: FlowStudioViewProps) {
|
|
22
|
+
export function FlowStudioView({ model, notes, onSelect, onHoverKey, className }: FlowStudioViewProps) {
|
|
21
23
|
const root = useRef<HTMLDivElement>(null);
|
|
22
24
|
const api = useRef<FlowStudio | null>(null);
|
|
23
25
|
const currentModel = useRef(model);
|
|
24
26
|
const currentNotes = useRef(notes);
|
|
25
27
|
const currentSelect = useRef(onSelect);
|
|
28
|
+
const currentHoverKey = useRef(onHoverKey);
|
|
26
29
|
const lastModel = useRef<string | null>(null);
|
|
27
30
|
currentModel.current = model;
|
|
28
31
|
currentNotes.current = notes;
|
|
29
32
|
currentSelect.current = onSelect;
|
|
33
|
+
currentHoverKey.current = onHoverKey;
|
|
30
34
|
const modelJson = JSON.stringify(model);
|
|
31
35
|
|
|
32
36
|
useEffect(() => {
|
|
@@ -36,6 +40,7 @@ export function FlowStudioView({ model, notes, onSelect, className }: FlowStudio
|
|
|
36
40
|
const instance = mount(container, currentModel.current, {
|
|
37
41
|
notes: currentNotes.current,
|
|
38
42
|
onSelect: id => currentSelect.current?.(id),
|
|
43
|
+
onHoverKey: key => currentHoverKey.current?.(key),
|
|
39
44
|
});
|
|
40
45
|
if (mountedViews === 0) {
|
|
41
46
|
previousGlobalMount = window.flowStudioMount;
|
|
@@ -22,7 +22,7 @@ function FlowPage({ state, onRetry, onSelect }: { state: State; onRetry: () => v
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
`FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
|
|
25
|
+
`FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, `onHoverKey`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. `onHoverKey` receives the key of the bar under the pointer (see `bars` on a node) or `null`; call `window.FlowStudio?.highlightKey(key)` to mark a key from outside. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
|
|
26
26
|
|
|
27
27
|
## Live data contract
|
|
28
28
|
|
|
@@ -112,6 +112,7 @@ Nodes represent processing units, cards, gateways, groups, and milestones.
|
|
|
112
112
|
| `collapsed` | `boolean` | Optional | `true` | When true, group starts collapsed hiding child nodes. |
|
|
113
113
|
| `sla` | `number` | Optional | - | Target service level agreement threshold in seconds. |
|
|
114
114
|
| `metrics` | `object` | Optional | - | Telemetry payload (see metrics table). |
|
|
115
|
+
| `bars` | `array` | Optional | - | A small bar chart on the node, one bar per `key` (see bars table). |
|
|
115
116
|
|
|
116
117
|
---
|
|
117
118
|
|
|
@@ -129,6 +130,21 @@ Provides numerical telemetry, health bars, and sparklines.
|
|
|
129
130
|
|
|
130
131
|
---
|
|
131
132
|
|
|
133
|
+
### `bars` items (under `nodes`)
|
|
134
|
+
|
|
135
|
+
A bar chart drawn inside the node, for example rows per day. Hovering a bar marks the bars with the same `key` on
|
|
136
|
+
every node, so a reader can ask "was this day bad everywhere?". The host page can follow the hover with the
|
|
137
|
+
`onHoverKey` mount option and drive it with `highlightKey(key)`. At most 120 bars.
|
|
138
|
+
|
|
139
|
+
| Property | Type | Required? | Default | Description |
|
|
140
|
+
|---|---|---|---|---|
|
|
141
|
+
| `key` | `string` | Required | - | Opaque id of the bar (a date or a step). Equal keys are linked across charts. |
|
|
142
|
+
| `value` | `number` or `null` | Required | - | Bar size (finite, not negative). `null` means no data for that key: a short red tick, never a zero bar. |
|
|
143
|
+
| `kind` | `string` | Optional | - | `"low"` draws the bar in the critical colour. The last bar with data is drawn in the accent colour, unless it is `low`. |
|
|
144
|
+
| `label` | `string` | Optional | - | Tooltip text; defaults to `key: value`. |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
132
148
|
## 8. `links` array items
|
|
133
149
|
|
|
134
150
|
Links represent directional data transit, dependencies, or workflow transitions.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { barsHtml, markKey } from "./bars.ts";
|
|
4
|
+
import { validateModel, type Model, type NodeBar } from "./model.ts";
|
|
5
|
+
|
|
6
|
+
const bars: NodeBar[] = [
|
|
7
|
+
{ key: "2026-09-01", value: 10 },
|
|
8
|
+
{ key: "2026-09-02", value: null },
|
|
9
|
+
{ key: "2026-09-03", value: 4, kind: "low" },
|
|
10
|
+
{ key: "2026-09-04", value: 20 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
test("no bars, no chart", () => {
|
|
14
|
+
assert.equal(barsHtml(undefined), "");
|
|
15
|
+
assert.equal(barsHtml([]), "");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("one rect per bar, each carrying its key; a missing day is a short red tick, never a zero bar", () => {
|
|
19
|
+
const html = barsHtml(bars);
|
|
20
|
+
assert.equal((html.match(/<rect /g) ?? []).length, 4);
|
|
21
|
+
for (const b of bars) assert.match(html, new RegExp(`data-key="${b.key}"`));
|
|
22
|
+
assert.match(html, /<rect class="miss" data-key="2026-09-02"/);
|
|
23
|
+
assert.match(html, /<rect class="low" data-key="2026-09-03"/);
|
|
24
|
+
// The last bar with data is the accent one.
|
|
25
|
+
assert.match(html, /<rect class="last" data-key="2026-09-04"/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("bar height follows the value against the largest, and the largest fills the chart", () => {
|
|
29
|
+
const html = barsHtml([{ key: "a", value: 5 }, { key: "b", value: 10 }], 20);
|
|
30
|
+
const heights = [...html.matchAll(/height="([0-9.]+)"/g)].map(m => Number(m[1])).filter(h => h < 20);
|
|
31
|
+
assert.equal(heights.length, 2);
|
|
32
|
+
assert.ok(Math.abs(heights[1]! - 15) < 0.01, "the largest bar is height - 5");
|
|
33
|
+
assert.ok(Math.abs(heights[0]! - 7.5) < 0.01, "half the value is half the height");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("keys and labels are escaped, so a hostile key cannot become markup", () => {
|
|
37
|
+
const html = barsHtml([{ key: '"><script>x</script>', value: 1, label: "<b>" }]);
|
|
38
|
+
assert.doesNotMatch(html, /<script>/);
|
|
39
|
+
assert.doesNotMatch(html, /<b>/);
|
|
40
|
+
assert.match(html, /<b>/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the chart is described for a screen reader", () => {
|
|
44
|
+
assert.match(barsHtml(bars), /role="img" aria-label="[^"]+"/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
interface FakeRect { key: string; attrs: Map<string, string>; getAttribute(n: string): string | null; setAttribute(n: string, v: string): void; removeAttribute(n: string): void }
|
|
48
|
+
const fakeRect = (key: string): FakeRect => {
|
|
49
|
+
const attrs = new Map<string, string>([["data-key", key]]);
|
|
50
|
+
return { key, attrs, getAttribute: n => attrs.get(n) ?? null, setAttribute: (n, v) => void attrs.set(n, v), removeAttribute: n => void attrs.delete(n) };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test("markKey marks every bar with that key, on every chart, and clears the others", () => {
|
|
54
|
+
const rects = [fakeRect("a"), fakeRect("b"), fakeRect("a")];
|
|
55
|
+
const root = { querySelectorAll: () => rects } as unknown as ParentNode;
|
|
56
|
+
markKey(root, "a");
|
|
57
|
+
assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), ["true", undefined, "true"]);
|
|
58
|
+
markKey(root, "b");
|
|
59
|
+
assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), [undefined, "true", undefined]);
|
|
60
|
+
markKey(root, null);
|
|
61
|
+
assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), [undefined, undefined, undefined]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const base = (): Model => ({
|
|
65
|
+
version: 1,
|
|
66
|
+
meta: { title: "T", help: "h", asOf: "2026-01-01", source: "s" },
|
|
67
|
+
layout: "columns-lanes",
|
|
68
|
+
columns: [{ id: "c0", title: "A", width: 150 }],
|
|
69
|
+
lanes: [{ id: "l1", title: "Lane 1" }],
|
|
70
|
+
states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
|
|
71
|
+
nodes: [{ id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A", allowIsolated: true, bars }],
|
|
72
|
+
links: [],
|
|
73
|
+
});
|
|
74
|
+
const codes = (m: unknown) => validateModel(m).filter(i => i.level === "error").map(i => i.code);
|
|
75
|
+
|
|
76
|
+
test("a node may carry bars", () => assert.deepEqual(codes(base()), []));
|
|
77
|
+
test("bars must be a list", () => {
|
|
78
|
+
const m = base(); (m.nodes[0] as unknown as { bars: unknown }).bars = "nope";
|
|
79
|
+
assert.ok(codes(m).includes("bars-invalid"));
|
|
80
|
+
});
|
|
81
|
+
test("each bar needs a string key and a finite, non-negative value or null", () => {
|
|
82
|
+
for (const bad of [{ key: 1, value: 1 }, { key: "k", value: -1 }, { key: "k", value: Number.NaN }, { key: "k", value: "3" }, null]) {
|
|
83
|
+
const m = base(); (m.nodes[0] as unknown as { bars: unknown[] }).bars = [bad];
|
|
84
|
+
assert.ok(codes(m).includes("bars-invalid"), JSON.stringify(bad));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test("at most 120 bars", () => {
|
|
88
|
+
const m = base(); m.nodes[0]!.bars = Array.from({ length: 121 }, (_, i) => ({ key: String(i), value: 1 }));
|
|
89
|
+
assert.ok(codes(m).includes("bars-invalid"));
|
|
90
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Small bar charts on a node, one bar per key (a day, a step). Hovering a bar marks the same key on
|
|
2
|
+
// every chart on the canvas, so a reader can ask "was this day bad everywhere?". The key is opaque:
|
|
3
|
+
// the model decides what it means. Pure string rendering plus one tiny DOM helper, no layout maths.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from "./ctx.ts";
|
|
6
|
+
import { on } from "./ctx.ts";
|
|
7
|
+
import { esc, type NodeBar } from "./model.ts";
|
|
8
|
+
|
|
9
|
+
const BAR_UNIT = 4; // viewBox units per bar; the chart is stretched to the node's width by CSS
|
|
10
|
+
|
|
11
|
+
/** The chart as an SVG string, or "" when the node has no bars. A null value is a missing key: a red tick, never a zero bar. */
|
|
12
|
+
export function barsHtml(bars: NodeBar[] | undefined, height = 22): string {
|
|
13
|
+
if (!bars || bars.length === 0) return "";
|
|
14
|
+
const width = bars.length * BAR_UNIT;
|
|
15
|
+
const max = Math.max(1, ...bars.map(b => b.value ?? 0));
|
|
16
|
+
const lastWithData = bars.map(b => b.value !== null).lastIndexOf(true);
|
|
17
|
+
const rects = bars.map((b, i) => {
|
|
18
|
+
const x = i * BAR_UNIT;
|
|
19
|
+
const w = BAR_UNIT - 1;
|
|
20
|
+
const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
|
|
21
|
+
if (b.value === null) {
|
|
22
|
+
return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
|
|
23
|
+
}
|
|
24
|
+
const h = Math.max(1, (b.value / max) * (height - 5));
|
|
25
|
+
const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
|
|
26
|
+
return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
|
|
27
|
+
});
|
|
28
|
+
const first = esc(bars[0]!.key), last = esc(bars[bars.length - 1]!.key);
|
|
29
|
+
return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mark every bar with this key, on every chart under root, and clear the rest. null clears all. */
|
|
33
|
+
export function markKey(root: ParentNode, key: string | null): void {
|
|
34
|
+
for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
|
|
35
|
+
if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
|
|
36
|
+
else r.removeAttribute("data-hl");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Hovering a bar marks its key everywhere and tells the host page; leaving the canvas clears it. */
|
|
41
|
+
export function installBars(ctx: Ctx): void {
|
|
42
|
+
const set = (key: string | null): void => {
|
|
43
|
+
if (ctx.hoverKey === key) return;
|
|
44
|
+
ctx.hoverKey = key;
|
|
45
|
+
markKey(ctx.els.world, key);
|
|
46
|
+
ctx.onHoverKey?.(key);
|
|
47
|
+
};
|
|
48
|
+
on(ctx, ctx.els.world, "mouseover", (e: MouseEvent) => {
|
|
49
|
+
const bar = (e.target as Element | null)?.closest?.("rect[data-key]");
|
|
50
|
+
set(bar ? bar.getAttribute("data-key") : null);
|
|
51
|
+
});
|
|
52
|
+
on(ctx, ctx.els.world, "mouseleave", () => set(null));
|
|
53
|
+
}
|
|
@@ -92,6 +92,9 @@ export interface Ctx {
|
|
|
92
92
|
collapsed: Set<string>;
|
|
93
93
|
selected: string | null;
|
|
94
94
|
hl: string | null;
|
|
95
|
+
/** The bar key under the pointer (see bars.ts), or null. */
|
|
96
|
+
hoverKey: string | null;
|
|
97
|
+
onHoverKey?: (key: string | null) => void;
|
|
95
98
|
query: string;
|
|
96
99
|
hops: number;
|
|
97
100
|
filter: Filter;
|
|
@@ -17,6 +17,7 @@ import { install as installNotes } from "./notes.ts";
|
|
|
17
17
|
import { install as installPlayback, type PlaybackController } from "./playbackui.ts";
|
|
18
18
|
import { diffModels } from "./diff.ts";
|
|
19
19
|
import { notifySelection } from "./selection.ts";
|
|
20
|
+
import { installBars, markKey } from "./bars.ts";
|
|
20
21
|
import type { Issue } from "./model.ts";
|
|
21
22
|
|
|
22
23
|
export interface FlowStudio {
|
|
@@ -26,6 +27,8 @@ export interface FlowStudio {
|
|
|
26
27
|
setView(v: View, animate?: boolean): void;
|
|
27
28
|
fit(animate?: boolean): void;
|
|
28
29
|
select(id: string | null): void;
|
|
30
|
+
/** Mark one bar key on every chart, as if the pointer were over it. Does not call onHoverKey. */
|
|
31
|
+
highlightKey(key: string | null): void;
|
|
29
32
|
selected(): string | null;
|
|
30
33
|
mode(): string;
|
|
31
34
|
setMode(id: string): void;
|
|
@@ -65,7 +68,7 @@ function findEls(root: ParentNode): Els {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
interface SavedState { view: View; mode: string; selected: string | null; inspectorOpen: boolean; query: string; hops: number; filter: Ctx["filter"]; collapsed: Set<string>; knownGroups: Set<string>; playback: ReturnType<PlaybackController["playback"]> }
|
|
68
|
-
export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; restore?: SavedState } = {}): FlowStudio {
|
|
71
|
+
export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; onHoverKey?: (key: string | null) => void; restore?: SavedState } = {}): FlowStudio {
|
|
69
72
|
if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
|
|
70
73
|
throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
|
|
71
74
|
}
|
|
@@ -78,7 +81,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
|
|
|
78
81
|
|
|
79
82
|
const rm = matchMedia("(prefers-reduced-motion: reduce)");
|
|
80
83
|
const ctx: Ctx = {
|
|
81
|
-
model, root, els, onSelect: opts.onSelect,
|
|
84
|
+
model, root, els, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, hoverKey: null,
|
|
82
85
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
83
86
|
nodes: new Map(model.nodes.map(n => [n.id, n])),
|
|
84
87
|
links: model.links,
|
|
@@ -103,6 +106,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
|
|
|
103
106
|
|
|
104
107
|
installInteract(ctx);
|
|
105
108
|
installRender(ctx);
|
|
109
|
+
installBars(ctx);
|
|
106
110
|
installControls(ctx);
|
|
107
111
|
installInspector(ctx);
|
|
108
112
|
installChrome(ctx);
|
|
@@ -145,6 +149,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
|
|
|
145
149
|
setView: (v, animate) => ctx.fn.setView(v, animate),
|
|
146
150
|
fit: animate => { ctx.userMoved = false; ctx.fn.fit(animate); },
|
|
147
151
|
select: id => ctx.fn.select(id),
|
|
152
|
+
highlightKey: key => { ctx.hoverKey = key; markKey(ctx.els.world, key); },
|
|
148
153
|
selected: () => ctx.selected,
|
|
149
154
|
mode: () => ctx.mode,
|
|
150
155
|
setMode: id => ctx.fn.setMode(id),
|
|
@@ -167,7 +172,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
|
|
|
167
172
|
const changes = diffModels(ctx.model, prepared);
|
|
168
173
|
const saved: SavedState = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter(n => n.kind === "group").map(n => n.id)), playback: playback.playback() };
|
|
169
174
|
api.destroy();
|
|
170
|
-
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
175
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
|
|
171
176
|
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
172
177
|
// Refresh this api in place: callers keep their reference and window.FlowStudio stays this
|
|
173
178
|
// instance, so an update can never overwrite a different mounted engine.
|
|
@@ -142,3 +142,20 @@ test("free layout preserves finite negative node coordinates", () => {
|
|
|
142
142
|
assert.equal(g.nodes.a!.y, -40);
|
|
143
143
|
assert.ok([g.bounds.x, g.bounds.y, g.bounds.w, g.bounds.h, g.world.w, g.world.h].every(Number.isFinite));
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
test("a node with bars is taller by default, so the chart does not sit on its text; an explicit height wins", () => {
|
|
147
|
+
const model = m();
|
|
148
|
+
model.nodes[0]!.bars = [{ key: "a", value: 1 }];
|
|
149
|
+
const g = computeGeometry(model);
|
|
150
|
+
assert.equal(g.nodes.a!.h, 54 + 26);
|
|
151
|
+
assert.equal(g.nodes.b!.h, 54);
|
|
152
|
+
const free = m();
|
|
153
|
+
free.layout = "free";
|
|
154
|
+
free.nodes = [
|
|
155
|
+
{ id: "x", kind: "card", lane: "l1", x: 0, y: 0, state: "ok", title: "X", bars: [{ key: "a", value: 1 }] },
|
|
156
|
+
{ id: "y", kind: "card", lane: "l1", x: 0, y: 200, h: 100, state: "ok", title: "Y", bars: [{ key: "a", value: 1 }] },
|
|
157
|
+
];
|
|
158
|
+
const gf = computeGeometry(free);
|
|
159
|
+
assert.equal(gf.nodes.x!.h, 54 + 26);
|
|
160
|
+
assert.equal(gf.nodes.y!.h, 100);
|
|
161
|
+
});
|
|
@@ -17,6 +17,7 @@ export interface LayoutOptions { pitch: number; top: number; gap: number; padX:
|
|
|
17
17
|
|
|
18
18
|
export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
19
19
|
|
|
20
|
+
const BARS_EXTRA = 26; // room for the bar chart under a node's text
|
|
20
21
|
const LANE_PAD_TOP = 38; // room for the lane title
|
|
21
22
|
const LANE_PAD_BOTTOM = 14;
|
|
22
23
|
|
|
@@ -29,7 +30,7 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
|
|
|
29
30
|
|
|
30
31
|
const nodes: Record<string, Placed> = {};
|
|
31
32
|
for (const n of m.nodes) {
|
|
32
|
-
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
33
|
+
const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
|
|
33
34
|
if (m.layout === "free") {
|
|
34
35
|
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
35
36
|
continue;
|
|
@@ -43,6 +43,7 @@ export interface NodeDef {
|
|
|
43
43
|
collapsed?: boolean;
|
|
44
44
|
sla?: number;
|
|
45
45
|
metrics?: NodeMetric;
|
|
46
|
+
bars?: NodeBar[];
|
|
46
47
|
}
|
|
47
48
|
export interface NodeMetric {
|
|
48
49
|
value: number;
|
|
@@ -52,6 +53,14 @@ export interface NodeMetric {
|
|
|
52
53
|
label?: string;
|
|
53
54
|
series?: number[];
|
|
54
55
|
}
|
|
56
|
+
/** One bar of a node's chart. `key` is opaque (a date, a step) and links the same key across charts;
|
|
57
|
+
* a null `value` means no data for that key and is drawn as a short red tick, never as zero. */
|
|
58
|
+
export interface NodeBar {
|
|
59
|
+
key: string;
|
|
60
|
+
value: number | null;
|
|
61
|
+
kind?: "low";
|
|
62
|
+
label?: string;
|
|
63
|
+
}
|
|
55
64
|
export interface LinkDef {
|
|
56
65
|
id: string;
|
|
57
66
|
from: string;
|
|
@@ -153,6 +162,8 @@ export function validateModel(input: unknown): Issue[] {
|
|
|
153
162
|
const metric = n.metrics;
|
|
154
163
|
const invalidSeries = metric?.series !== undefined && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some(v => !Number.isFinite(v)));
|
|
155
164
|
if (metric !== undefined && (!metric || !Number.isFinite(metric.value) || (metric.max != null && !Number.isFinite(metric.max)) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
|
|
165
|
+
const badBars = n.bars !== undefined && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some(b => !b || typeof b.key !== "string" || (b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0))));
|
|
166
|
+
if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
|
|
156
167
|
if (n.sla !== undefined && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
|
|
157
168
|
}
|
|
158
169
|
for (const n of nodes) {
|
|
@@ -9,6 +9,7 @@ import { elbow, loopBack } from "./routing.ts";
|
|
|
9
9
|
import { cssId } from "./stateCss.ts";
|
|
10
10
|
import { baselineView, bucketOf, canonOf, effectiveLinks, isImproved, isNew, linkStateId, nodeStateId, nodeVisible } from "./rules.ts";
|
|
11
11
|
import { metricBadge, sparkline } from "./metrics.ts";
|
|
12
|
+
import { barsHtml, markKey } from "./bars.ts";
|
|
12
13
|
|
|
13
14
|
function loopRoute(ctx: Ctx, fromId: string, toId: string, from: Placed, to: Placed, channel: number): string {
|
|
14
15
|
const detourFor = (end: Placed, other: Placed) => {
|
|
@@ -45,7 +46,7 @@ function nodeHtml(ctx: Ctx, n: NodeDef): string {
|
|
|
45
46
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
|
|
46
47
|
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">▸ ${n.contains.length}</span>` : "";
|
|
47
48
|
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div>`
|
|
48
|
-
+ `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
|
|
49
|
+
+ `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function build(ctx: Ctx): void {
|
|
@@ -75,7 +76,7 @@ function build(ctx: Ctx): void {
|
|
|
75
76
|
for (const n of model.nodes) {
|
|
76
77
|
const p = ctx.geom.nodes[n.id];
|
|
77
78
|
if (!p) continue;
|
|
78
|
-
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
79
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
|
|
79
80
|
d.id = "n_" + n.id;
|
|
80
81
|
d.tabIndex = 0;
|
|
81
82
|
d.setAttribute("role", "button");
|
|
@@ -190,6 +191,7 @@ function paint(ctx: Ctx, fromSwitch = false): void {
|
|
|
190
191
|
if (r) { d.style.left = r.x + "px"; d.style.top = r.y + "px"; d.style.width = r.w + "px"; d.style.height = r.h + "px"; }
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
markKey(ctx.els.world, ctx.hoverKey);
|
|
193
195
|
ctx.fn.buildChrome();
|
|
194
196
|
ctx.fn.trace(ctx.selected);
|
|
195
197
|
ctx.fn.renderInspector();
|
|
@@ -184,6 +184,11 @@
|
|
|
184
184
|
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
185
185
|
.node .s.has-spark { padding-right:60px; }
|
|
186
186
|
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
187
|
+
.node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
|
|
188
|
+
.node svg.bars rect { fill:var(--muted); opacity:.6; }
|
|
189
|
+
.node svg.bars rect.last { fill:var(--accent); opacity:1; }
|
|
190
|
+
.node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
|
|
191
|
+
.node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
|
|
187
192
|
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
188
193
|
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
189
194
|
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|