qgraphflow 0.0.6 → 0.0.7
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/README.md +119 -70
- package/docs/clients.de.md +15 -24
- package/docs/clients.es.md +15 -24
- package/docs/clients.ja.md +15 -24
- package/docs/clients.md +15 -24
- package/docs/clients.pt.md +15 -24
- package/docs/clients.ru.md +15 -24
- package/docs/clients.zh-CN.md +15 -24
- package/docs/readme/README.de.md +120 -71
- package/docs/readme/README.es.md +120 -71
- package/docs/readme/README.ja.md +120 -71
- package/docs/readme/README.pt.md +120 -71
- package/docs/readme/README.ru.md +120 -71
- package/docs/readme/README.zh-CN.md +106 -59
- package/examples/jeepay/README.md +23 -0
- package/examples/jeepay/capabilities.graph.json +270 -0
- package/examples/jeepay/class.graph.json +237 -0
- package/examples/jeepay/collection.graph.json +3057 -0
- package/examples/jeepay/dataflow.graph.json +212 -0
- package/examples/jeepay/deployment.graph.json +222 -0
- package/examples/jeepay/engineering.graph.json +277 -0
- package/examples/jeepay/er.graph.json +482 -0
- package/examples/jeepay/flowchart.graph.json +312 -0
- package/examples/jeepay/relations.graph.json +289 -0
- package/examples/jeepay/sequence.graph.json +355 -0
- package/examples/jeepay/source.json +95 -0
- package/examples/jeepay/state.graph.json +175 -0
- package/examples/jeepay/usecase.graph.json +222 -0
- package/package.json +14 -3
- package/skills/q-flow/SKILL.md +28 -20
- package/skills/q-flow/agents/openai.yaml +1 -1
- package/skills/q-flow/assets/viewer/package.json +1 -1
- package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
- package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
- package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
- package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
- package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
- package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
- package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
- package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
- package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
- package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
- package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
- package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
- package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
- package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
- package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
- package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
- package/skills/q-flow/assets/viewer-dist/index.html +30 -28
- package/skills/q-flow/references/evidence-sources.md +7 -5
- package/skills/q-flow/references/graph-common.md +34 -34
- package/skills/q-flow/references/graph-schema.md +28 -7
- package/skills/q-flow/references/guided-intake.md +51 -71
- package/skills/q-flow/references/layout-routing.md +47 -0
- package/skills/q-flow/references/types/architecture.md +42 -22
- package/skills/q-flow/references/types/class.md +9 -2
- package/skills/q-flow/references/types/dataflow.md +11 -4
- package/skills/q-flow/references/types/deployment.md +11 -3
- package/skills/q-flow/references/types/er.md +8 -1
- package/skills/q-flow/references/types/flowchart.md +12 -5
- package/skills/q-flow/references/types/sequence.md +20 -16
- package/skills/q-flow/references/types/state.md +10 -3
- package/skills/q-flow/references/types/usecase.md +6 -0
- package/skills/q-flow/references/viewer-development.md +37 -24
- package/skills/q-flow/references/visual-contract.md +12 -6
- package/skills/q-flow/scripts/compile-layout.mjs +85 -102
- package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
- package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
- package/skills/q-flow/scripts/validate-graph.mjs +38 -21
- package/examples/order-flow.graph.json +0 -94
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { layoutLimits } from './layout-spacing.js';
|
|
2
|
+
import { getDiagram, diagramTypeOf } from './diagrams/registry.js';
|
|
3
|
+
import { routingBounds, groupBorders, groupHeadingBoxes, createEdgeRoutes, occupiedBox, segmentCrossesBox } from './edge-routing.js';
|
|
4
|
+
import { actorTop } from './diagrams/usecase.js';
|
|
5
|
+
import { layoutText } from './text-layout.js';
|
|
6
|
+
import { isArchitectureOverview } from './view-identity.js';
|
|
7
|
+
|
|
8
|
+
export const clearanceLimits = graph => ({ node: isArchitectureOverview(graph) ? 6 : 12, preferred: isArchitectureOverview(graph) ? 8 : 24, boundary: 6 });
|
|
9
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
10
|
+
const pointDistance = (p, a, b) => {
|
|
11
|
+
const dx = b.x - a.x, dy = b.y - a.y, t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1), 0, 1);
|
|
12
|
+
return Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy);
|
|
13
|
+
};
|
|
14
|
+
function segmentDistance(a, b, c, d) {
|
|
15
|
+
const cross = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
|
16
|
+
if (cross(a, b, c) * cross(a, b, d) <= 0 && cross(c, d, a) * cross(c, d, b) <= 0
|
|
17
|
+
&& Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) <= Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x))
|
|
18
|
+
&& Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) <= Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y))) return 0;
|
|
19
|
+
return Math.min(pointDistance(a, c, d), pointDistance(b, c, d), pointDistance(c, a, b), pointDistance(d, a, b));
|
|
20
|
+
}
|
|
21
|
+
function boxDistance(a, b, r) {
|
|
22
|
+
return Math.hypot(Math.max(r.x - Math.max(a.x, b.x), Math.min(a.x, b.x) - r.x - r.width, 0), Math.max(r.y - Math.max(a.y, b.y), Math.min(a.y, b.y) - r.y - r.height, 0));
|
|
23
|
+
}
|
|
24
|
+
function ellipseDistance(a, b, r) {
|
|
25
|
+
const point = p => {
|
|
26
|
+
const x = Math.abs(p.x - r.cx), y = Math.abs(p.y - r.cy);
|
|
27
|
+
if ((x / r.rx) ** 2 + (y / r.ry) ** 2 <= 1+1e-12) return 0;
|
|
28
|
+
if (x===0) return Math.max(0,y-r.ry);
|
|
29
|
+
if (y===0) return Math.max(0,x-r.rx);
|
|
30
|
+
const distance = t => Math.hypot(x - r.rx * Math.cos(t), y - r.ry * Math.sin(t));
|
|
31
|
+
let lo = 0, hi = Math.PI / 2;
|
|
32
|
+
for (let i = 0; i < 32; i++) { const l = lo + (hi - lo) / 3, h = hi - (hi - lo) / 3; if (distance(l) < distance(h)) hi = h; else lo = l; }
|
|
33
|
+
return Math.min(distance(0), distance(Math.PI / 2), distance((lo + hi) / 2));
|
|
34
|
+
};
|
|
35
|
+
const h = a.y === b.y, fixed = h ? a.y - r.cy : a.x - r.cx, radius = h ? r.ry : r.rx, extent = h ? r.rx : r.ry, centre = h ? r.cx : r.cy;
|
|
36
|
+
const lower = Math.min(h ? a.x : a.y, h ? b.x : b.y), upper = Math.max(h ? a.x : a.y, h ? b.x : b.y);
|
|
37
|
+
if (Math.abs(fixed) <= radius) { const half = extent * Math.sqrt(1 - (fixed / radius) ** 2); if (lower <= centre + half && upper >= centre - half) return 0; }
|
|
38
|
+
if (lower <= centre && upper >= centre) return Math.max(0,Math.abs(fixed)-radius);
|
|
39
|
+
const first=point(a);return first===0?0:Math.min(first,point(b));
|
|
40
|
+
}
|
|
41
|
+
function cylinderDistance(a,b,node) {
|
|
42
|
+
const left=node.position.x+12,right=node.position.x+node.size.width-12,top=node.position.y+2,bottom=node.position.y+node.size.height-2,rx=(right-left)/2,cx=(left+right)/2;
|
|
43
|
+
const inside=p=>{if(p.x<left||p.x>right)return false;const inset=5*(1-Math.sqrt(Math.min(p.x-left,right-p.x)/rx))**2;return p.y>=top+inset&&p.y<=bottom-inset;};
|
|
44
|
+
if(inside(a)||inside(b)||inside({x:clamp(cx,Math.min(a.x,b.x),Math.max(a.x,b.x)),y:clamp((top+bottom)/2,Math.min(a.y,b.y),Math.max(a.y,b.y))}))return 0;
|
|
45
|
+
const curve=(x,y,sx,sy)=>{
|
|
46
|
+
const distance=t=>pointDistance({x:x+sx*rx*t*t,y:y+sy*5*(1-t)**2},a,b);
|
|
47
|
+
let lo=0,hi=1;
|
|
48
|
+
for(let i=0;i<32;i++){const l=lo+(hi-lo)/3,h=hi-(hi-lo)/3;if(distance(l)<distance(h))hi=h;else lo=l;}
|
|
49
|
+
return Math.min(distance(0),distance(1),distance((lo+hi)/2));
|
|
50
|
+
};
|
|
51
|
+
return Math.min(boxDistance(a,b,{x:left,y:top+5,width:right-left,height:bottom-top-10}),curve(left,top,1,1),curve(right,top,-1,1),curve(left,bottom,1,-1),curve(right,bottom,-1,-1));
|
|
52
|
+
}
|
|
53
|
+
function polygonDistance(a, b, points) {
|
|
54
|
+
const inside = p => { let result = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
55
|
+
const c = points[i], d = points[j];
|
|
56
|
+
if ((c.y > p.y) !== (d.y > p.y) && p.x < (d.x - c.x) * (p.y - c.y) / (d.y - c.y) + c.x) result = !result;
|
|
57
|
+
} return result; };
|
|
58
|
+
if (inside(a) || inside(b)) return 0;
|
|
59
|
+
return Math.min(...points.map((p, i) => segmentDistance(a, b, p, points[(i + 1) % points.length])));
|
|
60
|
+
}
|
|
61
|
+
// Compile once per node: the router checks many grid segments against the same visible geometry.
|
|
62
|
+
const cache = new WeakMap();
|
|
63
|
+
export function nodeClearance(node, type) {
|
|
64
|
+
const signature = JSON.stringify([type,node.position,node.size,node.kind,node.label,node.subtitle,node.overviewText,node.badges,node.overviewTone]);
|
|
65
|
+
const cached = cache.get(node); if (cached?.signature === signature) return cached.probe;
|
|
66
|
+
const diagram = getDiagram(type), { x, y } = node.position;
|
|
67
|
+
const outline = diagram.outline?.(node, x, y) ?? [];
|
|
68
|
+
const [tag, shape] = outline[0] ?? [];
|
|
69
|
+
let probe;
|
|
70
|
+
if (type === 'usecase' && node.kind === 'actor') {
|
|
71
|
+
const top = y + actorTop(node), cx = x + node.size.width / 2;
|
|
72
|
+
const body = [[{x:cx,y:top+24},{x:cx,y:top+53}],[{x:cx-20,y:top+35},{x:cx+20,y:top+35}],[{x:cx,y:top+53},{x:cx-17,y:top+76}],[{x:cx,y:top+53},{x:cx+17,y:top+76}]];
|
|
73
|
+
const text = [[node.label, 20, top + 78, 26], ...(node.subtitle ? [[node.subtitle, 16, top + 106, 24]] : [])].map(([label, font, ty, height]) => {
|
|
74
|
+
const width = Math.min(node.size.width, layoutText(label, Infinity, font).width * 1.15); return { x: cx - width / 2, y: ty, width, height };
|
|
75
|
+
});
|
|
76
|
+
probe = (a, b) => Math.min(Math.max(0, pointDistance({x:cx,y:top+13},a,b)-11), ...body.map(([c,d])=>segmentDistance(a,b,c,d)), ...text.map(r=>boxDistance(a,b,r)));
|
|
77
|
+
} else if (tag === 'path' && ['architecture','deployment'].includes(type) && node.kind === 'database') {
|
|
78
|
+
probe=(a,b)=>cylinderDistance(a,b,node);
|
|
79
|
+
} else if (tag === 'polygon') {
|
|
80
|
+
const points = shape.points.split(/\s+/).map(p => { const [x,y]=p.split(',').map(Number); return {x,y}; });
|
|
81
|
+
probe = (a,b) => polygonDistance(a,b,points);
|
|
82
|
+
} else if (tag === 'circle') probe = (a,b) => Math.max(0,pointDistance({x:shape.cx,y:shape.cy},a,b)-shape.r);
|
|
83
|
+
else if (tag === 'ellipse') probe = (a,b) => ellipseDistance(a,b,shape);
|
|
84
|
+
else {
|
|
85
|
+
// Path-based cards (cylinders, documents and open stores) also reserve their full text body.
|
|
86
|
+
const r = tag === 'rect' ? shape : routingBounds(node,type), radius = tag === 'rect' ? Math.min(shape.rx ?? 0,r.width/2,r.height/2) : 0;
|
|
87
|
+
const inner = {x:r.x+radius,y:r.y+radius,width:r.width-2*radius,height:r.height-2*radius};
|
|
88
|
+
probe = (a,b) => Math.max(0,boxDistance(a,b,inner)-radius);
|
|
89
|
+
}
|
|
90
|
+
if (type === 'state' && ['initial','final'].includes(node.kind) && node.subtitle) {
|
|
91
|
+
const base=probe, area=diagram.textArea(node), text={...area,x:x+area.x,y:y+area.y};
|
|
92
|
+
probe=(a,b)=>Math.min(base(a,b),boxDistance(a,b,text));
|
|
93
|
+
}
|
|
94
|
+
const geometryProbe=probe, points=new Map();
|
|
95
|
+
probe=(a,b)=>{
|
|
96
|
+
if(a.x!==b.x||a.y!==b.y)return geometryProbe(a,b);
|
|
97
|
+
const key=a.x+','+a.y;if(points.has(key))return points.get(key);
|
|
98
|
+
const distance=geometryProbe(a,b);if(points.size<1024)points.set(key,distance);return distance;
|
|
99
|
+
};
|
|
100
|
+
cache.set(node,{signature,probe}); return probe;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function boundaryClearance(a,b,group) {
|
|
104
|
+
const h=a.y===b.y, axis=h?'x':'y', across=h?'y':'x';
|
|
105
|
+
return groupBorders(group).filter(([c,d])=>h===(c.y===d.y)).map(([c,d])=>({
|
|
106
|
+
distance:Math.abs(a[across]-c[across]),
|
|
107
|
+
length:Math.max(0,Math.min(Math.max(a[axis],b[axis]),Math.max(c[axis],d[axis]))-Math.max(Math.min(a[axis],b[axis]),Math.min(c[axis],d[axis])))
|
|
108
|
+
})).filter(item=>item.length>.001).sort((a,b)=>a.distance-b.distance)[0] ?? {distance:Infinity,length:0};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Only the terminal segment may enter its own node's clearance band.
|
|
112
|
+
export function clearanceProbe(graph, sourceId, targetId, sourcePoints = [], targetPoints = []) {
|
|
113
|
+
const type=diagramTypeOf(graph), limits=clearanceLimits(graph);
|
|
114
|
+
const entries=graph.nodes.map(node=>({node,probe:nodeClearance(node,type),bounds:routingBounds(node,type),terminals:[...(node.id===sourceId?sourcePoints:[]),...(node.id===targetId?targetPoints:[])]}));
|
|
115
|
+
return (a,b) => entries.some(({node,probe,bounds,terminals})=> {
|
|
116
|
+
if (boxDistance(a,b,bounds)>=limits.node && !(type==='usecase'&&node.kind==='actor') && !(type==='state'&&node.subtitle)) return false;
|
|
117
|
+
const endpoint=p=>terminals.some(t=>Math.abs(t.x-p.x)<.001&&Math.abs(t.y-p.y)<.001);
|
|
118
|
+
if (endpoint(a) && probe(b,b)+.001>=limits.node || endpoint(b) && probe(a,a)+.001>=limits.node) return false;
|
|
119
|
+
return probe(a,b)+.001<limits.node;
|
|
120
|
+
}) || (graph.groups??[]).some(group=>boundaryClearance(a,b,group).distance+.001<limits.boundary);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Edits reserve a halo around changed visible geometry. A changed boundary affects only nearby lanes/headings.
|
|
124
|
+
export function affectedRouteIds(before, after, seed = []) {
|
|
125
|
+
const ids = new Set(seed), type = diagramTypeOf(after), gap = clearanceLimits(after).node;
|
|
126
|
+
const changed = after.nodes.filter(node => JSON.stringify(node) !== JSON.stringify(before.nodes.find(old => old.id === node.id)));
|
|
127
|
+
const nodes = new Set(changed.map(node => node.id));
|
|
128
|
+
const obstacles = changed.map(node => { const r = occupiedBox(node, type); return { x:r.x-gap, y:r.y-gap, width:r.width+2*gap, height:r.height+2*gap }; });
|
|
129
|
+
const groups = (after.groups ?? []).filter(group => JSON.stringify(group) !== JSON.stringify(before.groups?.find(old => old.id === group.id)));
|
|
130
|
+
const routes = createEdgeRoutes(before), labelGap = layoutLimits(getDiagram(type)).labelGap;
|
|
131
|
+
const labelObstacles = [...changed.map(node => occupiedBox(node,type)), ...groups.flatMap(groupHeadingBoxes)];
|
|
132
|
+
const rectDistance = (a,b) => Math.hypot(Math.max(a.x-b.x-b.width,b.x-a.x-a.width,0),Math.max(a.y-b.y-b.height,b.y-a.y-a.height,0));
|
|
133
|
+
for (const edge of after.edges) {
|
|
134
|
+
const old = before.edges.find(item => item.id === edge.id), route = routes.get(edge.id);
|
|
135
|
+
if (nodes.has(edge.source) || nodes.has(edge.target) || JSON.stringify(edge) !== JSON.stringify(old)
|
|
136
|
+
|| route && [...(route.label ? [route.labelBox] : []), ...(route.endpointLabels ?? []).map(label=>label.labelBox)].some(label=>labelObstacles.some(box=>rectDistance(label,box)<labelGap))
|
|
137
|
+
|| route?.points.slice(1).some((b,i) => {
|
|
138
|
+
const a = route.points[i];
|
|
139
|
+
return obstacles.some(r => segmentCrossesBox(a,b,r)) || groups.some(group => boundaryClearance(a,b,group).distance < clearanceLimits(after).boundary
|
|
140
|
+
|| groupHeadingBoxes(group).some(r => segmentCrossesBox(a,b,r)));
|
|
141
|
+
})) ids.add(edge.id);
|
|
142
|
+
}
|
|
143
|
+
return ids;
|
|
144
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { operandScopes } from './sequence-fragments.js';
|
|
2
|
+
import { edgeLabelLayout } from './text-layout.js';
|
|
2
3
|
|
|
3
4
|
// Pair numbers follow the calls' message order (C1 is the first call that gets a reply), not their id spelling.
|
|
4
5
|
export function sequencePairs(graph) {
|
|
@@ -18,6 +19,27 @@ export function sequenceMessageLabel(graph, edge, pairs = sequencePairs(graph),
|
|
|
18
19
|
return pair ? `${pair.label} · ${edge.label ?? ''}` : parallel ? edge.label ?? '' : `${String(edge.order).padStart(2, '0')} · ${edge.label ?? ''}`;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Difference constraints on each message's actual span; moving the suffix keeps unrelated neighboring gaps unchanged.
|
|
23
|
+
// It only widens, so it lays out a fresh row and repairs a placed one alike. `nodes` stand left to right.
|
|
24
|
+
export function spreadParticipants(graph, nodes) {
|
|
25
|
+
const index = id => nodes.findIndex(node => node.id === id);
|
|
26
|
+
const constraints = [...graph.edges].sort((a, b) => Math.abs(index(a.source) - index(a.target)) - Math.abs(index(b.source) - index(b.target)) || a.order - b.order);
|
|
27
|
+
const selfCounts = new Map();
|
|
28
|
+
for (const edge of constraints) {
|
|
29
|
+
const left = Math.min(index(edge.source), index(edge.target));
|
|
30
|
+
let right = Math.max(index(edge.source), index(edge.target));
|
|
31
|
+
let required = edgeLabelLayout(sequenceMessageLabel(graph, edge)).width + 64;
|
|
32
|
+
if (left === right) {
|
|
33
|
+
const ordinal = selfCounts.get(edge.source) ?? 0; selfCounts.set(edge.source, ordinal + 1);
|
|
34
|
+
right++; required += 48 + ordinal * 24;
|
|
35
|
+
if (right === nodes.length) continue;
|
|
36
|
+
}
|
|
37
|
+
const center = node => node.position.x + node.size.width / 2;
|
|
38
|
+
const extra = Math.max(0, required - (center(nodes[right]) - center(nodes[left])));
|
|
39
|
+
for (let i = right; i < nodes.length; i++) nodes[i].position.x += extra;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// Hand-edited or legacy data may lack route.messageY, or the whole route. Such a message is stacked by order below the
|
|
22
44
|
// default headers so the page still opens; the layout check reports the missing coordinate and --layout auto writes it.
|
|
23
45
|
const FALLBACK_TOP = 140, FALLBACK_STEP = 54;
|
|
@@ -168,11 +168,29 @@ export function sequenceFragment(group, routes, locale, groups = [], executions
|
|
|
168
168
|
const childFrames = children.filter(child => child.parentOperandId === operandId(operand, i)).map(child => ({ ...child.position, ...child.size, id: child.id }));
|
|
169
169
|
const selected = operandEdges(group, operand, i, groups).map(id => routes.get(id)).filter(Boolean);
|
|
170
170
|
const boxes = [...childFrames, ...selected.flatMap(route => [route.labelBox, ...segmentBoxes(route)])];
|
|
171
|
-
|
|
171
|
+
const label = group.kind === 'par' ? operand.label : `${group.kind === 'loop' ? `${group.loop.min}..${group.loop.max} ` : ''}[${operand.guard}]`;
|
|
172
|
+
const width = Math.min(LAYOUT_TARGETS.labelWidth, Math.max(1, frame.width - 40));
|
|
173
|
+
const textHeight = layoutText(label, width, 14, 20).height + 16 + (operand.body ? layoutText(operand.body, width, 16, 24).height + 12 : 0);
|
|
174
|
+
return { operand, selected, childFrames, boxes, textHeight, top: Math.min(...boxes.map(box => box.y)), bottom: Math.max(...boxes.map(box => box.y + box.height)) };
|
|
172
175
|
});
|
|
173
176
|
for (let i = 1; i < sections.length; i++) {
|
|
174
177
|
const previous = sections[i - 1], next = sections[i];
|
|
175
|
-
separators.push(
|
|
178
|
+
separators.push((previous.bottom + next.top) / 2);
|
|
179
|
+
}
|
|
180
|
+
for (let start = 0; start < sections.length; start++) {
|
|
181
|
+
if (sections[start].boxes.length) continue;
|
|
182
|
+
let end = start;
|
|
183
|
+
while (end + 1 < sections.length && !sections[end + 1].boxes.length) end++;
|
|
184
|
+
const before = sections[start - 1], after = sections[end + 1];
|
|
185
|
+
const low = before ? before.bottom : headingBottom, high = after ? after.top : bottom;
|
|
186
|
+
let used = before ? 4 : 0;
|
|
187
|
+
const total = used + sections.slice(start, end + 1).reduce((sum, section) => sum + section.textHeight, 0) + (after?.textHeight ?? 0);
|
|
188
|
+
if (before) separators[start - 1] = low + (high - low) * used / total;
|
|
189
|
+
for (let index = start; index <= end; index++) {
|
|
190
|
+
used += sections[index].textHeight;
|
|
191
|
+
if (index + 1 < sections.length) separators[index] = low + (high - low) * used / total;
|
|
192
|
+
}
|
|
193
|
+
start = end;
|
|
176
194
|
}
|
|
177
195
|
const members = new Set(group.operands.flatMap((operand, i) => operandEdges(group, operand, i, groups)));
|
|
178
196
|
for (const [id, route] of routes) if (route.points.some(p => p.x >= frame.x && p.x <= frame.x + frame.width && p.y > frame.y && p.y < bottom) && !members.has(id)) errors.push(`layout: group ${group.id} omits enclosed message ${id}`);
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { viewIdOf } from './view-identity.js';
|
|
2
|
+
import { cardText, diagramTypeOf, getDiagram } from './diagrams/registry.js';
|
|
3
|
+
import { layoutLimits } from './layout-spacing.js';
|
|
4
|
+
import { requireDiagramQuality } from './layout-quality.js';
|
|
5
|
+
import { groupHeadingLayout } from './text-layout.js';
|
|
2
6
|
|
|
3
7
|
// The generator embeds the graph as escaped JSON inside this script element; saving from the page rewrites the same
|
|
4
8
|
// element, so the saved page reopens with its edits and the generator and the page never disagree about the encoding.
|
|
@@ -11,12 +15,13 @@ export function pageWithGraph(html, graph) {
|
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function graphInputWithEdits(input, drafts, currentGraph) {
|
|
14
|
-
const edited = graph =>
|
|
18
|
+
const edited = graph => viewIdOf(graph) === viewIdOf(currentGraph) ? currentGraph : drafts.get(viewIdOf(graph)) ?? graph;
|
|
15
19
|
return Array.isArray(input.diagrams) ? { ...input, diagrams: input.diagrams.map(edited) } : currentGraph;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export function currentGraphFromFlow(graph, nodes, edges) {
|
|
19
23
|
const nodeState = new Map(nodes.filter(node => node.type === 'diagram').map(node => [node.id, node]));
|
|
24
|
+
const groupState = new Map(nodes.filter(node => node.type === 'boundary').map(node => [node.id, node]));
|
|
20
25
|
const edgeState = new Map(edges.map(edge => [edge.id, edge]));
|
|
21
26
|
return {
|
|
22
27
|
...graph,
|
|
@@ -24,9 +29,14 @@ export function currentGraphFromFlow(graph, nodes, edges) {
|
|
|
24
29
|
const current = nodeState.get(node.id);
|
|
25
30
|
if (!current) return node;
|
|
26
31
|
const text = { label: current.data.label };
|
|
32
|
+
for (const key of ['overviewText', 'badges']) if (Object.hasOwn(current.data, key)) text[key] = current.data[key];
|
|
27
33
|
if (Object.hasOwn(current.data, 'subtitle')) text.subtitle = current.data.subtitle;
|
|
28
|
-
return { ...node, ...text, position: current.position };
|
|
34
|
+
return { ...node, ...text, position: current.position, size: current.data.size };
|
|
29
35
|
}),
|
|
36
|
+
...(graph.groups && { groups: graph.groups.map(group => {
|
|
37
|
+
const current = groupState.get(group.id);
|
|
38
|
+
return current ? { ...group, position: current.position, size: current.data.size } : group;
|
|
39
|
+
}) }),
|
|
30
40
|
edges: graph.edges.map(edge => {
|
|
31
41
|
const current = edgeState.get(edge.id);
|
|
32
42
|
return current && Object.hasOwn(current.data, 'label') ? { ...edge, label: current.data.label } : edge;
|
|
@@ -41,3 +51,36 @@ export function constrainNodeChanges(changes, nodes, diagramType) {
|
|
|
41
51
|
? { ...change, position: { ...change.position, y: positions.get(change.id).y } }
|
|
42
52
|
: change);
|
|
43
53
|
}
|
|
54
|
+
|
|
55
|
+
// The graph with the boxes (id -> { position, size }) of nodes and groups put in place.
|
|
56
|
+
export function placed(graph, boxes) {
|
|
57
|
+
const place = item => boxes.has(item.id) ? { ...item, ...boxes.get(item.id) } : item;
|
|
58
|
+
return { ...graph, nodes: graph.nodes.map(place), ...(graph.groups && { groups: graph.groups.map(place) }) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A card keeps all of its text. When an edit leaves it too short it grows by the missing height around its centre, else
|
|
62
|
+
// upward, else downward (a connection leaving its top or bottom edge needs the stub there clear), whichever the quality gate
|
|
63
|
+
// accepts first, and the boundaries that own it grow just enough to keep their clearance. If none is accepted the centred
|
|
64
|
+
// growth stays and the gate names what is left; Arrange only moves cards. `classic` is the card style the view already draws
|
|
65
|
+
// (compactCards of the graph before the edit). Returns id -> { position, size } for every box that changed.
|
|
66
|
+
export function fitCard(graph, id, classic) {
|
|
67
|
+
const type = diagramTypeOf(graph), diagram = getDiagram(type), node = graph.nodes.find(item => item.id === id);
|
|
68
|
+
const grow = diagram.cardLayout ? cardText(node, type, graph.meta.locale, classic).minHeight - node.size.height : 0;
|
|
69
|
+
if (grow <= 0) return new Map();
|
|
70
|
+
const { groupInset, groupHeadingGap } = layoutLimits(diagram), groups = new Map((graph.groups ?? []).map(group => [group.id, group]));
|
|
71
|
+
// `above` is the share of the extra height that goes over the card's top edge; each boundary then takes in its member's box.
|
|
72
|
+
const growth = above => {
|
|
73
|
+
let box = { position: { x: node.position.x, y: Math.max(0, node.position.y - grow * above) }, size: { width: node.size.width, height: node.size.height + grow } };
|
|
74
|
+
const boxes = new Map([[id, box]]);
|
|
75
|
+
for (let group = groups.get(node.groupId), depth = 0; group && depth < groups.size; group = groups.get(group.parentId), depth++) {
|
|
76
|
+
const { position, size } = group, top = groupHeadingLayout(group).height + groupHeadingGap;
|
|
77
|
+
const left = Math.max(0, Math.min(position.x, box.position.x - groupInset)), upper = Math.max(0, Math.min(position.y, box.position.y - top));
|
|
78
|
+
const right = Math.max(position.x + size.width, box.position.x + box.size.width + groupInset), lower = Math.max(position.y + size.height, box.position.y + box.size.height + groupInset);
|
|
79
|
+
boxes.set(group.id, box = { position: { x: left, y: upper }, size: { width: right - left, height: lower - upper } });
|
|
80
|
+
}
|
|
81
|
+
return boxes;
|
|
82
|
+
};
|
|
83
|
+
const accepted = boxes => { try { requireDiagramQuality(placed(graph, boxes)); return true; } catch { return false; } };
|
|
84
|
+
const options = [.5, 1, 0].map(growth);
|
|
85
|
+
return options.find(accepted) ?? options[0];
|
|
86
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { TYPOGRAPHY } from './visual-style.js';
|
|
1
|
+
import { TYPOGRAPHY, kindLabels } from './visual-style.js';
|
|
2
|
+
import { translate } from './i18n.js';
|
|
2
3
|
import { LAYOUT_LIMITS, LAYOUT_TARGETS } from './layout-spacing.js';
|
|
3
4
|
|
|
4
5
|
// Conservative system-font widths keep server-rendered SVG and browser labels in the same bounds.
|
|
@@ -79,11 +80,37 @@ export function layoutText(value, maxWidth, fontSize = TYPOGRAPHY.body, lineHeig
|
|
|
79
80
|
return { lines, width: Math.ceil(Math.max(...lines.map(line => textWidth(line, fontSize)))), height: lines.length * lineHeight, lineHeight };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
// Component card: the icon plate, the title and the kind tag share the first row; the subtitle and the source anchor (file
|
|
84
|
+
// name and lines) follow in the same text column. The whole block is centred vertically in a taller card.
|
|
85
|
+
export const CARD = Object.freeze({ pad: 14, plate: 24, textX: 48, tagGap: 8, tagPad: 8, titleLine: 26, subtitleLine: 24, sourceLine: 20, minHeight: 64, maxWidth: 360 });
|
|
86
|
+
// The card shows where a component is defined (file name and first line); the quick look and Inspector give the full path and range.
|
|
87
|
+
export const cardSourceText = node => node.source?.file ? `${node.source.file.split('/').at(-1)}:${node.source.lineStart}` : '';
|
|
88
|
+
// A source line never wraps: a file name too long for the column keeps its start and end around an ellipsis.
|
|
89
|
+
function oneLine(value, width, fontSize) {
|
|
90
|
+
const colon = value.lastIndexOf(':'), name = value.slice(0, colon), line = value.slice(colon);
|
|
91
|
+
for (let keep = name.length - 1; textWidth(value, fontSize) > width && keep > 4; keep--) value = `${name.slice(0, Math.ceil(keep / 2))}…${name.slice(name.length - Math.floor(keep / 2))}${line}`;
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
export const cardTag = (node, locale) => {
|
|
95
|
+
const value = translate(locale, kindLabels[node.kind] ?? node.kind);
|
|
96
|
+
// The stereotype style adds .6px letter spacing.
|
|
97
|
+
return { text: value, width: Math.ceil(textWidth(value, TYPOGRAPHY.small) + value.length * .6) + 2 * CARD.tagPad };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// classic: the card drawn before compact cards (kind row above a full-width title, no source line). Views whose stored
|
|
101
|
+
// geometry predates compact cards keep it; see compactCards.
|
|
102
|
+
// corner: extra room right of the kind tag for notation drawn in the card's top-right corner.
|
|
103
|
+
export function cardTextLayout(node, locale, classic = false, corner = 0) {
|
|
104
|
+
if (classic) {
|
|
105
|
+
const title = layoutText(node.label, node.size.width - 30, TYPOGRAPHY.title, 26), subtitle = layoutText(node.subtitle ?? '', node.size.width - 30, TYPOGRAPHY.body, 24);
|
|
106
|
+
return { title, subtitle, minHeight: Math.max(100, 65 + title.height + subtitle.height) };
|
|
107
|
+
}
|
|
108
|
+
const tag = cardTag(node, locale), column = node.size.width - CARD.textX - CARD.pad;
|
|
109
|
+
const title = layoutText(node.label, column - tag.width - CARD.tagGap - corner, TYPOGRAPHY.title, CARD.titleLine);
|
|
110
|
+
const subtitle = layoutText(node.subtitle ?? '', column, TYPOGRAPHY.body, CARD.subtitleLine);
|
|
111
|
+
const source = layoutText(oneLine(cardSourceText(node), column, TYPOGRAPHY.small), column, TYPOGRAPHY.small, CARD.sourceLine);
|
|
112
|
+
const contentHeight = CARD.pad + title.height + (subtitle.height ? 4 + subtitle.height : 0) + (source.height ? 6 + source.height : 0) + CARD.pad;
|
|
113
|
+
return { title, subtitle, source, tag, contentHeight, minHeight: Math.max(CARD.minHeight, contentHeight) };
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
export function edgeLabelLayout(value, maxWidth = LAYOUT_TARGETS.labelWidth) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Legacy collections use their unique diagram type as identity. Repeated architecture views require authored IDs.
|
|
2
|
+
export const viewIdOf = graph => graph?.meta?.viewId ?? graph?.meta?.diagramType ?? 'architecture';
|
|
3
|
+
export const architectureViewOf = graph => graph?.meta?.architectureView ?? 'relations';
|
|
4
|
+
export const isArchitectureOverview = graph => (graph?.meta?.diagramType ?? 'architecture') === 'architecture' && architectureViewOf(graph) !== 'relations';
|
|
5
|
+
export const architectureViewLabels = { relations: 'Component relationship architecture', capabilities: 'Platform capability architecture', engineering: 'Engineering layer architecture' };
|
|
6
|
+
|
|
7
|
+
// Presentation types are independent templates, while the nine semantic JSON types remain compatible.
|
|
8
|
+
export const VIEW_TYPES = Object.freeze([
|
|
9
|
+
{ id: 'capabilities', type: 'architecture', label: architectureViewLabels.capabilities },
|
|
10
|
+
{ id: 'engineering', type: 'architecture', label: architectureViewLabels.engineering },
|
|
11
|
+
{ id: 'relations', type: 'architecture', label: architectureViewLabels.relations },
|
|
12
|
+
{ id: 'flowchart', type: 'flowchart', label: 'Flowchart' },
|
|
13
|
+
{ id: 'sequence', type: 'sequence', label: 'Sequence' },
|
|
14
|
+
{ id: 'er', type: 'er', label: 'ER diagram' },
|
|
15
|
+
{ id: 'deployment', type: 'deployment', label: 'Deployment' },
|
|
16
|
+
{ id: 'class', type: 'class', label: 'Class diagram' },
|
|
17
|
+
{ id: 'state', type: 'state', label: 'State diagram' },
|
|
18
|
+
{ id: 'usecase', type: 'usecase', label: 'Use cases' },
|
|
19
|
+
{ id: 'dataflow', type: 'dataflow', label: 'Data flow' }
|
|
20
|
+
]);
|
|
21
|
+
export const viewTypeOf = graph => (graph?.meta?.diagramType ?? 'architecture') === 'architecture' ? architectureViewOf(graph) : graph.meta.diagramType;
|
|
22
|
+
export const viewTypeLabel = graph => VIEW_TYPES.find(view => view.id === viewTypeOf(graph))?.label ?? graph?.meta?.diagramType ?? architectureViewLabels.relations;
|
|
23
|
+
export const compareViews = (a, b) => {
|
|
24
|
+
const rank = graph => { const index = VIEW_TYPES.findIndex(view => view.id === viewTypeOf(graph)); return index < 0 ? VIEW_TYPES.length : index; };
|
|
25
|
+
return rank(a) - rank(b);
|
|
26
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IDENTITY, IDENTITY_SCALES, RADIX } from './radix-colors.js';
|
|
2
|
+
import { overviewAppearance, overviewPalette } from './architecture-overview-theme.js';
|
|
2
3
|
|
|
3
4
|
// Mix a color over a background: identity washes and headers stay tied to their chip color.
|
|
4
5
|
export function mix(color, background, amount) {
|
|
@@ -14,21 +15,22 @@ const FLIGHT_SCALES = ['orange', 'blue', 'teal', 'violet', 'plum', 'indigo'];
|
|
|
14
15
|
export const PALETTES = Object.fromEntries(Object.entries(RADIX).map(([theme, { neutral: n, accent: t, data: b, warn: a, guard: g }]) => [theme, {
|
|
15
16
|
paper: n[1], surface: n[1], surface2: n[2],
|
|
16
17
|
ink: n[12], ink2: n[11], ink3: n[11], rule: n[6], ruleSoft: n[4],
|
|
17
|
-
accent:
|
|
18
|
-
data:
|
|
18
|
+
accent: theme === 'dark' ? '#67c4aa' : '#126b5a', accentSoft: t[2], ringCore: t[5],
|
|
19
|
+
data: theme === 'dark' ? '#d9b16c' : '#886019', warn: a[11], ringWarn: a[5], guard: g[11],
|
|
19
20
|
badge: n[3], outline: theme === 'dark' ? n[10] : n[9],
|
|
20
21
|
// Dark cards lift one step above the canvas so a plain card still reads as a surface.
|
|
21
22
|
card: theme === 'dark' ? n[3] : n[1],
|
|
22
23
|
groupFill: n[2], groupFillNested: theme === 'dark' ? mix(n[2], n[3], .5) : n[1], groupLine: n[5],
|
|
23
|
-
moduleTones:
|
|
24
|
+
moduleTones: (theme === 'dark' ? ['#80b5ea', '#67c4aa', '#bb9add', '#d9b16c', '#9eafa7'] : ['#3677bb', '#16816d', '#8c64ac', '#b27e2a', '#6a7c75']).map((color, index) => ({ name: ['blue', 'teal', 'purple', 'amber', 'sage'][index], chip: color, accent: color, wash: mix(color, theme === 'dark' ? n[3] : n[1], .09), header: mix(color, n[2], theme === 'dark' ? .16 : .09) })),
|
|
24
25
|
// Lifecycle tones of a state card: the goal is green, an ended state Slate, a failed one Red, and in-flight states take
|
|
25
26
|
// the ramp above. The body is step 3; the frame is step 11 on light cards and step 10 on dark ones, the first steps
|
|
26
27
|
// that hold 3:1 against that body in each theme.
|
|
28
|
+
sequenceTones: IDENTITY_SCALES.map(name => IDENTITY[theme][name][10]),
|
|
27
29
|
stateTones: (() => {
|
|
28
30
|
const step = theme === 'dark' ? 10 : 11, scales = IDENTITY[theme], identity = name => ({ name, fill: scales[name][3], stroke: scales[name][step] });
|
|
29
31
|
return { goal: identity('grass'), ended: { name: 'slate', fill: n[3], stroke: n[step] }, failed: { name: 'red', fill: a[3], stroke: a[step] }, flight: FLIGHT_SCALES.map(identity) };
|
|
30
32
|
})(),
|
|
31
|
-
edge: theme === 'dark' ? n[10] :
|
|
33
|
+
edge: theme === 'dark' ? n[10] : '#747982',
|
|
32
34
|
mask: theme === 'dark' ? 'rgba(17,17,19,.75)' : 'rgba(252,252,253,.75)',
|
|
33
35
|
group: n[2]
|
|
34
36
|
}]));
|
|
@@ -107,6 +109,7 @@ const STATE_TONE_LABELS = { goal: 'Core state', ended: 'Ended state', failed: 'F
|
|
|
107
109
|
// Text never follows either: titles stay ink. A failure keeps its red frame over any module. A state in a state
|
|
108
110
|
// diagram wears its lifecycle tone instead of the module's frame and wash; the ring and the module's lines stay.
|
|
109
111
|
export function nodeAppearance(node, palette, moduleColors) {
|
|
112
|
+
if (node.overviewText !== undefined || node.badges !== undefined || node.overviewTone !== undefined) return overviewAppearance(node, palette);
|
|
110
113
|
const tone = moduleColors?.get(node.module);
|
|
111
114
|
const stateRole = node.kind === 'state' ? moduleColors?.stateTones?.get(node.id) : undefined;
|
|
112
115
|
if (stateRole) {
|
|
@@ -125,9 +128,10 @@ export function nodeAppearance(node, palette, moduleColors) {
|
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
// Pairs walk the identity scales in order; the order itself keeps neighbouring pair numbers far apart in hue.
|
|
128
|
-
export const sequenceGroupColor = (pair, palette) => pair ? palette.
|
|
131
|
+
export const sequenceGroupColor = (pair, palette) => pair ? palette.sequenceTones[pair.index % palette.sequenceTones.length] : undefined;
|
|
129
132
|
|
|
130
133
|
export function edgeColor(edge, target, palette, moduleColors, source, pair) {
|
|
134
|
+
if (source?.overviewTone !== undefined) return overviewPalette(palette).edge;
|
|
131
135
|
if (pair) return sequenceGroupColor(pair, palette);
|
|
132
136
|
if (edge.kind === 'failure') return palette.warn;
|
|
133
137
|
if (edge.kind === 'success') return palette.accent;
|
|
@@ -142,6 +146,10 @@ export function themeVariables(palette) {
|
|
|
142
146
|
return Object.fromEntries([...Object.entries(tokens).map(([name, key]) => [`--${name}`, palette[key]]), ...Object.entries(sizes).map(([name, key]) => [`--${name}`, `${TYPOGRAPHY[key]}px`])]);
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
export const evidenceLabels = { source: 'Source code', code: 'Code', config: 'Configuration', schema: 'Schema', test: 'Test', document: 'Document', framework: 'Framework convention', inference: 'Inference' };
|
|
150
|
+
// "file:start-end" for a node source or an edge site.
|
|
151
|
+
export const anchorText = anchor => `${anchor.file}:${anchor.lineStart}${anchor.lineEnd ? `-${anchor.lineEnd}` : ''}`;
|
|
152
|
+
|
|
145
153
|
export const kindLabels = {
|
|
146
154
|
external: 'External', config: 'Configuration', framework: 'Framework', security: 'Security', service: 'Service', business: 'Business',
|
|
147
155
|
data: 'Data', failure: 'Failure', system: 'System', component: 'Component', database: 'Database', start: 'Start', end: 'End',
|