Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/diagrams/gallery/grouped-zones-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 12 additions & 6 deletions packages/viewer/e2e/grouped-geometry.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const VIEWER = join(HERE, "..");
const EXAMPLES = join(VIEWER, "..", "..", "plugin", "skills", "diagram-recipes", "examples");
const mode = process.argv[2] ?? "tiers";
if (mode !== "tiers") throw new Error(`unsupported grouped geometry mode: ${mode}`);
if (!["tiers", "zones"].includes(mode)) throw new Error(`unsupported grouped geometry mode: ${mode}`);

const PORT = Number(process.env.GROUPED_GEOMETRY_PORT ?? 8197);
const TOKEN = "grouped-geometry-token";
const BASE = `http://127.0.0.1:${PORT}`;
const spec = JSON.parse(readFileSync(join(EXAMPLES, "architecture-zones.flow.json"), "utf8"));
const example = mode === "tiers" ? "architecture-zones.flow.json" : "data-lineage.flow.json";
const readyNode = mode === "tiers" ? "bff" : "events";
const groupPrefix = mode === "tiers" ? "__tier__" : "__grp__";
const spec = JSON.parse(readFileSync(join(EXAMPLES, example), "utf8"));
const edgeById = new Map(spec.edges.map((edge, index) => [edge.id ?? `e${index}-${edge.source}-${edge.target}`, edge]));

async function waitUp() {
Expand All @@ -40,14 +43,14 @@ try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
await page.goto(`${BASE}/w/grouped/#verify%2F${mode}`);
await page.waitForSelector('.react-flow__node[data-id="bff"]', { state: "visible" });
await page.waitForSelector(`.react-flow__node[data-id="${readyNode}"]`, { state: "visible" });
await page.waitForFunction(() => {
const wrap = document.querySelector(".tc-flow-wrap");
return wrap && getComputedStyle(wrap.querySelector(".react-flow")).visibility !== "hidden";
});
await page.waitForTimeout(150);

const metrics = await page.evaluate(({ edges }) => {
const metrics = await page.evaluate(({ edges, groupPrefix }) => {
const wrappers = [...document.querySelectorAll(".react-flow__node")];
const groups = wrappers.filter((node) => node.classList.contains("react-flow__node-group"));
const leaves = wrappers.filter((node) => !node.classList.contains("react-flow__node-group"));
Expand Down Expand Up @@ -101,7 +104,7 @@ try {
const labelPadding = [];
for (const label of document.querySelectorAll("[data-group-label-id]")) {
const id = label.getAttribute("data-group-label-id");
const group = groupBoxes.get(`__tier__${id}`);
const group = groupBoxes.get(`${groupPrefix}${id}`);
if (!group) continue;
const rect = box(label);
labelPadding.push({ id, left: (rect.left - group.left) / zoom, top: (rect.top - group.top) / zoom });
Expand Down Expand Up @@ -140,7 +143,7 @@ try {
}
}
return { zoom, nodeOverlap, containerOverlap, spillout, padding, minMemberGap, labelPadding, anchorMismatch, edgeOverNode: [...edgeOverNode] };
}, { edges: [...edgeById] });
}, { edges: [...edgeById], groupPrefix });

const failures = [];
if (metrics.nodeOverlap.length) failures.push(`node overlap: ${metrics.nodeOverlap.join(", ")}`);
Expand All @@ -154,6 +157,9 @@ try {
if (metrics.edgeOverNode.length) failures.push(`edge over node: ${metrics.edgeOverNode.join(", ")}`);
console.log(JSON.stringify(metrics, null, 2));
if (failures.length) throw new Error(failures.join("\n"));
if (process.env.GROUPED_GEOMETRY_SCREENSHOT) {
await page.screenshot({ path: process.env.GROUPED_GEOMETRY_SCREENSHOT, fullPage: true });
}
console.log(`PASS grouped ${mode} geometry`);
} finally {
await browser?.close();
Expand Down
6 changes: 1 addition & 5 deletions packages/viewer/e2e/overlap-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@
],
"_source": "CI run 31290712587 (master @ fb6ff4a, ubuntu-latest)",
"known_failing": {
"ex_data-lineage": { "observed": ["edge-over-node:events"] },
"ex_dataeng-etl": { "observed": ["edge-over-node:kafka"] },
"ex_k8s-topology": { "observed": ["edge-over-node:ingress,cm"] },
"ex_support-escalation": { "observed": ["edge-over-node:created,triage,l2inv"] },
"ex_swimlane": { "observed": ["edge-over-node:checkout"] },
"syn_groups_long": { "observed": ["edge-over-node:a1,a2"] }
"ex_swimlane": { "observed": ["edge-over-node:checkout"] }
}
}
132 changes: 129 additions & 3 deletions packages/viewer/src/client/renderers/flow-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ function layoutGroupedFlow(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[])
const m = meta.get(grp)!, p = metaPos[gkey(grp)];
out.push({
id: gkey(grp), type: "group", position: { x: p.x, y: p.y },
data: { label: m.label, color: m.color },
data: { label: m.label, color: m.color, _groupId: grp },
style: { width: boxW(grp), height: boxH(grp) },
selectable: false, draggable: false, zIndex: 0,
} as FlowNode);
Expand All @@ -362,6 +362,133 @@ function layoutGroupedFlow(spec: FlowSpec, nodes: FlowNode[], edges: FlowEdge[])
return { nodes: out, edges: withEdgeIds(edges) };
}

/** Authoritative compact-zone construction from one complete painted-geometry snapshot. */
function layoutMeasuredZones(
spec: FlowSpec,
nodes: FlowNode[],
edges: FlowEdge[],
measured: MeasuredGroupedGeometry,
): { nodes: FlowNode[]; edges: FlowEdge[] } {
const gkey = (id: string) => `__grp__${id}`;
const dir = spec.direction ?? "TB";
const meta = new Map<string, { label: string; color?: string }>();
for (const group of Array.isArray(spec.groups) ? spec.groups : [])
if (group && typeof group.id === "string") meta.set(group.id, { label: group.label ?? group.id, color: group.color });

const order: string[] = [];
const members = new Map<string, FlowNode[]>();
const loose: FlowNode[] = [];
const zoneOf = new Map<string, string>();
for (const node of nodes) {
const group = groupOf(node);
if (!group) { loose.push(node); continue; }
if (!members.has(group)) { members.set(group, []); order.push(group); }
if (!meta.has(group)) meta.set(group, { label: group });
members.get(group)!.push(node);
zoneOf.set(node.id, group);
}

const sizes = new Map<string, { width: number; height: number }>();
for (const node of nodes) {
const measuredSize = measured.nodes.get(node.id);
if (!measuredSize || !(measuredSize.width > 0 && measuredSize.height > 0)) throw new Error(`missing measured grouped node: ${node.id}`);
sizes.set(node.id, measuredSize);
}

const clusters = new Map<string, { place: Record<string, { x: number; y: number }>; width: number; height: number }>();
const containerSize = new Map<string, { width: number; height: number }>();
for (const group of order) {
const groupMembers = members.get(group)!;
const { rel } = layoutCluster(groupMembers, edges, sizes, dir);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of groupMembers) {
const point = rel[node.id], nodeSize = sizes.get(node.id)!;
minX = Math.min(minX, point.x); minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x + nodeSize.width); maxY = Math.max(maxY, point.y + nodeSize.height);
}
const place: Record<string, { x: number; y: number }> = {};
for (const node of groupMembers) place[node.id] = { x: rel[node.id].x - minX, y: rel[node.id].y - minY };
const width = maxX - minX, height = maxY - minY;
clusters.set(group, { place, width, height });
const label = measured.groupLabels.get(group);
if (!label) throw new Error(`missing measured grouped label: ${group}`);
containerSize.set(group, {
width: Math.max(width + GROUPED_SPACING.containerPadX * 2, label.width + GROUPED_SPACING.labelPadX * 2),
height: GROUPED_SPACING.memberTop + height + GROUPED_SPACING.containerPadBottom,
});
}

const metaId = (id: string) => zoneOf.has(id) ? gkey(zoneOf.get(id)!) : id;
const metaNodes = [
...order.map((group) => ({ id: gkey(group), ...containerSize.get(group)! })),
...loose.map((node) => ({ id: node.id, ...sizes.get(node.id)! })),
];
const seen = new Set<string>();
const metaEdges: { source: string; target: string }[] = [];
for (const edge of edges) {
const source = metaId(edge.source), target = metaId(edge.target), key = `${source}\u0000${target}`;
if (source !== target && !seen.has(key)) { seen.add(key); metaEdges.push({ source, target }); }
}
const metaPosition = dagreLayout(metaNodes, metaEdges, dir);

const containers: FlowNode[] = [];
const children: FlowNode[] = [];
const absolute = new Map<string, { x: number; y: number; width: number; height: number }>();
for (const group of order) {
const position = metaPosition[gkey(group)], dimensions = containerSize.get(group)!, groupMeta = meta.get(group)!;
containers.push({
id: gkey(group), type: "group", position,
data: { label: groupMeta.label, color: groupMeta.color, _groupId: group, _measuredGrouped: true },
style: dimensions, ...dimensions, selectable: false, draggable: false, zIndex: 0,
} as FlowNode);
for (const node of members.get(group)!) {
const point = clusters.get(group)!.place[node.id], nodeSize = sizes.get(node.id)!;
const childPosition = { x: GROUPED_SPACING.containerPadX + point.x, y: GROUPED_SPACING.memberTop + point.y };
children.push({
...node, ...nodeSize, parentId: gkey(group), extent: "parent", position: childPosition,
data: { ...(node.data ?? {}), _groupedAnchors: true },
} as FlowNode);
absolute.set(node.id, { x: position.x + childPosition.x, y: position.y + childPosition.y, ...nodeSize });
}
}
for (const node of loose) {
const dimensions = sizes.get(node.id)!, position = metaPosition[node.id];
children.push({ ...node, ...dimensions, position, data: { ...(node.data ?? {}), _groupedAnchors: true } } as FlowNode);
absolute.set(node.id, { ...position, ...dimensions });
}

const normalized = withEdgeIds(edges);
const routedInput = normalized.flatMap((edge) => {
const source = absolute.get(edge.source), target = absolute.get(edge.target);
if (!source || !target) return [];
const sx = source.x + source.width / 2, sy = source.y + source.height / 2;
const tx = target.x + target.width / 2, ty = target.y + target.height / 2;
let sourceSide: GroupedAnchorSide, targetSide: GroupedAnchorSide;
if (Math.abs(tx - sx) >= Math.abs(ty - sy)) {
const right = tx >= sx; sourceSide = right ? "r" : "l"; targetSide = right ? "l" : "r";
} else {
const down = ty >= sy; sourceSide = down ? "b" : "t"; targetSide = down ? "t" : "b";
}
const label = typeof (edge as { label?: unknown }).label === "string" ? String((edge as { label?: unknown }).label) : undefined;
return [{ id: edge.id!, source: edge.source, target: edge.target, sourceSide, targetSide, label, labelSize: label ? measured.edgeLabels.get(edge.id!) : undefined }];
});
const routed = routeFixedOrthogonalEdges({
nodes: [...absolute.entries()].map(([id, box]) => ({ id, ...box })),
edges: routedInput,
});
const routeById = new Map(routed.map((edge) => [edge.id, edge]));
const inputById = new Map(routedInput.map((edge) => [edge.id, edge]));
const outEdges = normalized.map((edge) => {
const route = routeById.get(edge.id!), input = inputById.get(edge.id!);
if (!route || !input) return edge;
return {
...edge, type: "sgcr", sourceHandle: input.sourceSide, targetHandle: input.targetSide,
data: { ...(edge.data as Record<string, unknown> | undefined), points: route.points, label: route.label, labelBox: route.labelBox },
};
});
return { nodes: [...containers, ...children], edges: outEdges };
}

/**
* Lay out a grouped graph as parallel full-length **swimlanes** — one lane per group (in
* `spec.groups` order, discovered groups appended, ungrouped trailing). Each lane is laid out
Expand Down Expand Up @@ -730,8 +857,7 @@ export function layoutMeasuredGroupedFlow(
): { nodes: FlowNode[]; edges: FlowEdge[] } {
const nodes = (Array.isArray(spec.nodes) ? spec.nodes : []).filter((node): node is FlowNode => !!node && typeof node === "object");
const edges = (Array.isArray(spec.edges) ? spec.edges : []).filter((edge): edge is FlowEdge => !!edge && typeof edge === "object");
if (!spec.tiers) throw new Error("measured grouped layout mode is not implemented in this stack layer");
return layoutMeasuredTiers(spec, nodes, edges, measured);
return spec.tiers ? layoutMeasuredTiers(spec, nodes, edges, measured) : layoutMeasuredZones(spec, nodes, edges, measured);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/viewer/src/client/renderers/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ export const mount: Mount = (el, content) => {
// because zone containers + parentId + smoothstep edges are dagre-path infrastructure —
// but their INTRA-zone layout is already SGCR (Phase 3, layoutCluster).
const useSgcr = spec.engine !== "dagre" && !grouped;
const useMeasuredGrouped = measuredGroupedMode({ engine: spec.engine, grouped, tiers: spec.tiers, lanes: spec.lanes }) === "tiers";
const useMeasuredGrouped = measuredGroupedMode({ engine: spec.engine, grouped, tiers: spec.tiers, lanes: spec.lanes }) !== null;
if (useSgcr) {
try {
assertMeasuredSgcrLabelModes(
Expand Down
5 changes: 3 additions & 2 deletions packages/viewer/src/client/renderers/grouped-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export interface MeasuredGroupedModeFlags {
}

/** Select measured tiers without changing the established lanes-over-tiers precedence. */
export function measuredGroupedMode(flags: MeasuredGroupedModeFlags): "tiers" | null {
return flags.engine !== "dagre" && flags.grouped && flags.tiers === true && flags.lanes !== true ? "tiers" : null;
export function measuredGroupedMode(flags: MeasuredGroupedModeFlags): "tiers" | "zones" | null {
if (flags.engine === "dagre" || !flags.grouped || flags.lanes === true) return null;
return flags.tiers === true ? "tiers" : "zones";
}

/** Any grouped data patch may resize painted content, so it must remount and remeasure atomically. */
Expand Down
104 changes: 104 additions & 0 deletions packages/viewer/test/grouped-zones.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { layoutMeasuredGroupedFlow, type FlowNode, type FlowSpec } from "../src/client/renderers/flow-layout.js";
import { GROUPED_SPACING, type MeasuredGroupedGeometry } from "../src/client/renderers/grouped-geometry.js";

const spec: FlowSpec = {
direction: "TB",
groups: [
{ id: "frontend", label: "Frontend applications and gateways" },
{ id: "services", label: "Backend services" },
],
nodes: [
{ id: "web", group: "frontend", data: { label: "Web App" } },
{ id: "bff", group: "frontend", data: { label: "BFF / Gateway" } },
{ id: "auth", group: "services", data: { label: "Auth" } },
{ id: "orders", group: "services", data: { label: "Orders" } },
],
edges: [
{ id: "inside", source: "web", target: "bff" },
{ id: "cross", source: "bff", target: "auth", label: "request" },
],
};

const measured: MeasuredGroupedGeometry = {
nodes: new Map([
["web", { width: 146, height: 52 }],
["bff", { width: 286, height: 76 }],
["auth", { width: 132, height: 48 }],
["orders", { width: 164, height: 58 }],
]),
groupLabels: new Map([
["frontend", { width: 238, height: 16 }],
["services", { width: 112, height: 16 }],
]),
edgeLabels: new Map([["cross", { width: 72, height: 24 }]]),
};

const byId = (nodes: FlowNode[], id: string) => nodes.find((node) => node.id === id)!;
const size = (node: FlowNode) => ({ width: Number(node.width ?? (node.style as { width?: number })?.width), height: Number(node.height ?? (node.style as { height?: number })?.height) });
const absolute = (nodes: FlowNode[], id: string) => {
const node = byId(nodes, id);
const parent = byId(nodes, String(node.parentId));
return { x: parent.position!.x + node.position!.x, y: parent.position!.y + node.position!.y, width: node.width!, height: node.height! };
};

describe("layoutMeasuredGroupedFlow compact zones", () => {
const out = layoutMeasuredGroupedFlow(spec, measured);

it("contains every painted member with standard padding and reserves the measured label strip", () => {
for (const id of ["web", "bff", "auth", "orders"]) {
const node = byId(out.nodes, id);
const parent = byId(out.nodes, String(node.parentId));
const parentSize = size(parent);
expect(node.position!.x).toBeGreaterThanOrEqual(GROUPED_SPACING.containerPadX);
expect(node.position!.y).toBeGreaterThanOrEqual(GROUPED_SPACING.memberTop);
expect(node.position!.x + node.width!).toBeLessThanOrEqual(parentSize.width - GROUPED_SPACING.containerPadX);
expect(node.position!.y + node.height!).toBeLessThanOrEqual(parentSize.height - GROUPED_SPACING.containerPadBottom);
}
expect(size(byId(out.nodes, "__grp__frontend")).width).toBeGreaterThanOrEqual(
measured.groupLabels.get("frontend")!.width + GROUPED_SPACING.labelPadX * 2,
);
});

it("keeps compact zone containers disjoint with at least the standard gap", () => {
const a = byId(out.nodes, "__grp__frontend"), b = byId(out.nodes, "__grp__services");
const as = size(a), bs = size(b);
const gap = Math.max(
b.position!.x - (a.position!.x + as.width),
a.position!.x - (b.position!.x + bs.width),
b.position!.y - (a.position!.y + as.height),
a.position!.y - (b.position!.y + bs.height),
);
expect(gap).toBeGreaterThanOrEqual(GROUPED_SPACING.containerGap);
});

it("routes from explicit face-centre handles for intra- and cross-zone edges", () => {
for (const edge of out.edges) {
const points = (edge.data as { points: { x: number; y: number }[] }).points;
const source = absolute(out.nodes, edge.source), target = absolute(out.nodes, edge.target);
const face = (box: typeof source, side: unknown) => side === "t" ? { x: box.x + box.width / 2, y: box.y }
: side === "r" ? { x: box.x + box.width, y: box.y + box.height / 2 }
: side === "b" ? { x: box.x + box.width / 2, y: box.y + box.height }
: { x: box.x, y: box.y + box.height / 2 };
expect(points[0]).toEqual(face(source, edge.sourceHandle));
expect(points.at(-1)).toEqual(face(target, edge.targetHandle));
}
});

it("places every authored measured label node-clear or rejects construction visibly", () => {
const cross = out.edges.find((edge) => edge.id === "cross")!;
const labelBox = (cross.data as { labelBox?: { x: number; y: number; width: number; height: number } }).labelBox;
expect(labelBox).toBeTruthy();
for (const id of ["web", "bff", "auth", "orders"]) {
const node = absolute(out.nodes, id);
expect(labelBox!.x < node.x + node.width && labelBox!.x + labelBox!.width > node.x && labelBox!.y < node.y + node.height && labelBox!.y + labelBox!.height > node.y).toBe(false);
}
const impossible = { ...measured, edgeLabels: new Map([["cross", { width: 10_000, height: 10_000 }]]) };
const oversized = layoutMeasuredGroupedFlow(spec, impossible).edges.find((edge) => edge.id === "cross")!;
expect((oversized.data as { labelBox?: unknown }).labelBox).toBeTruthy();
});

it("is byte-identical for repeated measured construction", () => {
expect(layoutMeasuredGroupedFlow(spec, measured)).toEqual(out);
});
});
Loading