Add archify skill (v2.14), pi-mermaid ext (v0.3.0), project-diagramming-mermaid + project-diagramming-archify skills
- skills/archify: vendored tt-a1i/archify 2.14.0 (MIT), pure Node 18+, zero deps - extensions/pi-mermaid: renders mermaid blocks as ASCII in TUI; deps beautiful-mermaid + mermaid - skills/project-diagramming-*: orchestration skills, per-project docs/ convention
This commit is contained in:
121
skills/archify/renderers/workflow/README.md
Normal file
121
skills/archify/renderers/workflow/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Workflow Renderer
|
||||
|
||||
Render `diagram_type: "workflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/workflow/render-workflow.mjs input.workflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/workflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `workflow.html` in the current working directory.
|
||||
|
||||
After rendering, run the artifact checker:
|
||||
|
||||
```bash
|
||||
node archify/scripts/check-render-output.mjs output.html
|
||||
```
|
||||
|
||||
It catches final-SVG issues that are easiest to see in a browser: non-finite
|
||||
SVG values, accidental two-point diagonal arrows, and arrows crossing the
|
||||
legend.
|
||||
|
||||
## Input
|
||||
|
||||
Workflow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow"
|
||||
},
|
||||
"lanes": [],
|
||||
"phases": [],
|
||||
"groups": [],
|
||||
"mainPath": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Omit `meta.viewBox` for the common case: the width is fixed at 720 and the
|
||||
height is derived from the lane count, so lanes and legend always fit. A
|
||||
complete worked example lives at
|
||||
`archify/examples/agent-tool-call.workflow.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/workflow.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives component kinds from `nodes[].type`. Supported
|
||||
`meta.legend.entries` keys, in stable order, are `frontend`, `backend`,
|
||||
`security`, `messagebus`, `database`, `cloud`, and `external`. Labels and
|
||||
visibility may be overridden through the shared legend contract; only kinds
|
||||
backed by rendered nodes receive Semantic Legend controls.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[720, auto]` — auto height = 52 + lanes×104 + (lanes−1)×20 + 124 |
|
||||
| Lane frame | x 40, width 640, height 104, gap 20; first lane top at y 52 |
|
||||
| Lane title strip | top 30px of each lane; node boxes must stay below it |
|
||||
| Column centers (`col` 0–5) | x = 88, 220, 300, 430, 500, 625 |
|
||||
| Phase headers | Optional `phases[]` render above the first lane, spanning `fromCol..toCol` |
|
||||
| Lane groups | Optional `groups[]` frame parallel work or branch work inside one lane |
|
||||
| Exception lanes | Set `lane.variant: "exception"` for retry, denial, fallback, or failure paths |
|
||||
| Main path lint | Optional `mainPath[]` checks that happy-path steps have matching edges and do not move backward |
|
||||
| Default node | 92×52 (height 68 when `tag` is set) |
|
||||
| Node spacing | ≥8px between nodes in the same lane |
|
||||
| Edge length | straight segments must span ≥28px |
|
||||
| Legend row | y = lane bottom + 44; viewBox height must be ≥ legend y + 18 |
|
||||
|
||||
Column-center gaps are 132 / 80 / 130 / 70 / 125 px: columns 1↔2 (80px) and
|
||||
3↔4 (70px) cannot both hold default-width 92px nodes in the same lane — skip a
|
||||
column or reduce `width`.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use lanes for ownership or runtime boundaries.
|
||||
- Use phase headers for high-level story beats such as Intake, Plan, Execute, and Report.
|
||||
- Use groups for parallel checks, branch handling, or bounded work within a lane; every group must contain at least one node.
|
||||
- Use `lane.variant: "exception"` for human wait, denial, retry, fallback, and failure lanes instead of mixing those paths into the happy path.
|
||||
- Set `mainPath` when the diagram has a clear happy path; the renderer validates that consecutive ids have matching edges and move left-to-right.
|
||||
- Place nodes with lane IDs and column indexes, not raw SVG coordinates.
|
||||
- Leave short adjacent links unlabeled; the arrow is enough.
|
||||
- Use labels for cross-lane decisions, approvals, async traces, and return paths.
|
||||
- Prefer route presets — `drop` (bend between lanes; `bias` 0–1 picks where),
|
||||
`outside-right`, `return-left`, `bottom-channel`, and `up-channel` — before
|
||||
using raw `via` points. `straight` and the default `auto` cover the rest.
|
||||
- Keep workflow examples compact enough to render well in narrow chat/browser
|
||||
previews.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including node overlap, nodes outside their lanes, invalid
|
||||
phase/group column ranges, empty groups, broken `mainPath` steps, unknown edge
|
||||
targets, labels colliding with nodes or other labels, labels wider than their
|
||||
node, legends outside the viewBox, or straight arrows that are too short to
|
||||
read cleanly. The shared Clean Flow Gate also rejects edges crossing unrelated
|
||||
nodes with 2px clearance; lanes, phases, and groups remain intentional
|
||||
pass-through containers. Text width is estimated CJK-aware: fullwidth glyphs
|
||||
count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear lane corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated edges overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid for fixed lane gaps.
|
||||
643
skills/archify/renderers/workflow/render-workflow.mjs
Normal file
643
skills/archify/renderers/workflow/render-workflow.mjs
Normal file
@@ -0,0 +1,643 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
polylinePath,
|
||||
routePointsValue,
|
||||
labelPoint,
|
||||
componentFill,
|
||||
componentText,
|
||||
arrowClassMap,
|
||||
variantAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: workflow, template, outPath } = loadDiagram({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'workflow',
|
||||
defaultExample: 'agent-tool-call.workflow.json'
|
||||
});
|
||||
|
||||
const layout = {
|
||||
laneX: 40,
|
||||
laneY: 52,
|
||||
laneW: 640,
|
||||
laneH: 104,
|
||||
laneGap: 20,
|
||||
laneTitleH: 30,
|
||||
colXs: [88, 220, 300, 430, 500, 625],
|
||||
nodeW: 92,
|
||||
nodeH: 52
|
||||
};
|
||||
|
||||
// Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend.
|
||||
const autoHeight = layout.laneY
|
||||
+ (workflow.lanes?.length || 1) * layout.laneH
|
||||
+ ((workflow.lanes?.length || 1) - 1) * layout.laneGap
|
||||
+ 124;
|
||||
const viewBox = workflow.meta?.viewBox || [720, autoHeight];
|
||||
|
||||
const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index]));
|
||||
const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label]));
|
||||
|
||||
function nodeContext(node) {
|
||||
const group = asArray(workflow.groups).find((candidate) => (
|
||||
candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol
|
||||
));
|
||||
const phase = asArray(workflow.phases).find((candidate) => (
|
||||
node.col >= candidate.fromCol && node.col <= candidate.toCol
|
||||
));
|
||||
return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ') || 'Workflow node';
|
||||
}
|
||||
|
||||
function laneTop(id) {
|
||||
return layout.laneY + laneIndex.get(id) * (layout.laneH + layout.laneGap);
|
||||
}
|
||||
|
||||
function lastLaneBottom() {
|
||||
return layout.laneY + workflow.lanes.length * layout.laneH + (workflow.lanes.length - 1) * layout.laneGap;
|
||||
}
|
||||
|
||||
function legendY() {
|
||||
return lastLaneBottom() + 44;
|
||||
}
|
||||
|
||||
function measureNode(node) {
|
||||
const width = node.width || layout.nodeW;
|
||||
const height = node.height || (node.tag ? 68 : layout.nodeH);
|
||||
const cx = layout.colXs[node.col];
|
||||
const contentH = layout.laneH - layout.laneTitleH;
|
||||
const y = laneTop(node.lane) + layout.laneTitleH + (contentH - height) / 2 + (node.yOffset || 0);
|
||||
return {
|
||||
...node,
|
||||
width,
|
||||
height,
|
||||
x: cx - width / 2,
|
||||
y,
|
||||
cx,
|
||||
cy: y + height / 2
|
||||
};
|
||||
}
|
||||
|
||||
// Font sizes for this renderer's node text; the fitting geometry is shared.
|
||||
const nodeTextFit = {
|
||||
labelPreferred: 11,
|
||||
labelMinimum: 9,
|
||||
sublabelPreferred: 8,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)]));
|
||||
|
||||
function workflowCompositionFrames() {
|
||||
const frames = [];
|
||||
for (const [index, lane] of asArray(workflow.lanes).entries()) {
|
||||
const y = layout.laneY + index * (layout.laneH + layout.laneGap);
|
||||
frames.push({ id: `lane-${index}`, label: lane.label, kind: 'lane', x: layout.laneX, y, width: layout.laneW, height: layout.laneH, radius: 10 });
|
||||
if (lane.variant === 'exception') {
|
||||
frames.push({ id: `lane-${index}-exception`, label: `${lane.label} exception`, kind: 'exception-lane', x: layout.laneX + 6, y: y + 6, width: layout.laneW - 12, height: layout.laneH - 12, radius: 8 });
|
||||
}
|
||||
}
|
||||
for (const [index, group] of asArray(workflow.groups).entries()) {
|
||||
const span = spanForCols(group.fromCol, group.toCol, 50);
|
||||
frames.push({
|
||||
id: `group-${index}`,
|
||||
label: group.label,
|
||||
kind: 'group',
|
||||
x: span.x,
|
||||
y: laneTop(group.lane) + layout.laneTitleH + 8,
|
||||
width: span.width,
|
||||
height: layout.laneH - layout.laneTitleH - 16,
|
||||
radius: 9,
|
||||
});
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index]));
|
||||
const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => {
|
||||
const fromStep = mainPathSteps.get(edge.from);
|
||||
const toStep = mainPathSteps.get(edge.to);
|
||||
const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null;
|
||||
return [edge, mainStep ?? asArray(workflow.mainPath).length + index];
|
||||
}));
|
||||
|
||||
function nodeStep(node) {
|
||||
return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id);
|
||||
}
|
||||
|
||||
function validateWorkflow() {
|
||||
const problems = [];
|
||||
if (workflow.schema_version !== 1) {
|
||||
problems.push('Workflow files must set "schema_version": 1.');
|
||||
}
|
||||
if (workflow.diagram_type !== 'workflow') {
|
||||
problems.push(`Unsupported diagram_type "${workflow.diagram_type}". Expected "workflow".`);
|
||||
}
|
||||
if (!workflow.meta || !workflow.meta.title) {
|
||||
problems.push('Workflow files must include meta.title.');
|
||||
}
|
||||
if (!Array.isArray(workflow.lanes) || !workflow.lanes.length) {
|
||||
problems.push('Workflow files must include at least one lane.');
|
||||
}
|
||||
if (!Array.isArray(workflow.nodes)) {
|
||||
problems.push('Workflow files must include a nodes array.');
|
||||
}
|
||||
if (!Array.isArray(workflow.edges)) {
|
||||
problems.push('Workflow files must include an edges array.');
|
||||
}
|
||||
if (workflow.phases !== undefined && !Array.isArray(workflow.phases)) {
|
||||
problems.push('Workflow "phases" must be an array.');
|
||||
}
|
||||
if (workflow.groups !== undefined && !Array.isArray(workflow.groups)) {
|
||||
problems.push('Workflow "groups" must be an array.');
|
||||
}
|
||||
if (workflow.mainPath !== undefined && !Array.isArray(workflow.mainPath)) {
|
||||
problems.push('Workflow "mainPath" must be an array of node ids.');
|
||||
}
|
||||
if (workflow.cards !== undefined && !Array.isArray(workflow.cards)) {
|
||||
problems.push('Workflow "cards" must be an array.');
|
||||
}
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Workflow layout validation failed', problems, {
|
||||
subject: { diagramType: 'workflow' },
|
||||
});
|
||||
}
|
||||
|
||||
const laneIds = new Set(workflow.lanes.map((lane) => lane.id));
|
||||
if (laneIds.size !== workflow.lanes.length) {
|
||||
problems.push('Lane ids must be unique.');
|
||||
}
|
||||
if (nodes.size !== workflow.nodes.length) {
|
||||
problems.push('Node ids must be unique.');
|
||||
}
|
||||
const phaseIds = new Set(asArray(workflow.phases).map((phase) => phase.id));
|
||||
if (phaseIds.size !== asArray(workflow.phases).length) {
|
||||
problems.push('Phase ids must be unique.');
|
||||
}
|
||||
const groupIds = new Set(asArray(workflow.groups).map((group) => group.id));
|
||||
if (groupIds.size !== asArray(workflow.groups).length) {
|
||||
problems.push('Group ids must be unique.');
|
||||
}
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
if (!laneIds.has(node.lane)) {
|
||||
problems.push(`Node "${node.id}" uses unknown lane "${node.lane}".`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(node.col) || node.col < 0 || node.col >= layout.colXs.length) {
|
||||
problems.push(`Node "${node.id}" uses column ${node.col}, but valid columns are integers 0..${layout.colXs.length - 1}.`);
|
||||
continue;
|
||||
}
|
||||
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
||||
problems.push(`Node "${node.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
const estLabelW = textUnits(node.label) * 6.8;
|
||||
if (estLabelW > node.width + 6) {
|
||||
problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
|
||||
}
|
||||
const availableTextW = availableNodeTextWidth(node.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
|
||||
['Tag', node.tag, nodeTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
|
||||
}
|
||||
}
|
||||
|
||||
const top = laneTop(node.lane);
|
||||
const contentTop = top + layout.laneTitleH;
|
||||
const laneRight = layout.laneX + layout.laneW;
|
||||
if (node.x < layout.laneX || node.x + node.width > laneRight) {
|
||||
problems.push(`Node "${node.id}" exceeds the horizontal bounds of lane "${node.lane}".`);
|
||||
}
|
||||
if (node.y < contentTop || node.y + node.height > top + layout.laneH) {
|
||||
problems.push(`Node "${node.id}" collides with the title or boundary of lane "${node.lane}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const phaseRanges = [];
|
||||
for (const phase of asArray(workflow.phases)) {
|
||||
if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)) {
|
||||
problems.push(`Phase "${phase.id}" must use integer fromCol/toCol values.`);
|
||||
continue;
|
||||
}
|
||||
if (phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) {
|
||||
problems.push(`Phase "${phase.id}" uses invalid columns ${phase.fromCol}..${phase.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
|
||||
} else {
|
||||
phaseRanges.push(phase);
|
||||
}
|
||||
const estLabelW = textUnits(phase.label) * 5.6;
|
||||
const width = spanForCols(phase.fromCol, phase.toCol).width;
|
||||
if (estLabelW > width + 8) {
|
||||
problems.push(`Phase label "${phase.label}" (~${Math.round(estLabelW)}px) is wider than its ${Math.round(width)}px span — shorten the label or widen the phase range.`);
|
||||
}
|
||||
}
|
||||
phaseRanges.sort((a, b) => a.fromCol - b.fromCol || a.toCol - b.toCol);
|
||||
for (let i = 0; i < phaseRanges.length; i += 1) {
|
||||
for (let j = i + 1; j < phaseRanges.length; j += 1) {
|
||||
const earlier = phaseRanges[i];
|
||||
const later = phaseRanges[j];
|
||||
if (later.fromCol > earlier.toCol) break;
|
||||
problems.push(`Phase "${later.id}" (${later.fromCol}..${later.toCol}) overlaps phase "${earlier.id}" (${earlier.fromCol}..${earlier.toCol}) — start at col ${earlier.toCol + 1} or later, or end the earlier phase at col ${later.fromCol - 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of asArray(workflow.groups)) {
|
||||
if (!laneIds.has(group.lane)) {
|
||||
problems.push(`Group "${group.id}" uses unknown lane "${group.lane}".`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) {
|
||||
problems.push(`Group "${group.id}" must use integer fromCol/toCol values.`);
|
||||
continue;
|
||||
}
|
||||
if (group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) {
|
||||
problems.push(`Group "${group.id}" uses invalid columns ${group.fromCol}..${group.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`);
|
||||
}
|
||||
const contained = [...nodes.values()].some((node) => node.lane === group.lane && node.col >= group.fromCol && node.col <= group.toCol);
|
||||
if (!contained) {
|
||||
problems.push(`Group "${group.id}" does not contain any nodes — align its lane/columns with the parallel or branch work it frames.`);
|
||||
}
|
||||
}
|
||||
|
||||
const byLane = new Map();
|
||||
for (const node of nodes.values()) {
|
||||
byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]);
|
||||
}
|
||||
for (const [lane, laneNodes] of byLane) {
|
||||
for (let i = 0; i < laneNodes.length; i += 1) {
|
||||
for (let j = i + 1; j < laneNodes.length; j += 1) {
|
||||
if (rectsOverlap(laneNodes[i], laneNodes[j], 8)) {
|
||||
problems.push(`Nodes "${laneNodes[i].id}" and "${laneNodes[j].id}" are less than 8px apart in lane "${lane}" — move one to another col, adjust yOffset, or reduce width/height.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of workflow.edges) {
|
||||
if (!nodes.has(edge.from)) problems.push(`Edge "${edge.label || edge.from}" references unknown source "${edge.from}".`);
|
||||
if (!nodes.has(edge.to)) problems.push(`Edge "${edge.label || edge.to}" references unknown target "${edge.to}".`);
|
||||
if (nodes.has(edge.from) && nodes.has(edge.to)) {
|
||||
const routed = pathFor(edge);
|
||||
if (routed.points.length === 2) {
|
||||
const [start, end] = routed.points;
|
||||
const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (segmentLength < 28) {
|
||||
problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) — drop its label or route it through a channel.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: workflow.edges,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
obstacles: nodes.values(),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
obstacleKind: 'node',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channel coordinates, or move the node to a clearer lane/column'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: workflow.edges,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via, bias, or channel coordinates so the edges use separate lane corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: workflow.edges,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via, bias, or channel coordinates so unrelated edges do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: workflow.edges,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
frames: workflowCompositionFrames(),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via, bias, or channel coordinates so the edge crosses the lane or group perpendicularly instead of following its border'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: workflow.edges,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via, bias, or channel coordinates so each turn has a readable run-up'
|
||||
}));
|
||||
|
||||
if (Array.isArray(workflow.mainPath)) {
|
||||
for (const id of workflow.mainPath) {
|
||||
if (!nodes.has(id)) {
|
||||
problems.push(`mainPath references unknown node "${id}".`);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < workflow.mainPath.length - 1; i += 1) {
|
||||
const fromId = workflow.mainPath[i];
|
||||
const toId = workflow.mainPath[i + 1];
|
||||
const from = nodes.get(fromId);
|
||||
const to = nodes.get(toId);
|
||||
if (!from || !to) continue;
|
||||
const linked = workflow.edges.some((edge) => edge.from === fromId && edge.to === toId);
|
||||
if (!linked) {
|
||||
problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge — add the edge or remove the pair from mainPath.`);
|
||||
}
|
||||
if (to.col < from.col) {
|
||||
problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} — use a return edge outside mainPath for loops.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const labelRects = [];
|
||||
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
||||
if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) continue;
|
||||
const [lx, ly] = labelPoint(edge, pathFor(edge).points);
|
||||
const width = Math.max(30, textUnits(edge.label) * 4.8 + 10);
|
||||
labelRects.push({ relation: edge, relationIndex: edgeIndex, label: edge.label, x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly });
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const node of nodes.values()) {
|
||||
if (rectsOverlap(rect, node, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy or remove one label.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: workflow.edges,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: workflow.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
if (viewBox[0] < layout.laneX + layout.laneW + 16) {
|
||||
problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes — set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`);
|
||||
}
|
||||
if (legendY() + 18 > viewBox[1]) {
|
||||
problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Workflow layout validation failed', problems, {
|
||||
subject: { diagramType: 'workflow' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function gapYBetween(fromLane, toLane, bias = 0.5) {
|
||||
const a = laneTop(fromLane) + layout.laneH;
|
||||
const b = laneTop(toLane);
|
||||
return a + (b - a) * bias;
|
||||
}
|
||||
|
||||
function spanForCols(fromCol, toCol, pad = 46) {
|
||||
const start = layout.colXs[fromCol] - pad;
|
||||
const end = layout.colXs[toCol] + pad;
|
||||
return { x: start, width: end - start, cx: (start + end) / 2 };
|
||||
}
|
||||
|
||||
function sameLaneAutoVia(start, end) {
|
||||
if (start[0] === end[0] || start[1] === end[1]) return [];
|
||||
const midX = (start[0] + end[0]) / 2;
|
||||
return [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
|
||||
function routeVia(edge, from, to, start, end) {
|
||||
if (edge.via) return edge.via;
|
||||
switch (edge.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'drop': {
|
||||
const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'outside-right': {
|
||||
const x = edge.channelX ?? layout.laneX + layout.laneW + 12;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'return-left': {
|
||||
const x = edge.channelX ?? Math.min(from.x, to.x) - 28;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = edge.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 32;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'up-channel': {
|
||||
const y = edge.channelY ?? Math.min(from.y, to.y) - 28;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (from.lane === to.lane) return sameLaneAutoVia(start, end);
|
||||
const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5);
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
const automaticPorts = automaticPortSpread(workflow.edges, nodes);
|
||||
|
||||
function pathFor(edge) {
|
||||
if (pathCache.has(edge)) return pathCache.get(edge);
|
||||
const from = nodes.get(edge.from);
|
||||
const to = nodes.get(edge.to);
|
||||
const ports = automaticPorts.get(edge);
|
||||
const start = ports?.from || anchor(from, chosenSide(edge.fromSide, defaultFromSide(from, to)));
|
||||
const end = ports?.to || anchor(to, chosenSide(edge.toSide, defaultToSide(from, to)));
|
||||
const points = [start, ...routeVia(edge, from, to, start, end), end];
|
||||
const routed = { d: polylinePath(points), points };
|
||||
pathCache.set(edge, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
function renderLane(lane, index) {
|
||||
const y = layout.laneY + index * (layout.laneH + layout.laneGap);
|
||||
const exception = lane.variant === 'exception'
|
||||
? `\n <rect data-graph-role="structural-frame" data-composition-frame-kind="exception-lane" data-composition-frame-id="lane-${index}-exception" x="${layout.laneX + 6}" y="${y + 6}" width="${layout.laneW - 12}" height="${layout.laneH - 12}" rx="8" class="c-security-group" stroke-width="1"/>`
|
||||
: '';
|
||||
const labelClass = lane.variant === 'exception' ? 't-security' : 't-dim';
|
||||
const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0');
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="lane" data-composition-frame-id="lane-${index}" x="${layout.laneX}" y="${y}" width="${layout.laneW}" height="${layout.laneH}" rx="10" class="c-lane" stroke-width="1"/>${exception}
|
||||
<text x="${layout.laneX + 14}" y="${y + 22}" class="${labelClass}" font-size="10" font-weight="600">${prefix} / ${esc(lane.label)}</text>`;
|
||||
}
|
||||
|
||||
function renderPhase(phase) {
|
||||
const span = spanForCols(phase.fromCol, phase.toCol, 46);
|
||||
const accent = variantAccent(phase.variant);
|
||||
const [lineClass] = arrowClassMap[phase.variant || 'default'] || arrowClassMap.default;
|
||||
return ` <line x1="${span.x}" y1="35" x2="${span.x + span.width}" y2="35" class="${lineClass}" stroke-width="1.1"/>
|
||||
<rect x="${span.x}" y="27" width="${span.width}" height="16" rx="4" class="c-mask"/>
|
||||
<text x="${span.cx}" y="39" class="${accent}" font-size="8" font-weight="600" text-anchor="middle">${esc(phase.label)}</text>`;
|
||||
}
|
||||
|
||||
function renderGroup(group, index) {
|
||||
const span = spanForCols(group.fromCol, group.toCol, 50);
|
||||
const y = laneTop(group.lane) + layout.laneTitleH + 8;
|
||||
const height = layout.laneH - layout.laneTitleH - 16;
|
||||
const cls = group.variant === 'security' ? 'c-security-group' : 'c-lane';
|
||||
const textClass = variantAccent(group.variant);
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="group" data-composition-frame-id="group-${index}" x="${span.x}" y="${y}" width="${span.width}" height="${height}" rx="9" class="${cls}" stroke-width="1"/>
|
||||
<text x="${span.x + 10}" y="${y + 14}" class="${textClass}" font-size="7" font-weight="600">${esc(group.label)}</text>`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const fill = componentFill[node.type] || 'c-external';
|
||||
const accent = componentText[node.type] || 't-muted';
|
||||
const hasSub = node.sublabel != null && node.sublabel !== '';
|
||||
const labelFontSize = fittedNodeFontSize(node.label, node.width, nodeTextFit.labelPreferred, nodeTextFit.labelMinimum);
|
||||
const sublabelFontSize = hasSub
|
||||
? fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)
|
||||
: nodeTextFit.sublabelPreferred;
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 38}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = node.tag
|
||||
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 12}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" text-anchor="middle">${esc(node.tag)}</text>`
|
||||
: '';
|
||||
const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context: nodeContext(node) };
|
||||
return ` <g ${focusNodeAttrs(node.id, node.label, passport)}>
|
||||
${focusNodeTitle(node.label, passport)}
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(workflow.meta, 'node', nodeStep(node))} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}
|
||||
<text${hasSub ? ' data-detail-anchor' : ''} x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderEdgePath(edge, index) {
|
||||
const [cls, marker] = arrowClassMap[edge.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(edge);
|
||||
const strokeWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4);
|
||||
return ` <path ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(workflow.meta, 'edge', edgeSteps.get(edge))} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderEdgeLabel(edge, index) {
|
||||
if (!edge.label) return '';
|
||||
const routed = pathFor(edge);
|
||||
const [lx, ly] = labelPoint(edge, routed.points);
|
||||
const labelW = Math.max(30, textUnits(edge.label) * 4.8 + 10);
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(edge.from, edge.to, edge.label, index, edge.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 10}" width="${labelW}" height="14" rx="3" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${variantAccent(edge.variant, { dashed: 't-database' })}" font-size="8" text-anchor="middle">${esc(edge.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
['frontend', 'User UI'],
|
||||
['backend', 'Agent logic'],
|
||||
['security', 'Policy'],
|
||||
['messagebus', 'Tool action'],
|
||||
['database', 'Context / trace'],
|
||||
['cloud', 'Cloud service'],
|
||||
['external', 'External system'],
|
||||
].map(([kind, label]) => ({ kind, label }));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set([...nodes.values()].map((node) => node.type));
|
||||
const entries = resolveLegend(workflow.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
layout: {
|
||||
x: 20,
|
||||
baselineY: legendY(),
|
||||
width: viewBox[0] - 40,
|
||||
fontSize: 7,
|
||||
itemGap: 7,
|
||||
minTitleY: lastLaneBottom() + 8,
|
||||
unfit: workflow.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'workflow',
|
||||
},
|
||||
renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${componentFill[entry.kind] || 'c-external'}" stroke-width="1"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(workflow.meta, 'workflow diagram')}>
|
||||
${svgAccessibleText(workflow.meta, 'workflow diagram')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Swimlanes -->
|
||||
${workflow.lanes.map(renderLane).join('\n\n')}
|
||||
|
||||
<!-- Phase headers -->
|
||||
${asArray(workflow.phases).map(renderPhase).join('\n')}
|
||||
|
||||
<!-- Workflow groups -->
|
||||
${asArray(workflow.groups).map(renderGroup).join('\n')}
|
||||
|
||||
<!-- Edge paths -->
|
||||
${workflow.edges.map(renderEdgePath).join('\n')}
|
||||
|
||||
<!-- Nodes -->
|
||||
${[...nodes.values()].map(renderNode).join('\n\n')}
|
||||
|
||||
<!-- Edge labels -->
|
||||
${workflow.edges.map(renderEdgeLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateWorkflow();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'workflow',
|
||||
meta: workflow.meta,
|
||||
svg: renderSvg(),
|
||||
cards: workflow.cards,
|
||||
});
|
||||
Reference in New Issue
Block a user