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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.qoder-plugin/plugin.json +1 -1
  6. package/README.md +119 -70
  7. package/docs/clients.de.md +15 -24
  8. package/docs/clients.es.md +15 -24
  9. package/docs/clients.ja.md +15 -24
  10. package/docs/clients.md +15 -24
  11. package/docs/clients.pt.md +15 -24
  12. package/docs/clients.ru.md +15 -24
  13. package/docs/clients.zh-CN.md +15 -24
  14. package/docs/readme/README.de.md +120 -71
  15. package/docs/readme/README.es.md +120 -71
  16. package/docs/readme/README.ja.md +120 -71
  17. package/docs/readme/README.pt.md +120 -71
  18. package/docs/readme/README.ru.md +120 -71
  19. package/docs/readme/README.zh-CN.md +106 -59
  20. package/examples/jeepay/README.md +23 -0
  21. package/examples/jeepay/capabilities.graph.json +270 -0
  22. package/examples/jeepay/class.graph.json +237 -0
  23. package/examples/jeepay/collection.graph.json +3057 -0
  24. package/examples/jeepay/dataflow.graph.json +212 -0
  25. package/examples/jeepay/deployment.graph.json +222 -0
  26. package/examples/jeepay/engineering.graph.json +277 -0
  27. package/examples/jeepay/er.graph.json +482 -0
  28. package/examples/jeepay/flowchart.graph.json +312 -0
  29. package/examples/jeepay/relations.graph.json +289 -0
  30. package/examples/jeepay/sequence.graph.json +355 -0
  31. package/examples/jeepay/source.json +95 -0
  32. package/examples/jeepay/state.graph.json +175 -0
  33. package/examples/jeepay/usecase.graph.json +222 -0
  34. package/package.json +14 -3
  35. package/skills/q-flow/SKILL.md +28 -20
  36. package/skills/q-flow/agents/openai.yaml +1 -1
  37. package/skills/q-flow/assets/viewer/package.json +1 -1
  38. package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
  39. package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
  41. package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
  42. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
  43. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
  44. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
  45. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
  46. package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
  47. package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
  48. package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
  49. package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
  50. package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
  51. package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
  52. package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
  53. package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
  54. package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
  55. package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
  56. package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
  57. package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
  58. package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
  59. package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
  60. package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
  61. package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
  62. package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
  63. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
  64. package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
  65. package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
  66. package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
  67. package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
  68. package/skills/q-flow/assets/viewer-dist/index.html +30 -28
  69. package/skills/q-flow/references/evidence-sources.md +7 -5
  70. package/skills/q-flow/references/graph-common.md +34 -34
  71. package/skills/q-flow/references/graph-schema.md +28 -7
  72. package/skills/q-flow/references/guided-intake.md +51 -71
  73. package/skills/q-flow/references/layout-routing.md +47 -0
  74. package/skills/q-flow/references/types/architecture.md +42 -22
  75. package/skills/q-flow/references/types/class.md +9 -2
  76. package/skills/q-flow/references/types/dataflow.md +11 -4
  77. package/skills/q-flow/references/types/deployment.md +11 -3
  78. package/skills/q-flow/references/types/er.md +8 -1
  79. package/skills/q-flow/references/types/flowchart.md +12 -5
  80. package/skills/q-flow/references/types/sequence.md +20 -16
  81. package/skills/q-flow/references/types/state.md +10 -3
  82. package/skills/q-flow/references/types/usecase.md +6 -0
  83. package/skills/q-flow/references/viewer-development.md +37 -24
  84. package/skills/q-flow/references/visual-contract.md +12 -6
  85. package/skills/q-flow/scripts/compile-layout.mjs +85 -102
  86. package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
  87. package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
  88. package/skills/q-flow/scripts/validate-graph.mjs +38 -21
  89. 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
- return { operand, selected, childFrames, boxes, top: Math.min(...boxes.map(box => box.y)), bottom: Math.max(...boxes.map(box => box.y + box.height)) };
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(Number.isFinite(previous.bottom) && Number.isFinite(next.top) ? (previous.bottom + next.top) / 2 : headingBottom + (bottom - headingBottom) * i / sections.length);
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 { diagramTypeOf } from './diagrams/registry.js';
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 => diagramTypeOf(graph) === diagramTypeOf(currentGraph) ? currentGraph : drafts.get(diagramTypeOf(graph)) ?? 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
- export function cardTextLayout(node) {
83
- const width = node.size.width - 30;
84
- const title = layoutText(node.label, width, TYPOGRAPHY.title, 26);
85
- const subtitle = layoutText(node.subtitle ?? '', width, TYPOGRAPHY.body, 24);
86
- return { title, subtitle, minHeight: 65 + title.height + subtitle.height };
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: t[11], accentSoft: t[2], ringCore: t[5],
18
- data: b[11], warn: a[11], ringWarn: a[5], guard: g[11],
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: IDENTITY_SCALES.map(name => { const scale = IDENTITY[theme][name]; return { name, chip: scale[9], accent: scale[10], wash: mix(scale[9], theme === 'dark' ? n[3] : n[1], theme === 'dark' ? .09 : .05), header: mix(scale[9], n[2], theme === 'dark' ? .16 : .1) }; }),
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] : n[9],
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.moduleTones[pair.index % palette.moduleTones.length].accent : undefined;
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',