@laitszkin/apollo-toolkit 3.11.2 → 3.11.4
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/CHANGELOG.md +32 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/generate-spec/SKILL.md +18 -16
- package/generate-spec/agents/openai.yaml +4 -1
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/init-project-html/SKILL.md +68 -89
- package/init-project-html/agents/openai.yaml +7 -18
- package/init-project-html/lib/atlas/assets/architecture.css +710 -107
- package/init-project-html/lib/atlas/assets/viewer.client.js +10 -2
- package/init-project-html/lib/atlas/layout.js +225 -55
- package/init-project-html/lib/atlas/render.js +64 -11
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/package.json +1 -1
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/spec-to-project-html/SKILL.md +38 -58
- package/spec-to-project-html/agents/openai.yaml +7 -14
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
|
@@ -69,10 +69,18 @@
|
|
|
69
69
|
return { x: state.x + xRatio * state.w, y: state.y + yRatio * state.h };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// The diagram viewport owns the wheel gesture entirely: ANY wheel
|
|
73
|
+
// event that lands inside the viewport is consumed so the host page
|
|
74
|
+
// never scrolls underneath the user. The wheel zooms the SVG around
|
|
75
|
+
// the cursor; trackpad pinch-zoom (which arrives as ctrlKey wheel
|
|
76
|
+
// on macOS) is treated the same way for a single predictable model.
|
|
72
77
|
viewport.addEventListener('wheel', function (evt) {
|
|
73
|
-
if (!evt.ctrlKey && !evt.metaKey && Math.abs(evt.deltaY) < 4 && Math.abs(evt.deltaX) < 4) return;
|
|
74
78
|
evt.preventDefault();
|
|
75
|
-
|
|
79
|
+
evt.stopPropagation();
|
|
80
|
+
const absX = Math.abs(evt.deltaX);
|
|
81
|
+
const absY = Math.abs(evt.deltaY);
|
|
82
|
+
if (absX < 0.5 && absY < 0.5) return;
|
|
83
|
+
const factor = evt.deltaY > 0 ? 1.08 : 1 / 1.08;
|
|
76
84
|
const pt = clientToSvg(evt);
|
|
77
85
|
zoom(factor, pt.x, pt.y);
|
|
78
86
|
}, { passive: false });
|
|
@@ -17,11 +17,15 @@ const ELK = require('elkjs');
|
|
|
17
17
|
const SUB_WIDTH = 240;
|
|
18
18
|
const SUB_HEIGHT = 92;
|
|
19
19
|
|
|
20
|
-
// Box-sizing knobs (intrinsic SVG coordinates).
|
|
20
|
+
// Box-sizing knobs (intrinsic SVG coordinates). The width/height caps
|
|
21
|
+
// are intentionally generous so CJK roles — which paint at ~1em per
|
|
22
|
+
// character instead of ~0.55em — can grow into a readable rectangle
|
|
23
|
+
// without spilling outside the box (the original ~360×220 cap was
|
|
24
|
+
// only honest for Latin text).
|
|
21
25
|
const SUB_WIDTH_MIN = 220;
|
|
22
|
-
const SUB_WIDTH_MAX =
|
|
26
|
+
const SUB_WIDTH_MAX = 520;
|
|
23
27
|
const SUB_HEIGHT_MIN = 92;
|
|
24
|
-
const SUB_HEIGHT_MAX =
|
|
28
|
+
const SUB_HEIGHT_MAX = 360;
|
|
25
29
|
const SUB_SIDE_PAD = 16;
|
|
26
30
|
const SUB_TOP_PAD = 14;
|
|
27
31
|
const SUB_BOTTOM_PAD = 14;
|
|
@@ -30,12 +34,22 @@ const KIND_LINE = 16; // kind chip line
|
|
|
30
34
|
const ROLE_LINE = 16; // each role line
|
|
31
35
|
const KIND_GAP = 4;
|
|
32
36
|
const ROLE_GAP = 8;
|
|
33
|
-
const MAX_ROLE_LINES =
|
|
37
|
+
const MAX_ROLE_LINES = 6;
|
|
34
38
|
|
|
35
|
-
const CLUSTER_PAD_TOP =
|
|
36
|
-
const CLUSTER_PAD_SIDE =
|
|
37
|
-
const CLUSTER_PAD_BOTTOM =
|
|
38
|
-
|
|
39
|
+
const CLUSTER_PAD_TOP = 44;
|
|
40
|
+
const CLUSTER_PAD_SIDE = 16;
|
|
41
|
+
const CLUSTER_PAD_BOTTOM = 18;
|
|
42
|
+
|
|
43
|
+
// Font sizes mirror architecture.css so the layout math agrees with
|
|
44
|
+
// what the SVG actually paints.
|
|
45
|
+
const SLUG_FONT_PX = 15;
|
|
46
|
+
const KIND_FONT_PX = 10;
|
|
47
|
+
const ROLE_FONT_PX = 11.5;
|
|
48
|
+
const EDGE_LABEL_FONT_PX = 11;
|
|
49
|
+
const EDGE_LABEL_LINE_PX = 14;
|
|
50
|
+
const EDGE_LABEL_LINE_WIDTH_MAX = 220;
|
|
51
|
+
const EDGE_LABEL_PAD_X = 18;
|
|
52
|
+
const EDGE_LABEL_PAD_Y = 8;
|
|
39
53
|
|
|
40
54
|
const KIND_LABELS = {
|
|
41
55
|
ui: 'UI',
|
|
@@ -47,30 +61,128 @@ const KIND_LABELS = {
|
|
|
47
61
|
external: 'external',
|
|
48
62
|
};
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
// East-Asian Wide / Full-width detection. The ranges follow the
|
|
65
|
+
// canonical "wide" / "fullwidth" code blocks (Unicode TR11) — we use
|
|
66
|
+
// them to switch between a ~0.55em-per-char Latin metric and a
|
|
67
|
+
// ~1.0em-per-char CJK metric so layout matches paint.
|
|
68
|
+
function isWideChar(ch) {
|
|
69
|
+
const code = ch.codePointAt(0);
|
|
70
|
+
return (
|
|
71
|
+
(code >= 0x1100 && code <= 0x115F) ||
|
|
72
|
+
(code >= 0x2E80 && code <= 0x9FFF) ||
|
|
73
|
+
(code >= 0xA000 && code <= 0xA4CF) ||
|
|
74
|
+
(code >= 0xAC00 && code <= 0xD7A3) ||
|
|
75
|
+
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
76
|
+
(code >= 0xFE30 && code <= 0xFE4F) ||
|
|
77
|
+
(code >= 0xFF00 && code <= 0xFF60) ||
|
|
78
|
+
(code >= 0xFFE0 && code <= 0xFFE6) ||
|
|
79
|
+
(code >= 0x20000 && code <= 0x2FFFD)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function charWidthFactor(ch) {
|
|
84
|
+
if (isWideChar(ch)) return 1.0;
|
|
85
|
+
if (ch === ' ') return 0.30;
|
|
86
|
+
if (/[A-Za-z0-9]/.test(ch)) return 0.55;
|
|
87
|
+
return 0.50;
|
|
53
88
|
}
|
|
54
89
|
|
|
55
|
-
// Approximate render width of a string in the target font. The 0.6
|
|
56
|
-
// factor is a deliberate over-estimate for our sans-serif stack so
|
|
57
|
-
// the chosen width almost always leaves a little breathing room.
|
|
58
90
|
function approxTextWidth(text, fontPx) {
|
|
59
|
-
|
|
91
|
+
if (!text) return 0;
|
|
92
|
+
let w = 0;
|
|
93
|
+
for (const ch of String(text)) w += fontPx * charWidthFactor(ch);
|
|
94
|
+
return w;
|
|
60
95
|
}
|
|
61
96
|
|
|
62
|
-
|
|
97
|
+
// Greedy wrap by *visual* width. CJK characters break between any
|
|
98
|
+
// two characters; ASCII words stay whole; whitespace is a soft break.
|
|
99
|
+
function wrapByVisualWidth(text, maxWidthPx, fontPx) {
|
|
63
100
|
if (!text) return [];
|
|
64
|
-
const
|
|
101
|
+
const str = String(text);
|
|
65
102
|
const lines = [];
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
let line = '';
|
|
104
|
+
let lineW = 0;
|
|
105
|
+
|
|
106
|
+
function flush() {
|
|
107
|
+
if (line.length > 0) lines.push(line.replace(/\s+$/, ''));
|
|
108
|
+
line = '';
|
|
109
|
+
lineW = 0;
|
|
71
110
|
}
|
|
72
|
-
|
|
73
|
-
|
|
111
|
+
|
|
112
|
+
let i = 0;
|
|
113
|
+
while (i < str.length) {
|
|
114
|
+
const cp = str.codePointAt(i);
|
|
115
|
+
const ch = String.fromCodePoint(cp);
|
|
116
|
+
const advance = ch.length;
|
|
117
|
+
|
|
118
|
+
if (ch === '\n') {
|
|
119
|
+
flush();
|
|
120
|
+
i += advance;
|
|
121
|
+
} else if (isWideChar(ch)) {
|
|
122
|
+
const w = fontPx * 1.0;
|
|
123
|
+
if (line && lineW + w > maxWidthPx) flush();
|
|
124
|
+
line += ch;
|
|
125
|
+
lineW += w;
|
|
126
|
+
i += advance;
|
|
127
|
+
} else if (/\s/.test(ch)) {
|
|
128
|
+
const w = fontPx * 0.30;
|
|
129
|
+
if (line) {
|
|
130
|
+
if (lineW + w > maxWidthPx) flush();
|
|
131
|
+
else { line += ch; lineW += w; }
|
|
132
|
+
}
|
|
133
|
+
i += advance;
|
|
134
|
+
} else {
|
|
135
|
+
let word = '';
|
|
136
|
+
let wordW = 0;
|
|
137
|
+
while (i < str.length) {
|
|
138
|
+
const cp2 = str.codePointAt(i);
|
|
139
|
+
const ch2 = String.fromCodePoint(cp2);
|
|
140
|
+
if (isWideChar(ch2) || /\s/.test(ch2)) break;
|
|
141
|
+
word += ch2;
|
|
142
|
+
wordW += fontPx * charWidthFactor(ch2);
|
|
143
|
+
i += ch2.length;
|
|
144
|
+
}
|
|
145
|
+
if (line && lineW + wordW > maxWidthPx) flush();
|
|
146
|
+
line += word;
|
|
147
|
+
lineW += wordW;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
return lines.filter((l) => l.length > 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Short labels (feature title, sub-module slug) — single-line width
|
|
155
|
+
// only. Capped so very long ASCII slugs do not blow up the cluster
|
|
156
|
+
// header band; CJK titles still get an honest visual width.
|
|
157
|
+
function estimateLabelWidth(text) {
|
|
158
|
+
if (!text) return 0;
|
|
159
|
+
return Math.min(320, Math.max(40, Math.ceil(approxTextWidth(text, 13)) + 16));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Edge labels — wrap into a tall enough rectangle so elkjs reserves
|
|
163
|
+
// proportional edge length (longer text ⇒ longer arrow). The wrapped
|
|
164
|
+
// text is stored with '\n' separators so render.js can paint each
|
|
165
|
+
// line as its own <tspan> centered on the same anchor.
|
|
166
|
+
function measureEdgeLabel(text) {
|
|
167
|
+
if (!text) return { text: '', lines: [], width: 0, height: 0 };
|
|
168
|
+
const raw = String(text);
|
|
169
|
+
const singleW = approxTextWidth(raw, EDGE_LABEL_FONT_PX);
|
|
170
|
+
if (singleW <= EDGE_LABEL_LINE_WIDTH_MAX) {
|
|
171
|
+
return {
|
|
172
|
+
text: raw,
|
|
173
|
+
lines: [raw],
|
|
174
|
+
width: Math.max(40, Math.ceil(singleW) + EDGE_LABEL_PAD_X),
|
|
175
|
+
height: EDGE_LABEL_LINE_PX + EDGE_LABEL_PAD_Y,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const lines = wrapByVisualWidth(raw, EDGE_LABEL_LINE_WIDTH_MAX, EDGE_LABEL_FONT_PX);
|
|
179
|
+
const widestLine = lines.reduce((m, l) => Math.max(m, approxTextWidth(l, EDGE_LABEL_FONT_PX)), 0);
|
|
180
|
+
return {
|
|
181
|
+
text: lines.join('\n'),
|
|
182
|
+
lines,
|
|
183
|
+
width: Math.ceil(widestLine) + EDGE_LABEL_PAD_X,
|
|
184
|
+
height: lines.length * EDGE_LABEL_LINE_PX + EDGE_LABEL_PAD_Y,
|
|
185
|
+
};
|
|
74
186
|
}
|
|
75
187
|
|
|
76
188
|
// measureSubmodule picks a width + height that fit the slug, the kind
|
|
@@ -83,26 +195,33 @@ function measureSubmodule(sub) {
|
|
|
83
195
|
const kindLabel = KIND_LABELS[sub && sub.kind] || (sub && sub.kind) || 'service';
|
|
84
196
|
const role = (sub && sub.role) || '';
|
|
85
197
|
|
|
86
|
-
const slugW = approxTextWidth(slug,
|
|
87
|
-
|
|
198
|
+
const slugW = approxTextWidth(slug, SLUG_FONT_PX);
|
|
199
|
+
// Kind chip is upper-cased + letter-spaced in CSS (0.22em); add ~30% slack.
|
|
200
|
+
const kindW = approxTextWidth(kindLabel.toUpperCase(), KIND_FONT_PX) * 1.3;
|
|
88
201
|
const baseInner = Math.max(slugW, kindW);
|
|
89
202
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
203
|
+
// Auto-expand the box to fit the role with as few lines as
|
|
204
|
+
// possible: prefer 1 line if it fits inside SUB_WIDTH_MAX, else 2,
|
|
205
|
+
// else 3 — and only wrap further when even 3 lines would exceed
|
|
206
|
+
// the cap. This is what "boxes auto-expand to their content" means
|
|
207
|
+
// for CJK roles whose glyphs are ~2× wider than Latin.
|
|
208
|
+
const roleVisualW = approxTextWidth(role, ROLE_FONT_PX);
|
|
209
|
+
const maxInner = SUB_WIDTH_MAX - SUB_SIDE_PAD * 2;
|
|
93
210
|
let chosenInner;
|
|
94
211
|
if (!role) {
|
|
95
212
|
chosenInner = baseInner;
|
|
96
213
|
} else {
|
|
97
|
-
|
|
98
|
-
|
|
214
|
+
let target;
|
|
215
|
+
if (roleVisualW <= maxInner) target = roleVisualW;
|
|
216
|
+
else if (Math.ceil(roleVisualW / 2) <= maxInner) target = Math.ceil(roleVisualW / 2);
|
|
217
|
+
else target = Math.ceil(roleVisualW / 3);
|
|
218
|
+
chosenInner = Math.max(baseInner, Math.max(180, target));
|
|
99
219
|
}
|
|
100
220
|
const width = Math.max(SUB_WIDTH_MIN, Math.min(SUB_WIDTH_MAX, Math.ceil(chosenInner + SUB_SIDE_PAD * 2)));
|
|
101
221
|
|
|
102
222
|
// With the chosen width fixed, wrap the role for real and count lines.
|
|
103
223
|
const innerW = width - SUB_SIDE_PAD * 2;
|
|
104
|
-
|
|
105
|
-
let roleLines = role ? wrapToLines(role, charsPerLine) : [];
|
|
224
|
+
let roleLines = role ? wrapByVisualWidth(role, innerW, ROLE_FONT_PX) : [];
|
|
106
225
|
if (roleLines.length > MAX_ROLE_LINES) {
|
|
107
226
|
roleLines = roleLines.slice(0, MAX_ROLE_LINES);
|
|
108
227
|
const last = roleLines[MAX_ROLE_LINES - 1];
|
|
@@ -129,7 +248,53 @@ function endpointId(endpoint, ownerFeature) {
|
|
|
129
248
|
return null;
|
|
130
249
|
}
|
|
131
250
|
|
|
251
|
+
function clusterLayoutOptions(feature, isCrossEdgeEndpoint) {
|
|
252
|
+
// If the feature declares intra-feature flow edges OR is an
|
|
253
|
+
// endpoint of any root-level cross-feature edge, the cluster must
|
|
254
|
+
// use a hierarchy-friendly directional algorithm (layered) so elk
|
|
255
|
+
// can route edges into/out of its children. Mixing rectpacking
|
|
256
|
+
// with layered hierarchy edges raises UnsupportedGraphException.
|
|
257
|
+
//
|
|
258
|
+
// Otherwise (truly isolated leaf cluster) pack sub-modules into a
|
|
259
|
+
// roughly square grid so a 10-sub-module feature does not become a
|
|
260
|
+
// tall column that wastes the rest of the viewport.
|
|
261
|
+
const hasInternalEdges = Array.isArray(feature.edges) && feature.edges.length > 0;
|
|
262
|
+
const common = {
|
|
263
|
+
'elk.padding': `[top=${CLUSTER_PAD_TOP},left=${CLUSTER_PAD_SIDE},bottom=${CLUSTER_PAD_BOTTOM},right=${CLUSTER_PAD_SIDE}]`,
|
|
264
|
+
'elk.spacing.nodeNode': '16',
|
|
265
|
+
'elk.nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
|
|
266
|
+
};
|
|
267
|
+
if (hasInternalEdges || isCrossEdgeEndpoint) {
|
|
268
|
+
return {
|
|
269
|
+
...common,
|
|
270
|
+
'elk.algorithm': 'layered',
|
|
271
|
+
'elk.direction': 'DOWN',
|
|
272
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '28',
|
|
273
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
...common,
|
|
278
|
+
'elk.algorithm': 'rectpacking',
|
|
279
|
+
'elk.aspectRatio': '1.4',
|
|
280
|
+
'elk.rectpacking.optimizationGoal': 'MAX_SCALE_DRIVEN',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function collectCrossEdgeFeatures(state) {
|
|
285
|
+
const set = new Set();
|
|
286
|
+
function note(endpoint) {
|
|
287
|
+
if (endpoint && typeof endpoint === 'object' && endpoint.feature) set.add(endpoint.feature);
|
|
288
|
+
}
|
|
289
|
+
for (const edge of state.edges || []) {
|
|
290
|
+
note(edge.from);
|
|
291
|
+
note(edge.to);
|
|
292
|
+
}
|
|
293
|
+
return set;
|
|
294
|
+
}
|
|
295
|
+
|
|
132
296
|
function buildGraph(state) {
|
|
297
|
+
const crossEndpoints = collectCrossEdgeFeatures(state);
|
|
133
298
|
const children = (state.features || []).map((feature) => ({
|
|
134
299
|
id: `feature::${feature.slug}`,
|
|
135
300
|
labels: [{
|
|
@@ -138,14 +303,7 @@ function buildGraph(state) {
|
|
|
138
303
|
width: estimateLabelWidth(feature.title || feature.slug),
|
|
139
304
|
height: 24,
|
|
140
305
|
}],
|
|
141
|
-
layoutOptions:
|
|
142
|
-
'elk.padding': `[top=${CLUSTER_PAD_TOP},left=${CLUSTER_PAD_SIDE},bottom=${CLUSTER_PAD_BOTTOM},right=${CLUSTER_PAD_SIDE}]`,
|
|
143
|
-
'elk.spacing.nodeNode': '24',
|
|
144
|
-
'elk.algorithm': 'layered',
|
|
145
|
-
'elk.direction': 'DOWN',
|
|
146
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '36',
|
|
147
|
-
'elk.nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
|
|
148
|
-
},
|
|
306
|
+
layoutOptions: clusterLayoutOptions(feature, crossEndpoints.has(feature.slug)),
|
|
149
307
|
children: (feature.submodules || []).map((sub) => {
|
|
150
308
|
const box = measureSubmodule(sub);
|
|
151
309
|
return {
|
|
@@ -168,17 +326,18 @@ function buildGraph(state) {
|
|
|
168
326
|
|
|
169
327
|
function pushEdge(list, raw, sourceId, targetId) {
|
|
170
328
|
if (!sourceId || !targetId) return;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
labels
|
|
176
|
-
id: `${
|
|
177
|
-
text:
|
|
178
|
-
width:
|
|
179
|
-
height:
|
|
180
|
-
}]
|
|
181
|
-
}
|
|
329
|
+
const id = raw.id || `e-${nextEdgeId++}`;
|
|
330
|
+
let labels = [];
|
|
331
|
+
if (raw.label) {
|
|
332
|
+
const m = measureEdgeLabel(raw.label);
|
|
333
|
+
labels = [{
|
|
334
|
+
id: `${id}::label`,
|
|
335
|
+
text: m.text,
|
|
336
|
+
width: m.width,
|
|
337
|
+
height: m.height,
|
|
338
|
+
}];
|
|
339
|
+
}
|
|
340
|
+
list.push({ id, sources: [sourceId], targets: [targetId], labels });
|
|
182
341
|
}
|
|
183
342
|
|
|
184
343
|
for (const feature of state.features || []) {
|
|
@@ -203,12 +362,20 @@ function buildGraph(state) {
|
|
|
203
362
|
'elk.algorithm': 'layered',
|
|
204
363
|
'elk.direction': 'RIGHT',
|
|
205
364
|
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
'elk.
|
|
365
|
+
// 16:9 hint so elk stops sprawling along one axis and leaving
|
|
366
|
+
// the other half of the viewport empty.
|
|
367
|
+
'elk.aspectRatio': '1.778',
|
|
368
|
+
'elk.spacing.nodeNode': '32',
|
|
369
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '60',
|
|
370
|
+
'elk.padding': '[top=20,left=20,bottom=20,right=20]',
|
|
209
371
|
'elk.edgeRouting': 'ORTHOGONAL',
|
|
210
372
|
'elk.edgeLabels.inline': 'false',
|
|
211
373
|
'elk.edgeLabels.placement': 'CENTER',
|
|
374
|
+
// Tighter post-layout placement: BALANCED keeps related nodes
|
|
375
|
+
// adjacent, EDGE_LENGTH compaction pulls disconnected
|
|
376
|
+
// sub-graphs in toward each other so unused columns collapse.
|
|
377
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
378
|
+
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
|
|
212
379
|
},
|
|
213
380
|
children,
|
|
214
381
|
edges: rootEdges,
|
|
@@ -327,4 +494,7 @@ module.exports = {
|
|
|
327
494
|
assertNoOverlap,
|
|
328
495
|
buildGraph,
|
|
329
496
|
measureSubmodule,
|
|
497
|
+
measureEdgeLabel,
|
|
498
|
+
approxTextWidth,
|
|
499
|
+
wrapByVisualWidth,
|
|
330
500
|
};
|
|
@@ -55,6 +55,10 @@ function head({ title, assetRel, pageKind }) {
|
|
|
55
55
|
' <meta charset="utf-8">',
|
|
56
56
|
` <title>${htmlEscape(title)}</title>`,
|
|
57
57
|
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
58
|
+
' <meta name="color-scheme" content="dark">',
|
|
59
|
+
' <link rel="preconnect" href="https://fonts.googleapis.com">',
|
|
60
|
+
' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
|
|
61
|
+
' <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Geist:wght@300..700&family=JetBrains+Mono:wght@400..600&display=swap">',
|
|
58
62
|
` <link rel="stylesheet" href="${assetRel}/architecture.css">`,
|
|
59
63
|
'</head>',
|
|
60
64
|
].join('\n');
|
|
@@ -77,24 +81,24 @@ function edgeKindFor(stateEdge) {
|
|
|
77
81
|
function findEdgeMeta(state, edgeId) {
|
|
78
82
|
for (const feature of state.features || []) {
|
|
79
83
|
for (const e of feature.edges || []) {
|
|
80
|
-
if (e.id === edgeId) return e;
|
|
84
|
+
if (e.id === edgeId) return { edge: e, scope: 'feature' };
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
for (const e of state.edges || []) {
|
|
84
|
-
if (e.id === edgeId) return e;
|
|
88
|
+
if (e.id === edgeId) return { edge: e, scope: 'root' };
|
|
85
89
|
}
|
|
86
|
-
return null;
|
|
90
|
+
return { edge: null, scope: 'feature' };
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
function renderMacroSvg(layout, state) {
|
|
90
94
|
if (layout.empty) {
|
|
91
|
-
return '<svg class="atlas-svg" viewBox="0 0 320 160" role="img" aria-label="Atlas is empty"><text x="160" y="80" text-anchor="middle" fill="currentColor">Atlas has no features yet</text></svg>';
|
|
95
|
+
return '<svg class="atlas-svg" viewBox="0 0 320 160" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Atlas is empty"><text x="160" y="80" text-anchor="middle" fill="currentColor">Atlas has no features yet</text></svg>';
|
|
92
96
|
}
|
|
93
97
|
const pad = 24;
|
|
94
98
|
const vbW = Math.max(320, Math.ceil(layout.width + pad * 2));
|
|
95
99
|
const vbH = Math.max(160, Math.ceil(layout.height + pad * 2));
|
|
96
100
|
const parts = [];
|
|
97
|
-
parts.push(`<svg class="atlas-svg" viewBox="0 0 ${vbW} ${vbH}" role="img" aria-label="Project architecture atlas" data-atlas-svg="macro">`);
|
|
101
|
+
parts.push(`<svg class="atlas-svg" viewBox="0 0 ${vbW} ${vbH}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Project architecture atlas" data-atlas-svg="macro">`);
|
|
98
102
|
parts.push(' <defs>');
|
|
99
103
|
for (const kind of ['call', 'return', 'data-row', 'failure']) {
|
|
100
104
|
parts.push(` <marker id="arrow-${kind}" class="m-arrow m-arrow--${kind}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>`);
|
|
@@ -140,17 +144,29 @@ function renderMacroSvg(layout, state) {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
for (const edge of layout.edges) {
|
|
143
|
-
const meta = findEdgeMeta(state, edge.id);
|
|
147
|
+
const { edge: meta, scope } = findEdgeMeta(state, edge.id);
|
|
144
148
|
const kind = edgeKindFor(meta);
|
|
145
149
|
const d = renderEdgePath(edge);
|
|
146
150
|
if (!d) continue;
|
|
147
|
-
|
|
151
|
+
const scopeClass = scope === 'root' ? ' m-edge--cross' : '';
|
|
152
|
+
parts.push(` <g class="m-edge m-edge--${kind}${scopeClass}" data-edge="${htmlEscape(edge.id)}">`);
|
|
148
153
|
parts.push(` <path d="${d}" fill="none" marker-end="url(#arrow-${kind})" />`);
|
|
149
154
|
for (const label of edge.labels || []) {
|
|
150
155
|
if (!label.text) continue;
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
156
|
+
const lines = String(label.text).split('\n');
|
|
157
|
+
const cx = label.x + (label.width || 0) / 2;
|
|
158
|
+
const lineH = 14; // matches EDGE_LABEL_LINE_PX in layout.js
|
|
159
|
+
const blockH = lines.length * lineH;
|
|
160
|
+
const firstBaseline = label.y + ((label.height || 0) - blockH) / 2 + (lineH - 3);
|
|
161
|
+
parts.push(` <text class="m-edge__label" x="${cx.toFixed(2)}" y="${firstBaseline.toFixed(2)}" text-anchor="middle">`);
|
|
162
|
+
lines.forEach((line, idx) => {
|
|
163
|
+
if (idx === 0) {
|
|
164
|
+
parts.push(` <tspan x="${cx.toFixed(2)}">${htmlEscape(line)}</tspan>`);
|
|
165
|
+
} else {
|
|
166
|
+
parts.push(` <tspan x="${cx.toFixed(2)}" dy="${lineH}">${htmlEscape(line)}</tspan>`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
parts.push(' </text>');
|
|
154
170
|
}
|
|
155
171
|
parts.push(' </g>');
|
|
156
172
|
}
|
|
@@ -339,7 +355,7 @@ function renderInternalDataflowSvg(steps) {
|
|
|
339
355
|
const totalW = padLeft + boxW + padRight;
|
|
340
356
|
|
|
341
357
|
const parts = [];
|
|
342
|
-
parts.push(`<svg class="sub-dataflow__svg" data-atlas-svg="sub-dataflow" viewBox="0 0 ${totalW} ${totalH}" role="img" aria-label="Internal dataflow">`);
|
|
358
|
+
parts.push(`<svg class="sub-dataflow__svg" data-atlas-svg="sub-dataflow" viewBox="0 0 ${totalW} ${totalH}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Internal dataflow">`);
|
|
343
359
|
parts.push(' <defs>');
|
|
344
360
|
parts.push(' <marker id="sub-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>');
|
|
345
361
|
parts.push(' </defs>');
|
|
@@ -539,9 +555,46 @@ async function renderAll({ outDir, state, scope = null, removedPaths = [] }) {
|
|
|
539
555
|
if (fs.existsSync(file)) fs.rmSync(file);
|
|
540
556
|
}
|
|
541
557
|
|
|
558
|
+
// Full base render (no scope): sweep stale HTML so `apltk architecture
|
|
559
|
+
// render` is a true refresh — old feature folders or renamed sub-modules
|
|
560
|
+
// do not linger with the previous (broken) markup or styling.
|
|
561
|
+
if (!scope) {
|
|
562
|
+
sweepOrphanFeaturePages(outDir, state);
|
|
563
|
+
}
|
|
564
|
+
|
|
542
565
|
return { written, layout };
|
|
543
566
|
}
|
|
544
567
|
|
|
568
|
+
function sweepOrphanFeaturePages(outDir, state) {
|
|
569
|
+
const featuresRoot = path.join(outDir, 'features');
|
|
570
|
+
if (!fs.existsSync(featuresRoot)) return;
|
|
571
|
+
const validFeatures = new Map();
|
|
572
|
+
for (const f of state.features || []) {
|
|
573
|
+
validFeatures.set(f.slug, new Set((f.submodules || []).map((s) => s.slug)));
|
|
574
|
+
}
|
|
575
|
+
let entries;
|
|
576
|
+
try { entries = fs.readdirSync(featuresRoot, { withFileTypes: true }); } catch (_e) { return; }
|
|
577
|
+
for (const entry of entries) {
|
|
578
|
+
if (!entry.isDirectory()) continue;
|
|
579
|
+
const featDir = path.join(featuresRoot, entry.name);
|
|
580
|
+
if (!validFeatures.has(entry.name)) {
|
|
581
|
+
fs.rmSync(featDir, { recursive: true, force: true });
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const wantedSubs = validFeatures.get(entry.name);
|
|
585
|
+
let files;
|
|
586
|
+
try { files = fs.readdirSync(featDir); } catch (_e) { continue; }
|
|
587
|
+
for (const file of files) {
|
|
588
|
+
if (!file.toLowerCase().endsWith('.html')) continue;
|
|
589
|
+
if (file === 'index.html') continue;
|
|
590
|
+
const slug = file.slice(0, -5);
|
|
591
|
+
if (!wantedSubs.has(slug)) {
|
|
592
|
+
fs.rmSync(path.join(featDir, file), { force: true });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
545
598
|
function scopeFromDiff(diff) {
|
|
546
599
|
const submodules = [];
|
|
547
600
|
for (const item of diff.modifiedSubmodules || []) submodules.push(item);
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|