@caddis/cli 0.4.48 → 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 +2 -2
- 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/design-language.md +5 -5
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +16 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +13 -4
- 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/playbackui.ts +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +4 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +6 -1
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +64 -7
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +6 -1
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +58 -6
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +2 -2
- 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/design-language.md +5 -5
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +16 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +13 -4
- 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/playbackui.ts +1 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +4 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +6 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +64 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +6 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +58 -6
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -23,7 +23,7 @@ export function install(ctx: Ctx): PlaybackController {
|
|
|
23
23
|
// Only the elements marked in the last draw are cleared again, so a frame does not walk the whole graph.
|
|
24
24
|
let markedEdges: SVGPathElement[] = [];
|
|
25
25
|
let markedNodes: HTMLElement[] = [];
|
|
26
|
-
const token = sv("circle", { id: "token", class: "token", r: "
|
|
26
|
+
const token = sv("circle", { id: "token", class: "token", r: "4" }) as SVGCircleElement;
|
|
27
27
|
e.layer.appendChild(token);
|
|
28
28
|
const pathOf = (link: string): SVGPathElement | undefined => ctx.edgeEls[ctx.links.findIndex(l => l.id === link)];
|
|
29
29
|
const nodeOf = (id: string) => ctx.nodeEls.get(id);
|
|
@@ -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();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
--bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
|
|
3
3
|
--accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
|
|
4
|
-
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:
|
|
4
|
+
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:0.8px; --edge-w-hot:1px; --edge-w-play:1.2px; --trail-w:1.3px; --ripple-w:1.5px;
|
|
5
5
|
--warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
|
|
6
6
|
--mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
|
7
7
|
}
|
|
@@ -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; }
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<style>:root {
|
|
16
16
|
--bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
|
|
17
17
|
--accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
|
|
18
|
-
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:
|
|
18
|
+
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:0.8px; --edge-w-hot:1px; --edge-w-play:1.2px; --trail-w:1.3px; --ripple-w:1.5px;
|
|
19
19
|
--warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
|
|
20
20
|
--mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
|
21
21
|
}
|
|
@@ -198,6 +198,11 @@
|
|
|
198
198
|
.node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
|
|
199
199
|
.node .s.has-spark { padding-right:60px; }
|
|
200
200
|
.node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
|
|
201
|
+
.node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
|
|
202
|
+
.node svg.bars rect { fill:var(--muted); opacity:.6; }
|
|
203
|
+
.node svg.bars rect.last { fill:var(--accent); opacity:1; }
|
|
204
|
+
.node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
|
|
205
|
+
.node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
|
|
201
206
|
.node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
|
|
202
207
|
.node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
|
|
203
208
|
.node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
|
|
@@ -229,7 +234,7 @@
|
|
|
229
234
|
<div class="workspace" id="workspace">
|
|
230
235
|
<section class="canvaswrap">
|
|
231
236
|
<div id="viewport" tabindex="0" role="application" aria-label="Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.">
|
|
232
|
-
<div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="
|
|
237
|
+
<div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
|
|
233
238
|
<div id="cols" aria-hidden="true"></div>
|
|
234
239
|
<div id="tools" role="toolbar" aria-label="Canvas controls" aria-orientation="vertical"></div>
|
|
235
240
|
<div id="zoomLabel" aria-hidden="true">100%</div>
|
|
@@ -326,6 +331,8 @@
|
|
|
326
331
|
const metric = n.metrics;
|
|
327
332
|
const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
|
|
328
333
|
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.");
|
|
334
|
+
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)));
|
|
335
|
+
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.");
|
|
329
336
|
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.");
|
|
330
337
|
}
|
|
331
338
|
for (const n of nodes) {
|
|
@@ -975,6 +982,7 @@
|
|
|
975
982
|
|
|
976
983
|
// src/layout.ts
|
|
977
984
|
var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
985
|
+
var BARS_EXTRA = 26;
|
|
978
986
|
var LANE_PAD_TOP = 38;
|
|
979
987
|
var LANE_PAD_BOTTOM = 14;
|
|
980
988
|
function computeGeometry(m, opt = {}) {
|
|
@@ -988,7 +996,7 @@
|
|
|
988
996
|
const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
|
|
989
997
|
const nodes = {};
|
|
990
998
|
for (const n of m.nodes) {
|
|
991
|
-
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
999
|
+
const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
|
|
992
1000
|
if (m.layout === "free") {
|
|
993
1001
|
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
994
1002
|
continue;
|
|
@@ -1105,6 +1113,47 @@
|
|
|
1105
1113
|
return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
|
|
1106
1114
|
}
|
|
1107
1115
|
|
|
1116
|
+
// src/bars.ts
|
|
1117
|
+
var BAR_UNIT = 4;
|
|
1118
|
+
function barsHtml(bars, height = 22) {
|
|
1119
|
+
if (!bars || bars.length === 0) return "";
|
|
1120
|
+
const width = bars.length * BAR_UNIT;
|
|
1121
|
+
const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
|
|
1122
|
+
const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
|
|
1123
|
+
const rects = bars.map((b, i) => {
|
|
1124
|
+
const x = i * BAR_UNIT;
|
|
1125
|
+
const w = BAR_UNIT - 1;
|
|
1126
|
+
const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
|
|
1127
|
+
if (b.value === null) {
|
|
1128
|
+
return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
|
|
1129
|
+
}
|
|
1130
|
+
const h = Math.max(1, b.value / max * (height - 5));
|
|
1131
|
+
const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
|
|
1132
|
+
return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
|
|
1133
|
+
});
|
|
1134
|
+
const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
|
|
1135
|
+
return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
|
|
1136
|
+
}
|
|
1137
|
+
function markKey(root, key) {
|
|
1138
|
+
for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
|
|
1139
|
+
if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
|
|
1140
|
+
else r.removeAttribute("data-hl");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function installBars(ctx) {
|
|
1144
|
+
const set = (key) => {
|
|
1145
|
+
if (ctx.hoverKey === key) return;
|
|
1146
|
+
ctx.hoverKey = key;
|
|
1147
|
+
markKey(ctx.els.world, key);
|
|
1148
|
+
ctx.onHoverKey?.(key);
|
|
1149
|
+
};
|
|
1150
|
+
on(ctx, ctx.els.world, "mouseover", (e) => {
|
|
1151
|
+
const bar = e.target?.closest?.("rect[data-key]");
|
|
1152
|
+
set(bar ? bar.getAttribute("data-key") : null);
|
|
1153
|
+
});
|
|
1154
|
+
on(ctx, ctx.els.world, "mouseleave", () => set(null));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1108
1157
|
// src/render.ts
|
|
1109
1158
|
function loopRoute(ctx, fromId, toId, from, to, channel) {
|
|
1110
1159
|
const detourFor = (end, other) => {
|
|
@@ -1141,7 +1190,7 @@
|
|
|
1141
1190
|
const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
|
|
1142
1191
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
|
|
1143
1192
|
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
|
|
1144
|
-
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}`;
|
|
1193
|
+
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}`;
|
|
1145
1194
|
}
|
|
1146
1195
|
function build(ctx) {
|
|
1147
1196
|
const { model, els } = ctx;
|
|
@@ -1176,7 +1225,7 @@
|
|
|
1176
1225
|
for (const n of model.nodes) {
|
|
1177
1226
|
const p = ctx.geom.nodes[n.id];
|
|
1178
1227
|
if (!p) continue;
|
|
1179
|
-
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
1228
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
|
|
1180
1229
|
d.id = "n_" + n.id;
|
|
1181
1230
|
d.tabIndex = 0;
|
|
1182
1231
|
d.setAttribute("role", "button");
|
|
@@ -1311,6 +1360,7 @@
|
|
|
1311
1360
|
d.style.height = r.h + "px";
|
|
1312
1361
|
}
|
|
1313
1362
|
}
|
|
1363
|
+
markKey(ctx.els.world, ctx.hoverKey);
|
|
1314
1364
|
ctx.fn.buildChrome();
|
|
1315
1365
|
ctx.fn.trace(ctx.selected);
|
|
1316
1366
|
ctx.fn.renderInspector();
|
|
@@ -2183,7 +2233,7 @@
|
|
|
2183
2233
|
let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
|
|
2184
2234
|
let markedEdges = [];
|
|
2185
2235
|
let markedNodes = [];
|
|
2186
|
-
const token = sv("circle", { id: "token", class: "token", r: "
|
|
2236
|
+
const token = sv("circle", { id: "token", class: "token", r: "4" });
|
|
2187
2237
|
e.layer.appendChild(token);
|
|
2188
2238
|
const pathOf = (link) => ctx.edgeEls[ctx.links.findIndex((l) => l.id === link)];
|
|
2189
2239
|
const nodeOf = (id) => ctx.nodeEls.get(id);
|
|
@@ -2506,6 +2556,8 @@
|
|
|
2506
2556
|
root,
|
|
2507
2557
|
els,
|
|
2508
2558
|
onSelect: opts.onSelect,
|
|
2559
|
+
onHoverKey: opts.onHoverKey,
|
|
2560
|
+
hoverKey: null,
|
|
2509
2561
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
2510
2562
|
nodes: new Map(model.nodes.map((n) => [n.id, n])),
|
|
2511
2563
|
links: model.links,
|
|
@@ -2548,6 +2600,7 @@
|
|
|
2548
2600
|
style.textContent = stateCss(model.states);
|
|
2549
2601
|
install(ctx);
|
|
2550
2602
|
install2(ctx);
|
|
2603
|
+
installBars(ctx);
|
|
2551
2604
|
install3(ctx);
|
|
2552
2605
|
install4(ctx);
|
|
2553
2606
|
install5(ctx);
|
|
@@ -2594,6 +2647,10 @@
|
|
|
2594
2647
|
ctx.fn.fit(animate);
|
|
2595
2648
|
},
|
|
2596
2649
|
select: (id) => ctx.fn.select(id),
|
|
2650
|
+
highlightKey: (key) => {
|
|
2651
|
+
ctx.hoverKey = key;
|
|
2652
|
+
markKey(ctx.els.world, key);
|
|
2653
|
+
},
|
|
2597
2654
|
selected: () => ctx.selected,
|
|
2598
2655
|
mode: () => ctx.mode,
|
|
2599
2656
|
setMode: (id) => ctx.fn.setMode(id),
|
|
@@ -2619,7 +2676,7 @@
|
|
|
2619
2676
|
const changes = diffModels(ctx.model, prepared);
|
|
2620
2677
|
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() };
|
|
2621
2678
|
api.destroy();
|
|
2622
|
-
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
2679
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
|
|
2623
2680
|
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
2624
2681
|
Object.assign(api, replacement);
|
|
2625
2682
|
window.FlowStudio = api;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
--bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
|
|
3
3
|
--accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
|
|
4
|
-
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:
|
|
4
|
+
--up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:0.8px; --edge-w-hot:1px; --edge-w-play:1.2px; --trail-w:1.3px; --ripple-w:1.5px;
|
|
5
5
|
--warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
|
|
6
6
|
--mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
|
7
7
|
}
|
|
@@ -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; }
|