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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
168
|
-
const end =
|
|
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 :
|
|
287
|
-
const end = targetEndpoint ? targetEndpoint.point :
|
|
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 =
|
|
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')
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 >
|
|
222
|
-
return [`diagrams must contain between 1 and
|
|
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.
|
|
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
|
+
}
|