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
@@ -1,7 +1,10 @@
1
+ import { alignedLayout } from './layout-policy.js';
2
+ import { presentationGraph } from './presentation-graph.js';
1
3
  import { TYPOGRAPHY } from './visual-style.js';
2
- import { cardTextLayout, estimateLabelSize, edgeLabelLayout, groupHeadingLayout, labelRunsByLine } from './text-layout.js';
4
+ import { overviewSections, hasOverviewContent, overviewCardLayout } from './architecture-overview.js';
5
+ import { CARD, cardTextLayout, estimateLabelSize, edgeLabelLayout, groupHeadingLayout, labelRunsByLine, layoutText } from './text-layout.js';
3
6
  import { LAYOUT_LIMITS, LAYOUT_TARGETS } from './layout-spacing.js';
4
- import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
7
+ import { compactCards, diagramTypeOf, getDiagram } from './diagrams/registry.js';
5
8
  import { sequenceHeaderHeight } from './diagrams/sequence.js';
6
9
  import { actorTop } from './diagrams/usecase.js';
7
10
  import { stateSymbolX } from './diagrams/state.js';
@@ -41,7 +44,8 @@ export function cardinalityMarks(cardinality, point, neighbor) {
41
44
  }
42
45
 
43
46
  export function graphBounds(graph, routes = createEdgeRoutes(graph)) {
44
- const items = [...(graph.groups ?? []), ...graph.nodes, ...sequenceExecutions(graph).map(item => ({ position: { x: item.x, y: item.y }, size: { width: item.width, height: item.height } }))];
47
+ graph = presentationGraph(graph);
48
+ const items = [...overviewSections(graph), ...(graph.groups ?? []), ...graph.nodes, ...sequenceExecutions(graph).map(item => ({ position: { x: item.x, y: item.y }, size: { width: item.width, height: item.height } }))];
45
49
  const points = [...routes.values()].flatMap(route => [...route.points, { x: route.labelBox.x, y: route.labelBox.y }, { x: route.labelBox.x + route.labelBox.width, y: route.labelBox.y + route.labelBox.height }]);
46
50
  for (const route of routes.values()) for (const label of route.endpointLabels ?? []) points.push({ x: label.labelBox.x, y: label.labelBox.y }, { x: label.labelBox.x + label.labelBox.width, y: label.labelBox.y + label.labelBox.height });
47
51
  if (getDiagram(graph.meta?.diagramType).cardinalities) for (const edge of graph.edges) {
@@ -57,7 +61,7 @@ export function graphBounds(graph, routes = createEdgeRoutes(graph)) {
57
61
  return { x, y, width: right - x, height: bottom - y };
58
62
  }
59
63
 
60
- function anchor(node, side, offset = 0, type = 'architecture') {
64
+ export function nodeAnchor(node, side, offset = 0, type = 'architecture') {
61
65
  const custom = getDiagram(type)?.anchor;
62
66
  if (custom) return custom(node, side, offset);
63
67
  const middle = center(node);
@@ -140,8 +144,7 @@ function sidesFor(source, target) {
140
144
  }
141
145
 
142
146
  function waypointEndpoint(node, point, fallbackSide, type) {
143
- const { x, y } = node.position;
144
- const { width, height } = node.size;
147
+ const { x, y, width, height } = type === 'usecase' && node.kind === 'actor' ? routingBounds(node, type) : { ...node.position, ...node.size };
145
148
  const dx = Math.max(x - point.x, 0, point.x - x - width);
146
149
  const dy = Math.max(y - point.y, 0, point.y - y - height);
147
150
  const side = dx === 0 && dy === 0 ? fallbackSide
@@ -150,10 +153,19 @@ function waypointEndpoint(node, point, fallbackSide, type) {
150
153
  const middle = center(node);
151
154
  const limit = Math.max(0, (horizontal ? height : width) / 2 - LAYOUT_LIMITS.endpoint);
152
155
  const offset = Math.max(-limit, Math.min(limit, horizontal ? point.y - middle.y : point.x - middle.x));
153
- return { side, point: anchor(node, side, ['decision', 'choice'].includes(node.kind) ? 0 : offset, type) };
156
+ return { side, point: nodeAnchor(node, side, ['decision', 'choice'].includes(node.kind) ? 0 : offset, type) };
154
157
  }
155
158
 
156
159
  function routePointsWithWaypoints(start, end, sourceSide, targetSide, waypoints, stub = LAYOUT_LIMITS.endpoint) {
160
+ const horizontal = start.y === end.y, vertical = start.x === end.x;
161
+ const opposite = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' };
162
+ const direction = { left: -1, right: 1, top: -1, bottom: 1 };
163
+ const axis = horizontal ? 'x' : 'y', across = horizontal ? 'y' : 'x';
164
+ if ((horizontal && ['left', 'right'].includes(sourceSide) || vertical && ['top', 'bottom'].includes(sourceSide))
165
+ && opposite[sourceSide] === targetSide && (end[axis] - start[axis]) * direction[sourceSide] > 0
166
+ && waypoints.every((point, i) => point[across] === start[across]
167
+ && (point[axis] - (i ? waypoints[i - 1][axis] : start[axis])) * direction[sourceSide] >= 0
168
+ && (end[axis] - point[axis]) * direction[sourceSide] >= 0)) return [start, end];
157
169
  const points = [start, outward(start, sourceSide, stub)];
158
170
  // A shaped endpoint can sit inside its layout box. Turn at the real stub before joining its allocated channel.
159
171
  if (['left', 'right'].includes(sourceSide) && waypoints.length && points[1].y !== waypoints[0].y) points.push({ x: points[1].x, y: waypoints[0].y });
@@ -164,8 +176,8 @@ function routePointsWithWaypoints(start, end, sourceSide, targetSide, waypoints,
164
176
  }
165
177
 
166
178
  function routeBetween(source, target, sides, sourceOffset, targetOffset, stub = LAYOUT_LIMITS.endpoint, type = 'architecture') {
167
- const start = anchor(source, sides.sourceSide, sourceOffset, type);
168
- const end = anchor(target, sides.targetSide, targetOffset, type);
179
+ const start = nodeAnchor(source, sides.sourceSide, sourceOffset, type);
180
+ const end = nodeAnchor(target, sides.targetSide, targetOffset, type);
169
181
  const sourceStub = outward(start, sides.sourceSide, stub);
170
182
  const targetStub = outward(end, sides.targetSide, stub);
171
183
  const channelOffset = sourceOffset || targetOffset;
@@ -234,6 +246,7 @@ function routeSequenceEdge(edge, source, target, selfIndex, context) {
234
246
  }
235
247
 
236
248
  export function createEdgeRoutes(graph) {
249
+ graph = presentationGraph(graph);
237
250
  const type = diagramTypeOf(graph);
238
251
  const stub = getDiagram(type).endpointStub ?? LAYOUT_LIMITS.endpoint;
239
252
  const nodeById = new Map(graph.nodes.map(node => [node.id, node]));
@@ -283,8 +296,8 @@ export function createEdgeRoutes(graph) {
283
296
  const sourceEndpoint = waypoints?.length && waypointEndpoint(item.source, waypoints[0], 'right', type);
284
297
  const targetEndpoint = waypoints?.length && waypointEndpoint(item.target, waypoints.at(-1), 'right', type);
285
298
  const sourceSide = sourceEndpoint ? sourceEndpoint.side : 'right', targetSide = targetEndpoint ? targetEndpoint.side : 'right';
286
- const start = sourceEndpoint ? sourceEndpoint.point : anchor(item.source, 'right', -16 - selfIndex * 12, type);
287
- const end = targetEndpoint ? targetEndpoint.point : anchor(item.source, 'right', 16 + selfIndex * 12, type);
299
+ const start = sourceEndpoint ? sourceEndpoint.point : nodeAnchor(item.source, 'right', -16 - selfIndex * 12, type);
300
+ const end = targetEndpoint ? targetEndpoint.point : nodeAnchor(item.source, 'right', 16 + selfIndex * 12, type);
288
301
  route = {
289
302
  points: item.edge.route?.via?.length
290
303
  ? routePointsWithWaypoints(start, end, sourceSide, targetSide, item.edge.route.via, stub)
@@ -292,7 +305,7 @@ export function createEdgeRoutes(graph) {
292
305
  label,
293
306
  labelPoint: item.edge.route?.labelAt ?? { x: right + extent + 8 + labelSize.width / 2, y: middleY },
294
307
  sourceSide, targetSide,
295
- curved: Boolean(getDiagram(type).curvedSelfLoops)
308
+ curved: Boolean(getDiagram(type).curvedSelfLoops) && !alignedLayout(graph)
296
309
  };
297
310
  } else {
298
311
  const waypoints = item.edge.route?.via;
@@ -344,7 +357,7 @@ function pointOnNodeSide(point, node, side, type) {
344
357
  if ((side === 'top' || side === 'bottom') && (point.x < bounds.x || point.x > bounds.x + bounds.width)) return false;
345
358
  const middle = center(node);
346
359
  const offset = side === 'left' || side === 'right' ? point.y - middle.y : point.x - middle.x;
347
- const expected = anchor(node, side, offset, type);
360
+ const expected = nodeAnchor(node, side, offset, type);
348
361
  return Math.hypot(point.x - expected.x, point.y - expected.y) < 1e-7;
349
362
  }
350
363
 
@@ -360,7 +373,7 @@ export function segmentCrossesBox(start, end, box) {
360
373
  return false;
361
374
  }
362
375
 
363
- function routingBounds(node, type) {
376
+ export function routingBounds(node, type) {
364
377
  if (type === 'state' && ['initial', 'final'].includes(node.kind)) {
365
378
  const radius = node.kind === 'initial' ? 12 : 13, middle = center(node);
366
379
  return { x: node.position.x + stateSymbolX(node) - radius, y: middle.y - radius, width: radius * 2, height: radius * 2 };
@@ -374,31 +387,37 @@ function routingBounds(node, type) {
374
387
  return occupiedBox(node, type);
375
388
  }
376
389
 
377
- function segmentCrossesNode(start, end, node, type) {
390
+ export function segmentCrossesNode(start, end, node, type) {
378
391
  if (type === 'state' && ['initial', 'final'].includes(node.kind) && node.subtitle) {
379
392
  const area = getDiagram(type).textArea(node);
380
393
  if (segmentCrossesBox(start, end, { ...area, x: node.position.x + area.x, y: node.position.y + area.y })) return true;
381
394
  }
382
395
  const bounds = routingBounds(node, type);
383
396
  if (type === 'sequence') return segmentCrossesBox(start, end, bounds);
384
- if (type === 'usecase' && node.kind === 'actor') return segmentCrossesBox(start, end, bounds);
397
+ if (type === 'usecase' && node.kind === 'actor') {
398
+ const cy = node.position.y + actorTop(node), cx = center(node).x;
399
+ const labelWidth = Math.min(node.size.width, layoutText(node.label, Infinity, 20).width * 1.15);
400
+ const subtitleWidth = node.subtitle ? Math.min(node.size.width, layoutText(node.subtitle, Infinity, 16).width * 1.15) : 0;
401
+ return segmentCrossesBox(start, end, bounds) || segmentCrossesBox(start, end, { x: cx - labelWidth / 2, y: cy + 78, width: labelWidth, height: 26 })
402
+ || subtitleWidth > 0 && segmentCrossesBox(start, end, { x: cx - subtitleWidth / 2, y: cy + 106, width: subtitleWidth, height: 24 });
403
+ }
385
404
  const middle = center(node);
386
405
  if (start.y === end.y) {
387
406
  if (start.y <= bounds.y || start.y >= bounds.y + bounds.height) return false;
388
407
  const offset = start.y - middle.y;
389
- const left = anchor(node, 'left', offset, type).x, right = anchor(node, 'right', offset, type).x;
408
+ const left = nodeAnchor(node, 'left', offset, type).x, right = nodeAnchor(node, 'right', offset, type).x;
390
409
  return Math.max(Math.min(start.x, end.x), left) < Math.min(Math.max(start.x, end.x), right) - 1e-7;
391
410
  }
392
411
  if (start.x === end.x) {
393
412
  if (start.x <= bounds.x || start.x >= bounds.x + bounds.width) return false;
394
413
  const offset = start.x - middle.x;
395
- const top = anchor(node, 'top', offset, type).y, bottom = anchor(node, 'bottom', offset, type).y;
414
+ const top = nodeAnchor(node, 'top', offset, type).y, bottom = nodeAnchor(node, 'bottom', offset, type).y;
396
415
  return Math.max(Math.min(start.y, end.y), top) < Math.min(Math.max(start.y, end.y), bottom) - 1e-7;
397
416
  }
398
417
  return false;
399
418
  }
400
419
 
401
- function groupBorders(group) {
420
+ export function groupBorders(group) {
402
421
  const left = group.position.x;
403
422
  const right = left + group.size.width;
404
423
  const top = group.position.y;
@@ -421,7 +440,7 @@ export function groupHeadingBoxes(group) {
421
440
  return boxes;
422
441
  }
423
442
 
424
- function sharedSegmentLength(firstStart, firstEnd, secondStart, secondEnd) {
443
+ export function sharedSegmentLength(firstStart, firstEnd, secondStart, secondEnd) {
425
444
  if (firstStart.y === firstEnd.y && secondStart.y === secondEnd.y && firstStart.y === secondStart.y) {
426
445
  return Math.max(0, Math.min(Math.max(firstStart.x, firstEnd.x), Math.max(secondStart.x, secondEnd.x))
427
446
  - Math.max(Math.min(firstStart.x, firstEnd.x), Math.min(secondStart.x, secondEnd.x)));
@@ -451,6 +470,7 @@ export function routeCrossings(first, firstPoints, second, secondPoints) {
451
470
  }
452
471
 
453
472
  export function auditGraphLayout(graph) {
473
+ graph = presentationGraph(graph);
454
474
  const type = diagramTypeOf(graph);
455
475
  const crossings = [];
456
476
  const routes = createEdgeRoutes(graph);
@@ -484,9 +504,10 @@ export function auditGraphLayout(graph) {
484
504
  if (route.points.some(p => p.y > Math.min(...[edge.source, edge.target].map(id => { const n = graph.nodes.find(n => n.id === id); return n.position.y + n.size.height; })))) fail('sequence.lifeline', [edge.id, edge.source, edge.target], `layout: sequence edge ${edge.id} exceeds its participant lifeline`);
485
505
  }
486
506
  }
507
+ const classicCard = getDiagram(type).cardLayout && !compactCards(graph);
487
508
  if (getDiagram(type).cardLayout) for (const node of graph.nodes) {
488
- if (node.size.height < 100) continue;
489
- const { minHeight } = cardTextLayout(node);
509
+ if (node.size.height < (classicCard ? 100 : CARD.minHeight)) continue;
510
+ const { minHeight } = type === 'architecture' && hasOverviewContent(node) ? overviewCardLayout(node) : cardTextLayout(node, graph.meta.locale, classicCard);
490
511
  if (node.size.height < minHeight) fail('text.card-height', [node.id], `layout: node ${node.id} text needs at least ${minHeight}px height at 20/16px; enlarge the node and check its route clearance`);
491
512
  }
492
513
  for (let left = 0; left < graph.nodes.length; left += 1) {
@@ -1,6 +1,8 @@
1
+ import { presentationGraph } from './presentation-graph.js';
2
+ import { overviewSections, sectionSvg } from './architecture-overview.js';
1
3
  import { createEdgeRoutes, graphBounds, pathFromRoute, cardinalityMarks } from './edge-routing.js';
2
4
  import { layoutText } from './text-layout.js';
3
- import { DIAGRAM_TYPES, diagramTypeOf, getDiagram, edgeMarkers, isDashed, labelRoleColors } from './diagrams/registry.js';
5
+ import { DIAGRAM_TYPES, compactCards, diagramTypeOf, getDiagram, edgeMarkers, isDashed, labelRoleColors } from './diagrams/registry.js';
4
6
  import { renderNode } from './node-svg.js';
5
7
  import { text, fit, escapeXml, svgStyles, groupHeadingSvg, groupFrameSvg } from './diagrams/drawing.js';
6
8
  import { PALETTES, dataKinds, edgeColor, sequenceGroupColor, moduleColorMap, groupAppearanceMap, TYPOGRAPHY } from './visual-style.js';
@@ -8,6 +10,7 @@ import { RADIX_COLORS_NOTICE } from './radix-colors.js';
8
10
  import { sequencePairs, sequenceExecutions } from './sequence-executions.js';
9
11
  import { sequenceFragment, renderFragment, fragmentDepth, fragmentSurfaceAt } from './sequence-fragments.js';
10
12
  import { requireDiagramQuality } from './layout-quality.js';
13
+ import { translate } from './i18n.js';
11
14
 
12
15
  function edgeMarker(edge, type, target, moduleColors, source, pair) {
13
16
  const { end } = edgeMarkers(edge, type);
@@ -46,8 +49,23 @@ function renderEdge(edge, route, type, offsetX, offsetY, palette, target, module
46
49
  return `<g data-diagram-edge-id="${escapeXml(edge.id)}"><path d="${pathFromRoute(route, offsetX, offsetY)}" fill="none" stroke="${stroke}" stroke-width="1.5"${dash}${edgeMarker(edge, type, target, moduleColors, source, pair)}${edgeStartMarker(edge, type)}/>${cardinalities}${background}${pairLine}${label ? route.labelLines.map((line, index) => text(labelX, labelY - labelSize.height / 2 + 3 + TYPOGRAPHY.body + index * TYPOGRAPHY.edgeLineHeight, route.labelRuns ? { runs: route.labelRuns[index], colors: labelRoleColors(type, palette) } : line, 'edge', ' text-anchor="middle"')).join('') : ''}${multiplicities}</g>`;
47
50
  }
48
51
 
52
+ // meta.notes sit under the board, on the paper: a heading, then one bulleted paragraph per note. Same text the Viewer's
53
+ // "Key points" card shows, so a diagram embedded in a README carries its findings.
54
+ const NOTE = { indent: 20, line: 24, gap: 6, top: 12, afterHeading: 14, bottom: 18 };
55
+ function notesBlock(notes, locale, left, top, width) {
56
+ const layouts = notes.map(note => layoutText(note, width - NOTE.indent, TYPOGRAPHY.body, NOTE.line));
57
+ let y = top + NOTE.top + NOTE.afterHeading;
58
+ const items = layouts.map(({ lines }) => {
59
+ const first = y + 17, markup = text(left, first, '•', 'body') + lines.map((line, i) => text(left + NOTE.indent, first + i * NOTE.line, line, 'body')).join('');
60
+ y += lines.length * NOTE.line + NOTE.gap;
61
+ return markup;
62
+ }).join('');
63
+ return { height: y - top + NOTE.bottom, svg: `<g data-notes="${notes.length}">${text(left, top + NOTE.top, translate(locale, 'Key points'), 'group')}${items}</g>` };
64
+ }
65
+
49
66
  export function createDiagramSvg(graph, theme = 'light', moduleColors) {
50
67
  requireDiagramQuality(graph);
68
+ graph = presentationGraph(graph);
51
69
  const palette = PALETTES[theme] ?? PALETTES.light;
52
70
  moduleColors ??= moduleColorMap([graph], palette);
53
71
  const groupAppearances = groupAppearanceMap(graph.groups ?? [], palette);
@@ -62,21 +80,25 @@ export function createDiagramSvg(graph, theme = 'light', moduleColors) {
62
80
  const subtitleLayout = layoutText(graph.meta.subtitle ?? '', width - margin * 2, TYPOGRAPHY.small);
63
81
  const subtitleY = 66 + Math.max(0, titleLayout.lines.length - 1) * titleLayout.lineHeight;
64
82
  const header = Math.max(88, subtitleLayout.lines.length ? subtitleY + (subtitleLayout.lines.length - 1) * subtitleLayout.lineHeight + 22 : 43 + Math.max(0, titleLayout.lines.length - 1) * titleLayout.lineHeight + 24);
65
- const height = bounds.height + margin * 2 + header;
83
+ const boardBottom = bounds.height + margin * 2 + header;
84
+ const notes = graph.meta.notes?.length ? notesBlock(graph.meta.notes, graph.meta.locale, margin, boardBottom, width - margin * 2) : { height: 0, svg: '' };
85
+ const height = boardBottom + notes.height;
66
86
  const offsetX = margin - bounds.x;
67
87
  const offsetY = margin + header - bounds.y;
68
88
  const fragments = new Map((graph.groups ?? []).map(group => [group.id, type === 'sequence' ? sequenceFragment(group, routes, graph.meta.locale, graph.groups ?? [], executions) : null]));
69
89
  const groups = [...(graph.groups ?? [])].sort((a, b) => fragmentDepth(a, graph.groups ?? []) - fragmentDepth(b, graph.groups ?? [])).map(group => renderGroup(group, offsetX, offsetY, fragments.get(group.id), groupAppearances.get(group.id)) + (type === 'sequence' ? renderFragment({ ...fragments.get(group.id), guards: [], bodies: [] }, group, offsetX, offsetY, palette.ink3, groupAppearances.get(group.id).fill) : '')).join('');
90
+ const sections = overviewSections(graph).map(section => sectionSvg(section, palette, offsetX, offsetY)).join('');
70
91
  const fragmentText = type === 'sequence' ? (graph.groups ?? []).map(group => renderFragment({ ...fragments.get(group.id), separators: [] }, group, offsetX, offsetY, palette.ink3, groupAppearances.get(group.id).fill)).join('') : '';
71
92
 
72
93
  const edges = graph.edges.map(edge => renderEdge(edge, routes.get(edge.id), type, offsetX, offsetY, palette, nodeById.get(edge.target), moduleColors, nodeById.get(edge.source), pairs.get(edge.id), type === 'sequence' && routes.get(edge.id).label ? fragmentSurfaceAt(routes.get(edge.id).labelBox, graph.groups ?? [], groupAppearances) : undefined)).join('');
73
- const nodes = graph.nodes.map(node => renderNode({ ...node, executionRects: executions.filter(item => item.participantId === node.id).map(item => ({ ...item, x: item.x - node.position.x, y: item.y - node.position.y, color: sequenceGroupColor(pairs.get(item.start.edgeId), palette) ?? palette.edge })) }, type, offsetX, offsetY, palette, graph.meta.locale, moduleColors)).join('');
94
+ const classicCard = !compactCards(graph);
95
+ const nodes = graph.nodes.map(node => renderNode({ ...node, classicCard, executionRects: executions.filter(item => item.participantId === node.id).map(item => ({ ...item, x: item.x - node.position.x, y: item.y - node.position.y, color: sequenceGroupColor(pairs.get(item.start.edgeId), palette) ?? palette.edge })) }, type, offsetX, offsetY, palette, graph.meta.locale, moduleColors)).join('');
74
96
  const boardX = 24;
75
97
  const boardY = header;
76
98
  const boardWidth = width - 48;
77
- const boardHeight = height - header - 24;
99
+ const boardHeight = boardBottom - header - 24;
78
100
 
79
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!-- Radix Colors\n${RADIX_COLORS_NOTICE}-->\n<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" data-graph-offset-x="${offsetX}" data-graph-offset-y="${offsetY}" role="img" aria-labelledby="title desc"><title id="title">${escapeXml(graph.meta.title)}</title><desc id="desc">${escapeXml(type)} diagram for ${escapeXml(graph.meta.sourceRef)}</desc><defs><filter id="node-shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="5" stdDeviation="7" flood-color="${palette.ink}" flood-opacity=".045"/></filter><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.edge}"/></marker><marker id="arrow-module" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="context-stroke"/></marker><marker id="arrow-warn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.warn}"/></marker><marker id="arrow-ok" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.accent}"/></marker><marker id="arrow-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.data}"/></marker><marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M1 1L9 5L1 9" fill="none" stroke="context-stroke" stroke-width="1.5"/></marker><marker id="triangle" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto"><path d="M1 1L11 6L1 11Z" fill="${palette.surface}" stroke="context-stroke"/></marker><marker id="diamond-filled" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="context-stroke"/></marker><marker id="diamond-open" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="${palette.surface}" stroke="context-stroke"/></marker><style>${svgStyles(palette)}</style></defs><rect width="100%" height="100%" fill="${palette.paper}"/><rect x="${boardX}" y="${boardY}" width="${boardWidth}" height="${boardHeight}" rx="16" fill="${palette.surface}" stroke="${palette.rule}"/>${titleLayout.lines.map((line, index) => text(margin, 43 + index * titleLayout.lineHeight, line, 'heading')).join('')}${subtitleLayout.lines.map((line, index) => text(margin, subtitleY + index * subtitleLayout.lineHeight, line, 'meta')).join('')}${groups}${type === 'sequence' ? nodes + edges : edges + nodes}${fragmentText}</svg>\n`;
101
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!-- Radix Colors\n${RADIX_COLORS_NOTICE}-->\n<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" data-graph-offset-x="${offsetX}" data-graph-offset-y="${offsetY}" role="img" aria-labelledby="title desc"><title id="title">${escapeXml(graph.meta.title)}</title><desc id="desc">${escapeXml(type)} diagram for ${escapeXml(graph.meta.sourceRef)}</desc><defs><filter id="node-shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="5" stdDeviation="7" flood-color="${palette.ink}" flood-opacity=".045"/></filter><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.edge}"/></marker><marker id="arrow-module" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="context-stroke"/></marker><marker id="arrow-warn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.warn}"/></marker><marker id="arrow-ok" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.accent}"/></marker><marker id="arrow-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.data}"/></marker><marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M1 1L9 5L1 9" fill="none" stroke="context-stroke" stroke-width="1.5"/></marker><marker id="triangle" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto"><path d="M1 1L11 6L1 11Z" fill="${palette.surface}" stroke="context-stroke"/></marker><marker id="diamond-filled" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="context-stroke"/></marker><marker id="diamond-open" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="${palette.surface}" stroke="context-stroke"/></marker><style>${svgStyles(palette)}</style></defs><rect width="100%" height="100%" fill="${palette.paper}"/><rect x="${boardX}" y="${boardY}" width="${boardWidth}" height="${boardHeight}" rx="16" fill="${palette.surface}" stroke="${palette.rule}"/>${titleLayout.lines.map((line, index) => text(margin, 43 + index * titleLayout.lineHeight, line, 'heading')).join('')}${subtitleLayout.lines.map((line, index) => text(margin, subtitleY + index * subtitleLayout.lineHeight, line, 'meta')).join('')}${sections}${groups}${type === 'sequence' ? nodes + edges : edges + nodes}${fragmentText}${notes.svg}</svg>\n`;
80
102
  }
81
103
 
82
104
  // The generator and the Viewer's in-place save both write these files, so names and bytes come from one place: light
@@ -1,10 +1,16 @@
1
+ import { isArchitectureOverview } from './view-identity.js';
2
+ import { DEPLOYMENT_TIERS } from './layout-semantics.js';
1
3
  import { SUPPORTED_LOCALES } from './i18n.js';
2
- import { auditGraphLayout } from './edge-routing.js';
4
+ import { auditGraphLayout, graphBounds } from './edge-routing.js';
5
+ import { MAX_SCREENS, OVERVIEW_AREA, READABLE_ZOOM, layeredDirections } from './layout-spacing.js';
3
6
  import { missingCallExecutions, validateExecutions } from './sequence-executions.js';
4
7
  import { validateOperands } from './sequence-fragments.js';
8
+ import { viewIdOf } from './view-identity.js';
9
+ import { validateOverview } from './architecture-overview.js';
5
10
  import { DIAGRAM_TYPES, diagramTypeOf, getDiagram } from './diagrams/registry.js';
6
11
 
7
12
  const EVIDENCE_KINDS = new Set(['source', 'code', 'config', 'schema', 'test', 'document', 'framework', 'inference']);
13
+ export const MAX_NOTES = 6, MAX_NOTE_LENGTH = 120;
8
14
  const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
15
 
10
16
  function requireString(value, label, errors) {
@@ -15,6 +21,16 @@ function optionalString(value, label, errors) {
15
21
  if (value !== undefined && typeof value !== 'string') errors.push(`${label} must be a string`);
16
22
  }
17
23
 
24
+ // A node `source` and an edge `site` share one anchor shape, so one check and one --repo-root verification serve both.
25
+ function validateAnchor(anchor, label, errors) {
26
+ requireString(anchor.file, `${label}.file`, errors);
27
+ optionalString(anchor.symbol, `${label}.symbol`, errors);
28
+ if (!Number.isInteger(anchor.lineStart) || anchor.lineStart < 1) errors.push(`${label}.lineStart must be a positive integer`);
29
+ if (anchor.lineEnd !== undefined && (!Number.isInteger(anchor.lineEnd) || (Number.isInteger(anchor.lineStart) && anchor.lineEnd < anchor.lineStart))) {
30
+ errors.push(`${label}.lineEnd must be an integer at or after lineStart`);
31
+ }
32
+ }
33
+
18
34
  function requireBox(item, label, errors, inputOnly = false) {
19
35
  for (const [group, keys] of [['position', ['x', 'y']], ['size', ['width', 'height']]]) {
20
36
  if (inputOnly && item[group] === undefined) continue;
@@ -46,6 +62,14 @@ function validateStringArray(value, label, errors) {
46
62
  }
47
63
  }
48
64
 
65
+ // meta.notes: the findings a reader must see before opening any node; the Viewer and the SVG both draw them.
66
+ function validateNotes(notes, errors) {
67
+ validateStringArray(notes, 'meta.notes', errors);
68
+ if (!Array.isArray(notes)) return;
69
+ if (notes.length > MAX_NOTES) errors.push(`meta.notes must have at most ${MAX_NOTES} items`);
70
+ notes.forEach((note, index) => { if (typeof note === 'string' && [...note].length > MAX_NOTE_LENGTH) errors.push(`meta.notes[${index}] exceeds ${MAX_NOTE_LENGTH} characters`); });
71
+ }
72
+
49
73
  export function graphsOf(input) {
50
74
  return input && typeof input === 'object' && !Array.isArray(input) && Array.isArray(input.diagrams)
51
75
  ? input.diagrams
@@ -59,7 +83,10 @@ export function validateGraph(graph, { inputOnly = false, audit = true } = {}) {
59
83
  if (!isObject(graph.meta)) errors.push('meta must be an object');
60
84
  requireString(graph.meta?.title, 'meta.title', errors);
61
85
  requireString(graph.meta?.sourceRef, 'meta.sourceRef', errors);
86
+ if (graph.meta?.viewId !== undefined) requireString(graph.meta.viewId, 'meta.viewId', errors);
87
+ if (graph.meta?.architectureView !== undefined && ((graph.meta.diagramType ?? 'architecture') !== 'architecture' || !['relations', 'capabilities', 'engineering'].includes(graph.meta.architectureView))) errors.push('meta.architectureView is unsupported');
62
88
  for (const key of ['subtitle', 'scope']) optionalString(graph.meta?.[key], `meta.${key}`, errors);
89
+ validateNotes(graph.meta?.notes, errors);
63
90
  if (graph.meta?.locale !== undefined && !SUPPORTED_LOCALES.includes(graph.meta.locale)) errors.push('meta.locale is unsupported');
64
91
  const diagramType = diagramTypeOf(graph);
65
92
  if (!DIAGRAM_TYPES.includes(diagramType)) errors.push('meta.diagramType is unsupported');
@@ -85,13 +112,8 @@ export function validateGraph(graph, { inputOnly = false, audit = true } = {}) {
85
112
  nodeIds.add(node?.id);
86
113
  if (node.source !== undefined && !isObject(node.source)) errors.push(`${label}.source must be an object`);
87
114
  else if (node.source) {
88
- requireString(node.source.file, `${label}.source.file`, errors);
89
- optionalString(node.source.symbol, `${label}.source.symbol`, errors);
115
+ validateAnchor(node.source, `${label}.source`, errors);
90
116
  if (node.source.kind !== undefined && !EVIDENCE_KINDS.has(node.source.kind)) errors.push(`${label}.source.kind is unsupported`);
91
- if (!Number.isInteger(node.source.lineStart) || node.source.lineStart < 1) errors.push(`${label}.source.lineStart must be a positive integer`);
92
- if (node.source.lineEnd !== undefined && (!Number.isInteger(node.source.lineEnd) || (Number.isInteger(node.source.lineStart) && node.source.lineEnd < node.source.lineStart))) {
93
- errors.push(`${label}.source.lineEnd must be an integer at or after lineStart`);
94
- }
95
117
  }
96
118
  for (const key of ['facts', 'tags', 'attributes', 'methods']) validateStringArray(node[key], `${label}.${key}`, errors);
97
119
  if (node.fields !== undefined) {
@@ -131,6 +153,8 @@ export function validateGraph(graph, { inputOnly = false, audit = true } = {}) {
131
153
  requireString(edge?.target, `${label}.target`, errors);
132
154
  if (!rules.edgeKinds.includes(edge?.kind)) errors.push(`${label}.kind is unsupported for ${diagramType}`);
133
155
  if (!EVIDENCE_KINDS.has(edge?.evidence)) errors.push(`${label}.evidence is unsupported`);
156
+ if (edge.site !== undefined && !isObject(edge.site)) errors.push(`${label}.site must be an object`);
157
+ else if (edge.site) validateAnchor(edge.site, `${label}.site`, errors);
134
158
  if (typeof edge.id === 'string' && edgeIds.has(edge.id)) errors.push(`${label}.id duplicates ${edge.id}`);
135
159
  edgeIds.add(edge?.id);
136
160
  if (typeof edge.source === 'string' && !nodeIds.has(edge.source)) errors.push(`${label}.source does not name a node: ${edge.source}`);
@@ -150,6 +174,7 @@ export function validateGraph(graph, { inputOnly = false, audit = true } = {}) {
150
174
  if (diagramType === 'sequence' && !inputOnly && edge.route?.messageY === undefined) errors.push(`${label}.route.messageY is required for a positioned sequence message; regenerate with --layout auto`);
151
175
  rules.validateEdge?.(edge, label, errors, { requireString, sequenceOrders });
152
176
  }
177
+ if (errors.length === 0) errors.push(...validateOverview(graph, { inputOnly, validateAnchor, evidenceKinds: EVIDENCE_KINDS }));
153
178
  if (errors.length === 0) errors.push(...validateLayoutSemantics(graph));
154
179
  if (errors.length === 0) errors.push(...validateOperands(graph));
155
180
  if (errors.length === 0) errors.push(...validateExecutions(graph, { inputOnly }));
@@ -168,7 +193,8 @@ function validateLayoutSemantics(graph) {
168
193
  }
169
194
  if (node.layout !== undefined) {
170
195
  if (!isObject(node.layout)) errors.push(`node ${node.id}.layout must be an object`);
171
- else for (const key of ['rank', 'order']) if (node.layout[key] !== undefined && (!Number.isInteger(node.layout[key]) || node.layout[key] < 0)) errors.push(`node ${node.id}.layout.${key} must be a non-negative integer`);
196
+ else if (node.layout.tier !== undefined && (type !== 'deployment' || !DEPLOYMENT_TIERS.includes(node.layout.tier))) errors.push(`node ${node.id}.layout.tier requires deployment and one of ${DEPLOYMENT_TIERS.join(', ')}`);
197
+ if (isObject(node.layout)) for (const key of ['rank', 'order']) if (node.layout[key] !== undefined && (!Number.isInteger(node.layout[key]) || node.layout[key] < 0)) errors.push(`node ${node.id}.layout.${key} must be a non-negative integer`);
172
198
  }
173
199
  }
174
200
  if (!sequence) for (const group of groups.values()) {
@@ -181,7 +207,9 @@ function validateLayoutSemantics(graph) {
181
207
  }
182
208
  }
183
209
  if (graph.layout !== undefined && !isObject(graph.layout)) errors.push('layout must be an object');
184
- else for (const key of ['primaryPath', 'participantOrder']) {
210
+ else if (graph.layout?.direction !== undefined && !layeredDirections(type).includes(graph.layout.direction)) errors.push(`layout.direction must be one of ${layeredDirections(type).join(', ')} for ${type}`);
211
+ if (graph.layout?.overviewConnections !== undefined && (!isArchitectureOverview(graph) || !['within-category', 'all'].includes(graph.layout.overviewConnections))) errors.push('layout.overviewConnections requires an architecture overview and within-category or all');
212
+ if (isObject(graph.layout)) for (const key of ['primaryPath', 'participantOrder']) {
185
213
  const list = graph.layout?.[key];
186
214
  if (list === undefined) continue;
187
215
  if (!Array.isArray(list) || !list.length || list.some(id => typeof id !== 'string' || !nodes.has(id)) || new Set(list).size !== list.length) {
@@ -218,15 +246,20 @@ export function validateGraphInput(input, options = {}) {
218
246
  if (!input || typeof input !== 'object' || Array.isArray(input)) return ['graph must be an object'];
219
247
  const authored = graph => { const errors = validateGraph(graph, options); return errors.length ? errors : missingCallExecutions(graph); };
220
248
  if (!Object.hasOwn(input, 'diagrams')) return authored(input);
221
- if (!Array.isArray(input.diagrams) || input.diagrams.length < 1 || input.diagrams.length > DIAGRAM_TYPES.length) {
222
- return [`diagrams must contain between 1 and ${DIAGRAM_TYPES.length} graphs`];
249
+ if (!Array.isArray(input.diagrams) || input.diagrams.length < 1 || input.diagrams.length > 32) {
250
+ return [`diagrams must contain between 1 and 32 graphs`];
223
251
  }
224
252
 
225
253
  const errors = [];
226
- const types = new Set();
254
+ const types = new Set(), viewIds = new Set();
255
+ const architectureCount = input.diagrams.filter(graph => diagramTypeOf(graph) === 'architecture').length;
227
256
  input.diagrams.forEach((graph, index) => {
228
257
  const type = diagramTypeOf(graph);
229
- if (types.has(type)) errors.push(`diagrams[${index}].meta.diagramType duplicates ${type}`);
258
+ if (types.has(type) && type !== 'architecture') errors.push(`diagrams[${index}].meta.diagramType duplicates ${type}`);
259
+ if (type === 'architecture' && architectureCount > 1 && !graph?.meta?.viewId) errors.push(`diagrams[${index}].meta.viewId is required for repeated architecture views`);
260
+ const viewId = viewIdOf(graph);
261
+ if (viewIds.has(viewId)) errors.push(`diagrams[${index}].meta.viewId duplicates ${viewId}`);
262
+ viewIds.add(viewId);
230
263
  types.add(type);
231
264
  errors.push(...authored(graph).map(error => `diagrams[${index}].${error}`));
232
265
  });
@@ -240,6 +273,9 @@ export function validateGraphInput(input, options = {}) {
240
273
  const PLAIN_KINDS = new Set(['initial', 'final']);
241
274
  const OUTSIDER_KINDS = new Set(['external', 'actor', 'device']);
242
275
  const moduleOf = item => (typeof item?.module === 'string' && item.module.trim() ? item.module : undefined);
276
+ // A relationship backed by repository material records the line that makes it hold; a sequence return follows its call.
277
+ const SITE_EVIDENCE = new Set(['source', 'code', 'config', 'schema', 'test']);
278
+ export const needsSite = edge => SITE_EVIDENCE.has(edge.evidence) && edge.kind !== 'return';
243
279
 
244
280
  export function reviewComposition(input) {
245
281
  if (validateGraphInput(input, { inputOnly: true }).length) return [];
@@ -249,7 +285,7 @@ export function reviewComposition(input) {
249
285
  // The same label across views is the same component: its module must be present everywhere and be the same one.
250
286
  const byLabel = new Map();
251
287
  for (const graph of graphs) for (const node of graph.nodes) if (typeof node.label === 'string' && !PLAIN_KINDS.has(node.kind)) {
252
- byLabel.set(node.label, [...(byLabel.get(node.label) ?? []), { diagramType: diagramTypeOf(graph), id: node.id, module: moduleOf(node) }]);
288
+ byLabel.set(node.label, [...(byLabel.get(node.label) ?? []), { diagramType: diagramTypeOf(graph), viewId: viewIdOf(graph), id: node.id, module: moduleOf(node) }]);
253
289
  }
254
290
  for (const graph of graphs) {
255
291
  const diagramType = diagramTypeOf(graph);
@@ -260,7 +296,7 @@ export function reviewComposition(input) {
260
296
  `${plain.length === 1 ? 'node' : 'nodes'} ${plain.map(node => node.id).join(', ')} ${plain.length === 1 ? 'has' : 'have'} no module and render${plain.length === 1 ? 's' : ''} on the plain surface without identity`,
261
297
  'Give every ordinary node the module of the subsystem whose work it performs (a step: the subsystem that does the work; an external hub or broker: its channel); leave only true outsiders plain.');
262
298
  for (const node of graph.nodes) {
263
- const elsewhere = (byLabel.get(node.label) ?? []).filter(item => item.diagramType !== diagramType && item.module && item.module !== moduleOf(node));
299
+ const elsewhere = (byLabel.get(node.label) ?? []).filter(item => item.viewId !== viewIdOf(graph) && item.module && item.module !== moduleOf(node));
264
300
  if (!elsewhere.length || PLAIN_KINDS.has(node.kind)) continue;
265
301
  const other = elsewhere[0];
266
302
  warn('module.inconsistent', [node.id], `node ${node.id} "${node.label}" ${moduleOf(node) ? `carries module "${moduleOf(node)}"` : 'has no module'} here but "${other.module}" in ${other.diagramType}`,
@@ -273,6 +309,11 @@ export function reviewComposition(input) {
273
309
  `${diagramType} gives all ${washed.length} nodes the module "${moduleOf(washed[0])}", so the wash tells the reader nothing`,
274
310
  'Check whether the steps really are one subsystem\'s work: a step performed by another subsystem (a store, a cache layer, a queue) takes that subsystem\'s module, start and end take the caller. If everything truly belongs to one subsystem, leave it.');
275
311
  }
312
+ // Advisory only once the graph has started recording sites, so a graph authored without them stays quiet.
313
+ const sited = graph.edges.filter(edge => edge.site), unsited = graph.edges.filter(edge => needsSite(edge) && !edge.site);
314
+ if (sited.length && unsited.length) warn('edge.site-missing', unsited.map(edge => edge.id),
315
+ `${unsited.length === 1 ? 'edge' : 'edges'} ${unsited.map(edge => edge.id).join(', ')} ${unsited.length === 1 ? 'has' : 'have'} repository evidence but no site, while ${sited.length} other ${sited.length === 1 ? 'edge records' : 'edges record'} one`,
316
+ 'Add the line that makes the relationship hold (the call, write, foreign key or extends clause) as site, or mark the edge inference.');
276
317
  if (diagramType === 'flowchart') {
277
318
  const outgoing = new Map();
278
319
  for (const edge of graph.edges) outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1);
@@ -284,3 +325,19 @@ export function reviewComposition(input) {
284
325
  }
285
326
  return warnings;
286
327
  }
328
+
329
+ // Advisory that needs laid-out geometry, so a graph without positions reports nothing: at the readable zoom a reader sees
330
+ // one OVERVIEW_AREA at a time, and a view that needs more than MAX_SCREENS of them is read by scrolling, not by looking.
331
+ export function overviewWarnings(input) {
332
+ if (validateGraphInput(input, { inputOnly: true }).length) return [];
333
+ const warnings = [];
334
+ for (const graph of graphsOf(input)) {
335
+ if (!graph.nodes.length || !graph.nodes.every(node => node.position && node.size)) continue;
336
+ const { width, height } = graphBounds(graph), across = width * READABLE_ZOOM / OVERVIEW_AREA.width, down = height * READABLE_ZOOM / OVERVIEW_AREA.height;
337
+ if (across * down <= MAX_SCREENS) continue;
338
+ warnings.push({ ruleId: 'view.oversized', severity: 'warning', diagramType: diagramTypeOf(graph), elementIds: [],
339
+ message: `the view spans ${Math.round(width)}×${Math.round(height)} units, about ${(across * down).toFixed(1)} screens (${across.toFixed(1)} wide × ${down.toFixed(1)} tall) at the readable zoom ${READABLE_ZOOM}`,
340
+ remediation: 'Split it by phase or sub-flow into separate views that together cover the whole model, never dropping facts; keep one view only when the user asked for it.' });
341
+ }
342
+ return warnings;
343
+ }