Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/complete-diagram-scene-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schlessera/brain-module-diagrams": minor
---

Enclose computed groups and headings in fit canvases without moving authored nodes. Previously accepted clipped fixed/narrow inputs now fail validation. Report scene_bounds for clipped fixed groups and layout findings for clipped narrow groups, and honor nonzero viewBox origins in client, static and flattened exports, annotations and legends.
4 changes: 2 additions & 2 deletions api-report/module-diagrams.txt
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ signatures (every public export and the types it is made of)
DiagramGeometryFinding (packages/module-diagrams/src/geometry.ts)
export interface DiagramGeometryFinding {
path: string;
code: "unroutable";
code: "unroutable" | "scene_bounds";
message: string;
}
DiagramGeometryOptions (packages/module-diagrams/src/geometry.ts)
Expand Down Expand Up @@ -183,7 +183,7 @@ signatures (every public export and the types it is made of)
DiagramLayoutFinding (packages/module-diagrams/src/layout.ts)
export interface DiagramLayoutFinding {
path: string;
code: "layout" | "text_floor" | "contrast" | "unroutable";
code: "layout" | "text_floor" | "contrast" | "unroutable" | "scene_bounds";
message: string;
}
DiagramNode (packages/module-diagrams/src/geometry.ts)
Expand Down
10 changes: 8 additions & 2 deletions docs/decisions/ci-utility.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,13 +572,19 @@ and before/after tracing. Loopback resources receive no exemption. A resource
attempt fails the actual proof; source, caller, guard or producer drift requires
a new audit. The scene, visual assertions and trace remain unchanged.

Diagram capture binds all four exact renderer, axis, positional and node-link callers and their complete call-site inventories,
Diagram capture binds all six exact renderer, axis, positional, node-link,
callout and scene-bounds callers and their complete call-site inventories,
closed SVG primitive producers,
literal adopted fixture, generated consumer CSS and locked inline-font inputs.
Its additional replay page contains that source-owned document, aborts and records
HTTP/websocket requests, and checks the zero-request invariant after document and
font loading and after capture. Unknown DOM or resource origins remain unresolved;
source or producer drift requires fresh execution and a new audit.
source or producer drift requires fresh execution and a new audit. The
scene-bounds caller owns one literal origin-group fixture and two literal
annotation variants. Its client/static/flattened renderers share the closed
primitive producer; the fixture, flattening dependency and changed geometry,
annotation and painter sources are pinned explicitly. This audit does not
qualify native retention or replace complete fresh runtime proof.

The selected browser capability additionally requires a complete audited
input-domain identity and an enforced zero-Worker boundary. The native,
Expand Down
23 changes: 17 additions & 6 deletions docs/integration-contract/diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ not prose or filesystem error text.
Error codes are `usage`, `input`, `parse`, `schema`, `unknown_field`,
`upgrade_required`, `duplicate_id`, `duplicate_reference`, `unknown_reference`,
`focal_count`, `tree_cycle`, `grid`, `overlap`, `unroutable`, `layout`,
`text_floor`, `contrast`, `axis`, and `positional`. Common editorial warnings use
`text_floor`, `contrast`, `axis`, `scene_bounds`, and `positional`. Common editorial warnings use
`budget`; family advisories (including positional guidance) are listed with their
grammars below.
A numeric input `schema_version` newer than 1 returns `upgrade_required` at
Expand Down Expand Up @@ -196,18 +196,29 @@ ends at the actual destination. Earlier connectors alone cannot justify
hidden transit. Routing is deterministic and bounded (20,000 candidates or
visibility vertices, 100,000 search steps); it may refuse crowded layouts
rather than claim a complete global routing solver. A refusal throws
`DiagramGeometryError` with `findings` (`path`, `code: unroutable`, `message`);
`DiagramGeometryError` with `findings` (`path`, `code: unroutable | scene_bounds`, `message`);
edge findings name the edge. The checker includes those findings at the end
of its errors and returns exit 1. Invalid metric options throw an error.

Canvas dimensions are fixed for named presets: doc-inline 960×600, doc-wide
and slide-16x9 1280×720, slide-4x3 1024×768, social-og 1200×632,
social-square 1080×1080, print-a4-landscape 1120×792,
print-a3-landscape 1584×1120 and print-letter-landscape 1056×816.
`fit` uses node extents plus 24px, with a 96px minimum per axis. Optional
architecture containment also includes authored scope bounds; dependency cycles
reserve their outside lane plus a margin. Routing never
moves nodes or invents family-specific layout.
`fit` encloses node extents plus 24px (minimum right/bottom extent 96px),
computed groups, their painted border and heading masks. The origin may be
negative, rounded outward to the 4px grid, so nodes keep their authored
coordinates even when a padded group reaches above or left of zero. Width and
height are dimensions from that origin, not maximum coordinates. Client,
static and flattened exports consume all four viewBox values; annotation bands,
legend and screen frame use the same origin. Dependency cycles reserve their
outside lane plus a margin. Routing never moves nodes or invents family layout.

Named frames retain origin `(0,0)` and their fixed dimensions. A group whose
border or heading cannot fit produces `scene_bounds` at `$.groups[index]`
(or `$.containment.scopes[index]` for a scope); no group is silently discarded.
Authored narrow frames likewise retain `(0,0)` and report `layout` when group
borders or headings exceed their exact 320px frame. This corrects accepted
clipping under #2228; the schema and authored node coordinates are unchanged.

## Authored narrow placement and paint

Expand Down
10 changes: 5 additions & 5 deletions packages/module-diagrams/src/callouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function planCallouts(spec: DiagramSpec, tree: DiagramTree, footerHeight:
const initialBottom = Math.max(0, ...tree.elements.map(item => item.kind === "connector" ? Math.max(...item.points.map(point => point.y)) : item.y + item.height));
let contentBottom = initialBottom;
const placed: CalloutPlacement[] = [];
const within = (box: DiagramBox) => box.x >= 8 && box.x + box.width <= frame.width - 8 && box.y >= 8 && (grow || box.y + box.height <= frame.height - footerHeight - 8);
const within = (box: DiagramBox) => box.x >= frame.x + 8 && box.x + box.width <= frame.x + frame.width - 8 && box.y >= frame.y + 8 && (grow || box.y + box.height <= frame.y + frame.height - footerHeight - 8);
function boundary(target: string, side: CalloutSide): DiagramPoint | undefined {
const box = targetFor(target);
if (!box) return;
Expand Down Expand Up @@ -218,7 +218,7 @@ export function planCallouts(spec: DiagramSpec, tree: DiagramTree, footerHeight:
for (const group of bandGroups) {
group.headers = [];
for (const index of group.indices) {
const column = columns === 1 || ordinal < firstColumn ? 0 : 1, x = 16 + column * (columnWidth + 24);
const column = columns === 1 || ordinal < firstColumn ? 0 : 1, x = frame.x + 16 + column * (columnWidth + 24);
if (!group.headers.some(header => header.x === x)) {
const offset = group.key ? calloutKeyWidth(group.key) + 10 : 0;
const header = wrapWords(`${group.name} · ${group.indices.length} ${group.indices.length === 1 ? "NOTE" : "NOTES"}`, columnWidth - offset, "label");
Expand All @@ -232,7 +232,7 @@ export function planCallouts(spec: DiagramSpec, tree: DiagramTree, footerHeight:
ordinal++;
}
for (const header of group.headers) {
const column = Math.round((header.x - 16) / (columnWidth + 24));
const column = Math.round((header.x - frame.x - 16) / (columnWidth + 24));
cursors[column]! += 10;
}
}
Expand Down Expand Up @@ -280,7 +280,7 @@ export function planCallouts(spec: DiagramSpec, tree: DiagramTree, footerHeight:
const header = group.headers.find(header => note.indexX! >= header.x && note.indexX! < header.x + header.width)!;
return note.box.x + note.box.width <= header.x + header.width;
};
const bandFits = () => !band.height || band.top + band.height + footerHeight + 16 <= frame.height && !bandGroups.some(invalidGroupHeader) && band.rows.every(rowFitsColumn);
const bandFits = () => !band.height || band.top + band.height + footerHeight + 16 <= frame.y + frame.height && !bandGroups.some(invalidGroupHeader) && band.rows.every(rowFitsColumn);
if (!grow && !bandFits()) {
// Trial state is isolated: a partially fitting group must not consume
// space or split its notes between the band and inline positions.
Expand All @@ -306,7 +306,7 @@ export function planCallouts(spec: DiagramSpec, tree: DiagramTree, footerHeight:
const invalidHeader = invalidGroupHeader(group);
for (const note of band.rows.filter(note => note.target === group.target)) if (invalidHeader || !rowFitsColumn(note) || !grow && !bandFits()) fail(note.index);
}
if (grow && placed.length + band.rows.length) frame.height = Math.ceil((Math.max(frame.height - footerHeight, contentBottom + 8, band.top + band.height) + footerHeight + 8) / 4) * 4;
if (grow && placed.length + band.rows.length) frame.height = Math.ceil((Math.max(frame.y + frame.height - footerHeight, contentBottom + 8, band.top + band.height) + footerHeight + 8 - frame.y) / 4) * 4;
tree.viewBox = frame;
return { placements: [...placed, ...band.rows].sort((a, b) => a.index - b.index), groups: bandGroups, bandTop: band.top, bandHeight: band.height, columns: band.columns, contentBottom, errors };
}
2 changes: 1 addition & 1 deletion packages/module-diagrams/src/export-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function exportDiagram(argv:string[],root:string):Promise<DiagramEx
const input=await readDiagramInput(root,parsed.position[0]!);result.input=input.path;const checked=checkDiagram(input.source);result.warnings=checked.warnings.map(w=>finding(w.code,w.path,w.message));if(!checked.valid){result.errors=checked.errors.map(e=>finding(e.code,e.path,e.message));return result;}
const svg=embedDiagramFonts(toFlatSvg(input.source,result.theme)),defaultName=input.path==="-"?join(SCRATCH_DIR,scratchName(`diagram-${theme}`,format)):input.path.replace(/(?:\.diagram\.ya?ml|\.[^./\\]+)$/i,"")+`.${theme}.${format}`,output=parsed.flags["--out"]??defaultName;
if(extname(output).toLowerCase()!==`.${format}`)throw Error("Output extension must match the artifact format");const destination=target(root,output);if(destination===input.absolute)throw Error("The source cannot be replaced by its derived artifact");if(isInScratch(root,output))assertScratchWritable(root,destination);
let data:string|Uint8Array=svg;if(format!=="svg"){const box=/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/.exec(svg)!;const width=Number(box[1]),height=Number(box[2]);data=await renderHtmlArtifact({html:`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0}svg{display:block}@page{size:${width}px ${height}px;margin:0}</style></head><body>${svg}</body></html>`,format:format as "png"|"pdf",width:Math.max(320,width)});}
let data:string|Uint8Array=svg;if(format!=="svg"){const box=/viewBox="[-0-9.]+ [-0-9.]+ ([0-9.]+) ([0-9.]+)"/.exec(svg)!;const width=Number(box[1]),height=Number(box[2]);data=await renderHtmlArtifact({html:`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0}svg{display:block}@page{size:${width}px ${height}px;margin:0}</style></head><body>${svg}</body></html>`,format:format as "png"|"pdf",width:Math.max(320,width)});}
result.output=write(root,output,data,input.path!=="-"||!!parsed.flags["--out"]);result.bytes=typeof data==="string"?Buffer.byteLength(data):data.byteLength;result.ok=true;
}catch(error){result.errors.push(finding("export","$",error));}return result;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/module-diagrams/src/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ export function flattenPaint(value:string,theme:DiagramTheme):string {
}
/** Self-contained chosen-theme SVG: no live theme tokens or external resources. */
export function toFlatSvg(source:string,theme:DiagramTheme):string {
if(!themes.has(theme))throw Error("Unknown diagram theme");const spec=parseDiagram(source),model=diagramSvgModel(spec),{width,height}=model.tree.viewBox;
if(!themes.has(theme))throw Error("Unknown diagram theme");const spec=parseDiagram(source),model=diagramSvgModel(spec),{x,y,width,height}=model.tree.viewBox;
const flat=(item:SvgPrimitive):SvgPrimitive=>({...item,attrs:Object.fromEntries(Object.entries(item.attrs).map(([key,value])=>[key,typeof value==="string"&&["fill","stroke","color"].includes(key)?flattenPaint(value,theme):value])),children:item.children?.map(flat)});
const css=flattenPaint(DIAGRAM_DRAWING_CSS,theme);
// Encode authored text deliberately; unresolved trusted paint already threw above.
const text=(value:string|number)=>escapeSvg(value).replace(/var\(/g,"var&#40;");
const serialize=(item:SvgPrimitive):string=>`<${item.tag}${Object.entries(item.attrs).map(([key,value])=>` ${key}="${text(value)}"`).join("")}>${item.text===undefined?"":text(item.text)}${item.children?.map(serialize).join("")??""}</${item.tag}>`;
return `<svg xmlns="http://www.w3.org/2000/svg" class="bk-diagram" data-theme="${theme}" role="img" aria-label="${text(spec.title)}" aria-description="${text(spec.desc)}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><title>${text(spec.title)}</title><desc>${text(spec.desc)}</desc><style>${css}</style>${model.elements.map(flat).map(serialize).join("")}</svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" class="bk-diagram" data-theme="${theme}" role="img" aria-label="${text(spec.title)}" aria-description="${text(spec.desc)}" viewBox="${x} ${y} ${width} ${height}" width="${width}" height="${height}"><title>${text(spec.title)}</title><desc>${text(spec.desc)}</desc><style>${css}</style>${model.elements.map(flat).map(serialize).join("")}</svg>`;
}
23 changes: 19 additions & 4 deletions packages/module-diagrams/src/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ export interface DiagramCardinality extends DiagramBox { kind: "cardinality"; ed
export type DiagramElement = DiagramShape | DiagramCardinality | DiagramConnector | DiagramLabelMask | DiagramEdgeLabel | DiagramNode | DiagramGroup;
export interface DiagramTree { schema_version: 1; type: DiagramSpec["type"]; grammar?: "architecture-containment"; title: string; desc: string; viewBox: DiagramBox; elements: DiagramElement[] }
export interface DiagramGeometryOptions { labelFontSize?: number; labelLineHeight?: number }
export interface DiagramGeometryFinding { path: string; code: "unroutable"; message: string }
export interface DiagramGeometryFinding { path: string; code: "unroutable" | "scene_bounds"; message: string }
export class DiagramGeometryError extends Error {
readonly findings: DiagramGeometryFinding[];
constructor(findings: DiagramGeometryFinding[]) { super(findings.map(item => item.message).join("; ")); this.name = "DiagramGeometryError"; this.findings = findings; }
}
/** Include the painted border and the non-admin heading mask above the group. */
export function diagramGroupBounds(group: DiagramGroup): DiagramBox {
const top = group.scopeKind === "admin" ? 1 : 8;
return { x: group.x - 1, y: group.y - top, width: group.width + 2, height: group.height + top + 1 };
}
const EPS = 1e-7, RADIUS = 8, CLEARANCE = 24, PARALLEL = 12, LABEL_GAP = 8;
const frameSizes: Record<string, [number, number]> = { "doc-inline": [960, 600], "doc-wide": [1280, 720], "slide-16x9": [1280, 720], "slide-4x3": [1024, 768], "social-og": [1200, 632], "social-square": [1080, 1080], "print-a4-landscape": [1120, 792], "print-a3-landscape": [1584, 1120], "print-letter-landscape": [1056, 816] };
const equal = (a: DiagramPoint, b: DiagramPoint) => Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
Expand Down Expand Up @@ -215,9 +220,19 @@ export function buildDiagram(input: DiagramSpec, options: DiagramGeometryOptions
groups.push(...spec.containment.scopes.map(scope=>({kind:"group" as const,id:scope.id,label:scope.label,scopeKind:scope.kind,parent:scope.parent,focal:focal.has(scope.id),x:scope.at[0],y:scope.at[1],width:scope.size[0],height:scope.size[1]})));
}
for (const group of groups) boxes.set(group.id, group);
const [baseWidth, height] = frameSizes[spec.size] ?? [Math.max(96, ...[...nodes,...groups.filter(group=>group.scopeKind)].map(node => node.x + node.width + CLEARANCE)), Math.max(96, ...[...nodes,...groups.filter(group=>group.scopeKind)].map(node => node.y + node.height + CLEARANCE))];
const width=baseWidth+(spec.size==="fit"&&spec.type==="dependency"&&spec.edges.some(edge=>edge.cycle)?24:0);
const viewBox = { x: 0, y: 0, width, height };
const groupBounds = groups.map(diagramGroupBounds);
const x = spec.size === "fit" ? Math.min(0, ...groupBounds.map(box => Math.floor(box.x / 4) * 4)) : 0;
const y = spec.size === "fit" ? Math.min(0, ...groupBounds.map(box => Math.floor(box.y / 4) * 4)) : 0;
const [right, bottom] = frameSizes[spec.size] ?? [Math.max(96, ...[...nodes, ...groups.filter(group => group.scopeKind)].map(node => node.x + node.width + CLEARANCE), ...groupBounds.map(box => box.x + box.width)), Math.max(96, ...[...nodes, ...groups.filter(group => group.scopeKind)].map(node => node.y + node.height + CLEARANCE), ...groupBounds.map(box => box.y + box.height))];
const width = right - x + (spec.size === "fit" && spec.type === "dependency" && spec.edges.some(edge => edge.cycle) ? 24 : 0);
const viewBox = { x, y, width, height: bottom - y };
if (spec.size !== "fit") {
const findings: DiagramGeometryFinding[] = [];
groups.forEach((group, index) => {
if (!contains(viewBox, groupBounds[index]!)) findings.push({ path: group.scopeKind ? `$.containment.scopes[${index - (spec.groups?.length ?? 0)}]` : `$.groups[${index}]`, code: "scene_bounds", message: `Group '${group.id}' and its heading exceed the selected frame; move the group inward or use size: fit` });
});
if (findings.length) throw new DiagramGeometryError(findings);
}
const fail = (edge: number, message: string): never => { const item = spec.edges[edge]!; throw new DiagramGeometryError([{ path: `$.edges[${edge}]`, code: "unroutable", message: `Edge '${item.id ?? `${item.from} → ${item.to}`}': ${message}` }]); };
const pairs: Pair[] = [], attachments = new Map<string, { edge: number; end: "from" | "to"; node: string; side: DiagramSide }[]>();
const shapePorts=new Map<string,Set<DiagramSide>>();
Expand Down
Loading
Loading