turbometro 0.1.0

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/bin/turbometro.js +2 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +107 -0
  6. package/dist/find-root.d.ts +2 -0
  7. package/dist/find-root.js +14 -0
  8. package/dist/graph.d.ts +14 -0
  9. package/dist/graph.js +74 -0
  10. package/dist/layout.d.ts +19 -0
  11. package/dist/layout.js +15 -0
  12. package/dist/parse-turbo.d.ts +5 -0
  13. package/dist/parse-turbo.js +29 -0
  14. package/dist/parse-workspace.d.ts +4 -0
  15. package/dist/parse-workspace.js +100 -0
  16. package/dist/render-html.d.ts +8 -0
  17. package/dist/render-html.js +699 -0
  18. package/dist/render-map.d.ts +29 -0
  19. package/dist/render-map.js +287 -0
  20. package/dist/replay.d.ts +9 -0
  21. package/dist/replay.js +74 -0
  22. package/dist/scale.d.ts +7 -0
  23. package/dist/scale.js +19 -0
  24. package/dist/scene-data.d.ts +40 -0
  25. package/dist/scene-data.js +146 -0
  26. package/dist/theme.d.ts +15 -0
  27. package/dist/theme.js +15 -0
  28. package/dist/types.d.ts +30 -0
  29. package/dist/types.js +1 -0
  30. package/fixtures/mini-mono/apps/web/package.json +11 -0
  31. package/fixtures/mini-mono/package.json +5 -0
  32. package/fixtures/mini-mono/packages/ui/package.json +11 -0
  33. package/fixtures/mini-mono/packages/utils/package.json +8 -0
  34. package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
  35. package/fixtures/mini-mono/turbo.json +10 -0
  36. package/package.json +44 -0
  37. package/vendor/dag-map/LICENSE +201 -0
  38. package/vendor/dag-map/NOTICE +6 -0
  39. package/vendor/dag-map/src/color-scales.js +61 -0
  40. package/vendor/dag-map/src/dag-map.css +63 -0
  41. package/vendor/dag-map/src/events.js +108 -0
  42. package/vendor/dag-map/src/graph-utils.js +185 -0
  43. package/vendor/dag-map/src/hasse.css +61 -0
  44. package/vendor/dag-map/src/index.js +55 -0
  45. package/vendor/dag-map/src/layout-flow.js +1066 -0
  46. package/vendor/dag-map/src/layout-hasse.js +485 -0
  47. package/vendor/dag-map/src/layout-metro.js +542 -0
  48. package/vendor/dag-map/src/occupancy.js +138 -0
  49. package/vendor/dag-map/src/render-flow-station.js +132 -0
  50. package/vendor/dag-map/src/render.js +360 -0
  51. package/vendor/dag-map/src/route-angular.js +137 -0
  52. package/vendor/dag-map/src/route-bezier.js +51 -0
  53. package/vendor/dag-map/src/route-metro.js +122 -0
  54. package/vendor/dag-map/src/themes.js +49 -0
@@ -0,0 +1,132 @@
1
+ // ================================================================
2
+ // render-flow-station.js — Station card + edge label renderers
3
+ // ================================================================
4
+ // Reusable renderers for the flow layout's Celonis-style visuals:
5
+ // punched-out dots on the line, rich cards to the side, on-line badges.
6
+
7
+ /** Escape user-supplied strings for safe SVG/XML interpolation. */
8
+ function esc(s) {
9
+ if (typeof s !== 'string') return s;
10
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ }
12
+
13
+ /**
14
+ * Create a station (node) renderer for flow layouts.
15
+ * @param {object} layout - result from layoutFlow()
16
+ * @param {Array} routes - route definitions [{id, cls, nodes}]
17
+ * @returns {function} renderNode(node, pos, ctx) => SVG string
18
+ */
19
+ export function createStationRenderer(layout, routes) {
20
+ return function renderStation(node, pos, ctx) {
21
+ const s = ctx.scale;
22
+ const dotR = 3.2 * s;
23
+ const fsLabel = layout.labelSize || 3.6 * s;
24
+ const fsData = fsLabel * 0.78;
25
+ const isDim = node.dim === true;
26
+ const dimOp = isDim ? 0.25 : 1;
27
+ let svg = '';
28
+
29
+ const routeIndices = [];
30
+ routes.forEach((route, ri) => {
31
+ if (route.nodes.includes(node.id)) routeIndices.push(ri);
32
+ });
33
+
34
+ const dotCoords = routeIndices.map(ri =>
35
+ layout.dotPos ? layout.dotPos(node.id, ri) : { x: layout.dotX(node.id, ri), y: pos.y }
36
+ );
37
+
38
+ // Punched-out dots ON the line
39
+ routeIndices.forEach((ri, i) => {
40
+ const col = ctx.theme.classes[routes[ri].cls];
41
+ if (!col) return;
42
+ svg += `<circle cx="${dotCoords[i].x}" cy="${dotCoords[i].y}" r="${dotR}" fill="${col}"${isDim ? ` opacity="${dimOp}"` : ''}/>`;
43
+ svg += `<circle cx="${dotCoords[i].x}" cy="${dotCoords[i].y}" r="${dotR * 0.35}" fill="${ctx.theme.paper}"${isDim ? ` opacity="${dimOp}"` : ''}/>`;
44
+ });
45
+
46
+ // Card from layout's obstacle-aware placement
47
+ const cp = layout.cardPlacements?.get(node.id);
48
+ if (cp) {
49
+ const { rect, cardPadX, cardPadY } = cp;
50
+
51
+ svg += `<rect x="${rect.x}" y="${rect.y}" width="${rect.w}" height="${rect.h}" rx="${2.5 * s}" `;
52
+ svg += `fill="${ctx.theme.paper}" stroke="${ctx.theme.muted}" stroke-width="${0.7 * s}"${isDim ? ` opacity="${dimOp}"` : ''}/>`;
53
+
54
+ const labelY = rect.y + cardPadY + fsLabel * 0.85;
55
+ svg += `<text x="${rect.x + cardPadX}" y="${labelY}" font-size="${fsLabel}" fill="${ctx.theme.ink}" text-anchor="start" font-weight="500"${isDim ? ` opacity="${dimOp * 0.8}"` : ''}>${esc(node.label)}</text>`;
56
+
57
+ const dataY = labelY + fsData + 3 * s;
58
+ let dx = rect.x + cardPadX;
59
+ routeIndices.forEach(ri => {
60
+ const col = ctx.theme.classes[routes[ri].cls];
61
+ if (!col) return;
62
+ svg += `<rect x="${dx}" y="${dataY - fsData * 0.7}" width="${3.5 * s}" height="${3.5 * s}" rx="${0.5 * s}" fill="${col}"${isDim ? ` opacity="${dimOp}"` : ''}/>`;
63
+ dx += 5 * s;
64
+ });
65
+ const metricValue = node.times ?? node.count;
66
+ if (metricValue !== undefined && metricValue !== null) {
67
+ svg += `<text x="${dx + 2 * s}" y="${dataY}" font-size="${fsData}" fill="${ctx.theme.muted}" text-anchor="start"${isDim ? ` opacity="${dimOp * 0.8}"` : ''}>${esc(String(metricValue))}</text>`;
68
+ }
69
+ }
70
+
71
+ return svg;
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Create an edge renderer that draws route paths + on-line volume badges.
77
+ * @param {object} layout - result from layoutFlow()
78
+ * @param {Map<string,string>} [edgeVolumes] - per-route volumes: "ri:from→to" → label
79
+ * @returns {function} renderEdge(edge, segment, ctx) => SVG string
80
+ */
81
+ export function createEdgeRenderer(layout, edgeVolumes) {
82
+ return function renderEdge(edge, segment, ctx) {
83
+ const s = ctx.scale;
84
+ let svg = '';
85
+
86
+ svg += `<path d="${segment.d}" stroke="${segment.color}" stroke-width="${segment.thickness}" fill="none" `;
87
+ svg += `stroke-linecap="round" stroke-linejoin="round" opacity="${segment.opacity}"`;
88
+ if (segment.dashed) svg += ` stroke-dasharray="${4 * s},${3 * s}"`;
89
+ svg += `/>`;
90
+
91
+ // Extra edges: draw station-sized dots at start and end
92
+ if (ctx.isExtraEdge && layout.extraDotPositions) {
93
+ // Find matching extra edge positions
94
+ for (const [key, pos] of layout.extraDotPositions) {
95
+ // Match by checking if this segment's path starts at the expected position
96
+ const startM = segment.d.match(/^M\s+(-?[\d.]+)\s+(-?[\d.]+)/);
97
+ if (!startM) continue;
98
+ const mx = parseFloat(startM[1]), my = parseFloat(startM[2]);
99
+ if (Math.abs(mx - pos.fromX) < 1 && Math.abs(my - pos.fromY) < 1) {
100
+ const dotR = 3.2 * s;
101
+ // Punched-out dots matching route station style, but in muted color
102
+ svg += `<circle cx="${pos.fromX}" cy="${pos.fromY}" r="${dotR}" fill="${ctx.theme.muted}"/>`;
103
+ svg += `<circle cx="${pos.fromX}" cy="${pos.fromY}" r="${dotR * 0.35}" fill="${ctx.theme.paper}"/>`;
104
+ svg += `<circle cx="${pos.toX}" cy="${pos.toY}" r="${dotR}" fill="${ctx.theme.muted}"/>`;
105
+ svg += `<circle cx="${pos.toX}" cy="${pos.toY}" r="${dotR * 0.35}" fill="${ctx.theme.paper}"/>`;
106
+ break;
107
+ }
108
+ }
109
+ }
110
+
111
+ if (edgeVolumes && !ctx.isExtraEdge && edge && ctx.routeIndex !== undefined) {
112
+ const ri = ctx.routeIndex;
113
+ const routeEdgeKey = `${ri}:${edge.from}\u2192${edge.to}`;
114
+ const vol = edgeVolumes.get(routeEdgeKey);
115
+
116
+ if (vol) {
117
+ const labelPos = layout.edgeLabelPositions?.get(routeEdgeKey);
118
+ if (labelPos) {
119
+ const fs = (layout.labelSize || 3.6 * s) * 0.67;
120
+ const tw = vol.length * fs * 0.55 + 3.5 * s;
121
+ const th = fs + 2.5 * s;
122
+
123
+ svg += `<rect x="${labelPos.x - tw / 2}" y="${labelPos.y - th / 2}" width="${tw}" height="${th}" rx="${1.5 * s}" `;
124
+ svg += `fill="${ctx.theme.paper}" stroke="${labelPos.color}" stroke-width="${0.5 * s}" opacity="0.9"/>`;
125
+ svg += `<text x="${labelPos.x}" y="${labelPos.y + fs * 0.35}" font-size="${fs}" fill="${labelPos.color}" text-anchor="middle" opacity="0.9">${esc(vol)}</text>`;
126
+ }
127
+ }
128
+ }
129
+
130
+ return svg;
131
+ };
132
+ }
@@ -0,0 +1,360 @@
1
+ // ================================================================
2
+ // render.js — SVG rendering for dag-map
3
+ // ================================================================
4
+ // Renders a DAG layout into an SVG string.
5
+ // Supports horizontal and diagonal label modes.
6
+ // Colors are driven by layout.theme (from the theme system).
7
+ //
8
+ // Two color modes:
9
+ // cssVars: false (default) — inline hex colors, portable SVG
10
+ // cssVars: true — CSS var() references, themeable from CSS
11
+
12
+ import { resolveTheme } from './themes.js';
13
+ import { colorScales } from './color-scales.js';
14
+
15
+ /** Escape user-supplied strings for safe SVG/XML interpolation. */
16
+ function esc(s) {
17
+ if (typeof s !== 'string') return s;
18
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
19
+ }
20
+
21
+ /** Escape values interpolated into quoted XML attributes. */
22
+ function escAttr(v) {
23
+ return esc(String(v ?? ''));
24
+ }
25
+
26
+ /**
27
+ * Render a DAG layout as an SVG string.
28
+ *
29
+ * @param {object} dag - { nodes: [{id, label, cls}], edges: [[from, to]] }
30
+ * @param {object} layout - result from layoutMetro()
31
+ * @param {object} [options]
32
+ * @param {string} [options.title] - title displayed at top of SVG
33
+ * @param {string|null} [options.subtitle] - subtitle text (null to hide)
34
+ * @param {string} [options.font] - font-family for SVG text
35
+ * @param {boolean} [options.diagonalLabels=false] - tube-map style diagonal labels
36
+ * @param {number} [options.labelAngle=45] - angle in degrees for diagonal labels (0-90)
37
+ * @param {boolean} [options.showLegend=true] - show legend at bottom
38
+ * @param {object} [options.legendLabels] - custom legend labels per class
39
+ * @param {boolean} [options.cssVars=false] - use CSS var() references instead of inline colors
40
+ * @param {number} [options.labelSize=5] - label font size multiplier (before scale)
41
+ * @param {number} [options.titleSize=10] - title font size multiplier (before scale)
42
+ * @param {number} [options.subtitleSize=6.5] - subtitle font size multiplier (before scale)
43
+ * @param {number} [options.legendSize=6.5] - legend text font size multiplier (before scale)
44
+ * @param {function} [options.renderNode] - custom node renderer: (node, pos, ctx) => SVG string
45
+ * @param {function} [options.renderEdge] - custom edge renderer: (edge, segment, ctx) => SVG string
46
+ * @returns {string} SVG markup
47
+ */
48
+ export function renderSVG(dag, layout, options = {}) {
49
+ const {
50
+ title,
51
+ subtitle,
52
+ diagonalLabels = false,
53
+ labelAngle = 45,
54
+ showLegend = true,
55
+ cssVars = false,
56
+ labelSize = 5,
57
+ titleSize = 10,
58
+ subtitleSize = 6.5,
59
+ legendSize = 6.5,
60
+ dimOpacity = 0.25,
61
+ renderNode,
62
+ renderEdge,
63
+ metrics,
64
+ edgeMetrics,
65
+ colorScale: userColorScale,
66
+ selected,
67
+ interactive = false,
68
+ } = options;
69
+
70
+ const colorScale = userColorScale || colorScales.palette;
71
+
72
+ const font = options.font || "'IBM Plex Mono', 'Courier New', monospace";
73
+
74
+ const defaultLegendLabels = {
75
+ pure: 'Primary',
76
+ recordable: 'Secondary',
77
+ side_effecting: 'Tertiary',
78
+ gate: 'Control',
79
+ };
80
+ const legendLabels = { ...defaultLegendLabels, ...(options.legendLabels || {}) };
81
+
82
+ // Resolve colors from theme (with backward-compat fallback)
83
+ const theme = layout.theme || resolveTheme('cream');
84
+
85
+ // Color resolver: either inline hex or CSS var() reference
86
+ const clsVar = (cls) => `var(--dm-cls-${cls.replace(/_/g, '-')})`;
87
+ const col = cssVars ? {
88
+ paper: 'var(--dm-paper)',
89
+ ink: 'var(--dm-ink)',
90
+ muted: 'var(--dm-muted)',
91
+ border: 'var(--dm-border)',
92
+ cls: (cls) => clsVar(cls),
93
+ } : {
94
+ paper: theme.paper,
95
+ ink: theme.ink,
96
+ muted: theme.muted,
97
+ border: theme.border,
98
+ cls: (cls) => theme.classes[cls] || theme.classes.pure,
99
+ };
100
+
101
+ const { positions, routePaths, extraEdges, width, height, routes, nodeRoute, nodeRoutes } = layout;
102
+ const s = layout.scale || 1;
103
+ const nodeMap = new Map(dag.nodes.map(n => [n.id, n]));
104
+ const inDeg = new Map(), outDeg = new Map();
105
+ dag.nodes.forEach(nd => { inDeg.set(nd.id, 0); outDeg.set(nd.id, 0); });
106
+ dag.edges.forEach(([f, t]) => { outDeg.set(f, outDeg.get(f) + 1); inDeg.set(t, inDeg.get(t) + 1); });
107
+
108
+ const displayTitle = title || `DAG (${dag.nodes.length} OPS)`;
109
+ const displaySubtitle = subtitle !== undefined ? subtitle : 'Topological layout. Colored lines = execution paths by node class.';
110
+
111
+ // Computed sizes in SVG coordinate units
112
+ const sz = {
113
+ title: titleSize * s,
114
+ subtitle: subtitleSize * s,
115
+ label: labelSize * s,
116
+ legend: legendSize * s,
117
+ stats: (legendSize - 0.5) * s,
118
+ };
119
+
120
+ // Size resolver: either inline value or CSS var() reference
121
+ const fs = cssVars ? {
122
+ title: `var(--dm-title-size, ${sz.title})`,
123
+ subtitle: `var(--dm-subtitle-size, ${sz.subtitle})`,
124
+ label: `var(--dm-label-size, ${sz.label})`,
125
+ legend: `var(--dm-legend-size, ${sz.legend})`,
126
+ stats: `var(--dm-stats-size, ${sz.stats})`,
127
+ } : sz;
128
+
129
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="${font}">\n`;
130
+
131
+ if (cssVars) {
132
+ svg += `<style>\n`;
133
+ svg += ` svg { --dm-title-size: ${sz.title}; --dm-subtitle-size: ${sz.subtitle}; --dm-label-size: ${sz.label}; --dm-legend-size: ${sz.legend}; --dm-stats-size: ${sz.stats}; }\n`;
134
+ svg += `</style>\n`;
135
+ }
136
+
137
+ svg += `<rect width="${width}" height="${height}" fill="${col.paper}"/>\n`;
138
+
139
+ // In cssVars mode, use style= (CSS property) so var() works; otherwise use font-size= (SVG attribute)
140
+ const fsAttr = (cls, size) => cssVars
141
+ ? `style="font-size: ${size}"`
142
+ : `font-size="${size}"`;
143
+
144
+ svg += `<text class="dm-title" x="${24 * s}" y="${22 * s}" ${fsAttr('title', fs.title)} fill="${col.ink}" letter-spacing="0.06em" opacity="0.5">${esc(displayTitle)}</text>\n`;
145
+ if (displaySubtitle) {
146
+ svg += `<text class="dm-subtitle" x="${24 * s}" y="${34 * s}" ${fsAttr('subtitle', fs.subtitle)} fill="${col.muted}">${esc(displaySubtitle)}</text>\n`;
147
+ }
148
+
149
+ // Route lines — extra edges first (behind)
150
+ // Note: route/edge colors come from layout (already resolved to hex).
151
+ // In cssVars mode, we need to map them back to CSS var references.
152
+ function segColor(hexColor) {
153
+ if (!cssVars) return hexColor;
154
+ // Find which class this hex color belongs to
155
+ for (const [cls, clsHex] of Object.entries(theme.classes)) {
156
+ if (clsHex === hexColor) return clsVar(cls);
157
+ }
158
+ return hexColor; // fallback to hex if no match
159
+ }
160
+
161
+ // Build edge lookup for data attributes
162
+ const edgeIndex = new Map();
163
+ dag.edges.forEach(([f, t], i) => { edgeIndex.set(`${f}\u2192${t}`, i); });
164
+
165
+ // Extra edges (cross-route connections)
166
+ extraEdges.forEach((seg, i) => {
167
+ if (renderEdge) {
168
+ const ctx = { theme, scale: s, isExtraEdge: true, index: i };
169
+ svg += renderEdge(null, { ...seg, color: segColor(seg.color) }, ctx);
170
+ svg += '\n';
171
+ } else {
172
+ svg += `<path d="${seg.d}" stroke="${segColor(seg.color)}" stroke-width="${seg.thickness}" fill="none" `;
173
+ svg += `stroke-linecap="round" stroke-linejoin="round" opacity="${seg.opacity}"`;
174
+ if (seg.dashed) svg += ` stroke-dasharray="${4 * s},${3 * s}"`;
175
+ svg += ` data-edge-extra="true"`;
176
+ svg += `/>\n`;
177
+ }
178
+ });
179
+
180
+ // Route edges
181
+ routes.forEach((route, ri) => {
182
+ const segments = routePaths[ri];
183
+ if (!segments) return;
184
+ segments.forEach((seg, si) => {
185
+ const fromId = route.nodes[si];
186
+ const toId = route.nodes[si + 1];
187
+
188
+ // Check for edge-level metric
189
+ const edgeKey = fromId && toId ? `${fromId}\u2192${toId}` : null;
190
+ const edgeMetric = edgeKey && edgeMetrics && edgeMetrics.get ? edgeMetrics.get(edgeKey) : undefined;
191
+ const hasEdgeMetric = edgeMetric !== undefined && edgeMetric !== null;
192
+ const edgeColor = hasEdgeMetric ? colorScale(edgeMetric.value) : segColor(seg.color);
193
+ const edgeOpacity = hasEdgeMetric ? Math.max(seg.opacity, 0.8) : seg.opacity;
194
+
195
+ if (renderEdge) {
196
+ const edge = fromId && toId ? { from: fromId, to: toId } : null;
197
+ const ctx = { theme, scale: s, isExtraEdge: false, routeIndex: ri, segmentIndex: si, edgeMetric };
198
+ svg += renderEdge(edge, { ...seg, color: edgeColor }, ctx);
199
+ svg += '\n';
200
+ } else {
201
+ // When interactive, emit a wider invisible hit area so thin edges are clickable
202
+ if (interactive && fromId && toId) {
203
+ svg += `<path d="${seg.d}" stroke="transparent" stroke-width="${Math.max(seg.thickness, 8 * s)}" fill="none" `;
204
+ svg += `stroke-linecap="round" pointer-events="stroke" `;
205
+ svg += `data-edge-from="${escAttr(fromId)}" data-edge-to="${escAttr(toId)}" data-route="${ri}" data-edge-hit="true"`;
206
+ svg += `/>\n`;
207
+ }
208
+ svg += `<path d="${seg.d}" stroke="${edgeColor}" stroke-width="${seg.thickness}" fill="none" `;
209
+ svg += `stroke-linecap="round" stroke-linejoin="round" opacity="${edgeOpacity}"`;
210
+ if (interactive) svg += ` pointer-events="none"`;
211
+ if (seg.dashed) svg += ` stroke-dasharray="${4 * s},${3 * s}"`;
212
+ if (fromId && toId) {
213
+ svg += ` data-edge-from="${escAttr(fromId)}" data-edge-to="${escAttr(toId)}" data-route="${ri}"`;
214
+ }
215
+ svg += `/>\n`;
216
+ }
217
+ });
218
+ });
219
+
220
+ // Stations (nodes)
221
+ dag.nodes.forEach(nd => {
222
+ const pos = positions.get(nd.id);
223
+ if (!pos) return;
224
+ const metric = metrics && metrics.get ? metrics.get(nd.id) : undefined;
225
+ const hasMetric = metric !== undefined && metric !== null;
226
+ const baseColor = col.cls(nd.cls || 'pure');
227
+ const color = hasMetric ? colorScale(metric.value) : baseColor;
228
+ const isInterchange = (inDeg.get(nd.id) > 1 || outDeg.get(nd.id) > 1);
229
+ const isGate = nd.cls === 'gate';
230
+
231
+ const ri = nodeRoute.get(nd.id);
232
+ const depth = (ri !== undefined && routes[ri]) ? routes[ri].depth : 0;
233
+
234
+ // Compute route info for this node
235
+ const nRoutes = nodeRoutes ? nodeRoutes.get(nd.id) : null;
236
+ const routeCount = nRoutes ? nRoutes.size : 1;
237
+ const routeClasses = nRoutes
238
+ ? [...nRoutes].map(idx => routes[idx]?.cls).filter(Boolean)
239
+ : [];
240
+
241
+ const metricAttr = hasMetric ? ` data-metric-value="${metric.value}"` : '';
242
+
243
+ if (renderNode) {
244
+ const ctx = {
245
+ theme,
246
+ scale: s,
247
+ isInterchange,
248
+ depth,
249
+ inDegree: inDeg.get(nd.id),
250
+ outDegree: outDeg.get(nd.id),
251
+ color,
252
+ routeIndex: ri,
253
+ routeCount,
254
+ routeClasses,
255
+ orientation: layout.orientation || 'ltr',
256
+ laneX: layout.laneX || null,
257
+ metric,
258
+ };
259
+ svg += `<g data-node-id="${escAttr(nd.id)}" data-node-cls="${escAttr(nd.cls || 'pure')}"${metricAttr}>`;
260
+ svg += renderNode(nd, pos, ctx);
261
+ svg += `</g>\n`;
262
+ } else {
263
+ let r;
264
+ if (isInterchange) {
265
+ r = 5.5 * s;
266
+ } else if (depth <= 1) {
267
+ r = 3.5 * s;
268
+ } else {
269
+ r = 3 * s;
270
+ }
271
+
272
+ const isDim = nd.dim === true;
273
+ const dO = dimOpacity;
274
+ const nodeOpacity = isDim ? dO : 1;
275
+
276
+ svg += `<g data-node-id="${escAttr(nd.id)}" data-node-cls="${escAttr(nd.cls || 'pure')}"${metricAttr}>`;
277
+
278
+ svg += `<circle data-id="${escAttr(nd.id)}" cx="${pos.x.toFixed(1)}" cy="${pos.y.toFixed(1)}" r="${r}" `;
279
+ svg += `fill="${col.paper}" stroke="${color}" stroke-width="${(isGate ? 2 : 1.6) * s}"`;
280
+ if (isGate) svg += ` stroke-dasharray="${2 * s},${1.5 * s}"`;
281
+ if (isDim) svg += ` opacity="${nodeOpacity}"`;
282
+ svg += `/>`;
283
+
284
+ if (isInterchange && !isGate) {
285
+ svg += `<circle cx="${pos.x.toFixed(1)}" cy="${pos.y.toFixed(1)}" r="${2 * s}" fill="${color}" opacity="${isDim ? dO * 0.4 : 0.3}"/>`;
286
+ }
287
+ if (isGate) {
288
+ svg += `<circle cx="${pos.x.toFixed(1)}" cy="${pos.y.toFixed(1)}" r="${2.2 * s}" fill="${col.cls('gate')}" opacity="${isDim ? dO * 0.6 : 0.4}"/>`;
289
+ }
290
+
291
+ // Selection ring (rendered around the node when selected)
292
+ const isSelected = selected && selected.has && selected.has(nd.id);
293
+ if (isSelected) {
294
+ const selR = r + 3 * s;
295
+ svg += `<circle cx="${pos.x.toFixed(1)}" cy="${pos.y.toFixed(1)}" r="${selR}" `;
296
+ svg += `fill="none" stroke="${col.ink}" stroke-width="${1.2 * s}" opacity="0.8" class="dag-map-selected"/>`;
297
+ }
298
+
299
+ // Metric label (rendered above the node)
300
+ if (hasMetric && metric.label) {
301
+ svg += `<text class="dm-metric-label" x="${pos.x.toFixed(1)}" y="${(pos.y - r - 2 * s).toFixed(1)}" `;
302
+ svg += `font-size="${labelSize * 0.9 * s}" fill="${color}" text-anchor="middle" font-weight="600" opacity="${isDim ? dO * 0.8 : 0.9}">${esc(metric.label)}</text>`;
303
+ }
304
+
305
+ const lfs = sz.label; // label font size in SVG units (for positioning)
306
+ const lfsCss = fs.label; // label font size value (inline or var())
307
+ const labelOpacity = isDim ? dO * 0.8 : 0.55;
308
+ if (diagonalLabels) {
309
+ const tickLen = 6 * s;
310
+ const angle = -labelAngle;
311
+ const rad = angle * Math.PI / 180;
312
+ const tickEndX = pos.x + Math.cos(rad) * tickLen;
313
+ const tickEndY = pos.y + Math.sin(rad) * tickLen;
314
+ svg += `<line x1="${pos.x.toFixed(1)}" y1="${(pos.y - r).toFixed(1)}" `;
315
+ svg += `x2="${tickEndX.toFixed(1)}" y2="${(tickEndY - r).toFixed(1)}" `;
316
+ svg += `stroke="${col.ink}" stroke-width="${0.6 * s}" opacity="${isDim ? dO * 0.4 : 0.3}"/>`;
317
+ const textX = tickEndX + 1 * s;
318
+ const textY = tickEndY - r - 1 * s;
319
+ svg += `<text class="dm-label" x="${textX.toFixed(1)}" y="${textY.toFixed(1)}" `;
320
+ svg += `${fsAttr('label', lfs * 0.9)} fill="${col.ink}" text-anchor="start" opacity="${labelOpacity}" `;
321
+ svg += `transform="rotate(${angle} ${textX.toFixed(1)} ${textY.toFixed(1)})">${esc(nd.label)}</text>`;
322
+ } else if (layout.orientation === 'ttb') {
323
+ const labelX = pos.x + r + 4 * s;
324
+ const labelY = pos.y + lfs * 0.35;
325
+ svg += `<text class="dm-label" x="${labelX.toFixed(1)}" y="${labelY.toFixed(1)}" `;
326
+ svg += `${fsAttr('label', lfsCss)} fill="${col.ink}" text-anchor="start" opacity="${labelOpacity}">${esc(nd.label)}</text>`;
327
+ } else {
328
+ const labelY = pos.y + r + 8 * s;
329
+ svg += `<text class="dm-label" x="${pos.x.toFixed(1)}" y="${labelY.toFixed(1)}" `;
330
+ svg += `${fsAttr('label', lfsCss)} fill="${col.ink}" text-anchor="middle" opacity="${labelOpacity}">${esc(nd.label)}</text>`;
331
+ }
332
+
333
+ svg += `</g>\n`;
334
+ }
335
+ });
336
+
337
+ // Legend
338
+ if (showLegend) {
339
+ const ly = height - 55 * s;
340
+ svg += `<line x1="${24 * s}" y1="${ly}" x2="${width - 24 * s}" y2="${ly}" stroke="${col.border}" stroke-width="${0.3 * s}"/>\n`;
341
+
342
+ // Derive legend entries from theme classes
343
+ const classKeys = Object.keys(theme.classes);
344
+ classKeys.forEach((cls, i) => {
345
+ const label = legendLabels[cls] || cls;
346
+ const color = col.cls(cls);
347
+ const x = 24 * s + i * 160 * s;
348
+ svg += `<line x1="${x}" y1="${ly + 16 * s}" x2="${x + 22 * s}" y2="${ly + 16 * s}" stroke="${color}" stroke-width="${3.5 * s}" opacity="0.5" stroke-linecap="round"`;
349
+ if (cls === 'gate') svg += ` stroke-dasharray="${4 * s},${3 * s}"`;
350
+ svg += `/>\n`;
351
+ svg += `<text class="dm-legend-text" x="${x + 28 * s}" y="${ly + 19 * s}" ${fsAttr('legend', fs.legend)} fill="${col.muted}">${esc(label)}</text>\n`;
352
+ });
353
+
354
+ const vertSpread = layout.maxY - layout.minY;
355
+ svg += `<text class="dm-stats" x="${24 * s}" y="${ly + 38 * s}" ${fsAttr('stats', fs.stats)} fill="${col.muted}">${dag.nodes.length} ops | ${dag.edges.length} edges | ${routes.length} routes | spread: ${vertSpread.toFixed(0)}px | scale: ${s}x</text>\n`;
356
+ }
357
+
358
+ svg += `</svg>`;
359
+ return svg;
360
+ }
@@ -0,0 +1,137 @@
1
+ // ================================================================
2
+ // route-angular.js — Angular progressive routing (R9/R10 style)
3
+ // ================================================================
4
+ // Interchange-aware angular routing with progressive curves.
5
+ // Convergence edges steepen toward the reference line.
6
+ // Divergence edges flatten away from it.
7
+ // Deterministic per-route variation via hash-based departure/arrival fractions.
8
+
9
+ /**
10
+ * Progressive curve generator.
11
+ *
12
+ * For convergence (isConvergence=true): the edge is returning toward the
13
+ * reference line (trunk or parent route). The curve STARTS flat (large
14
+ * horizontal run) and ENDS steep (small horizontal run) — "steepening."
15
+ * Weights: (nSegs - i)^power — first segment gets the most X.
16
+ *
17
+ * For divergence (isConvergence=false): the edge is departing from the
18
+ * reference line. The curve STARTS steep and ENDS flat — "flattening."
19
+ * Weights: (i + 1)^power — first segment gets the least X.
20
+ *
21
+ * @param {number} startX
22
+ * @param {number} startY
23
+ * @param {number} endX
24
+ * @param {number} endY
25
+ * @param {boolean} isConvergence
26
+ * @param {number} [power=2.2]
27
+ * @returns {string} SVG path data (L segments, no leading M)
28
+ */
29
+ export function progressiveCurve(startX, startY, endX, endY, isConvergence, power = 2.2) {
30
+ const totalDx = endX - startX;
31
+ const totalDy = endY - startY;
32
+ if (Math.abs(totalDy) < 1) return `L ${endX} ${endY}`;
33
+ if (totalDx < 3) return `L ${endX} ${endY}`;
34
+
35
+ // Number of segments based on Y distance
36
+ // ~1 segment per 18px of vertical distance, minimum 2, maximum 5
37
+ const nSegs = Math.max(2, Math.min(5, Math.round(Math.abs(totalDy) / 18)));
38
+
39
+ // X distribution: power curve
40
+ const weights = [];
41
+ for (let i = 0; i < nSegs; i++) {
42
+ if (isConvergence) {
43
+ // Convergence: first segments are flat (more X), last are steep (less X)
44
+ weights.push(Math.pow(nSegs - i, power));
45
+ } else {
46
+ // Divergence: first segments are steep (less X), last are flat (more X)
47
+ weights.push(Math.pow(i + 1, power));
48
+ }
49
+ }
50
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
51
+
52
+ // Y is distributed EVENLY across segments
53
+ const segDy = totalDy / nSegs;
54
+
55
+ // Build path segments
56
+ let d = '';
57
+ let cx = startX;
58
+ let cy = startY;
59
+
60
+ for (let i = 0; i < nSegs; i++) {
61
+ const segDx = totalDx * (weights[i] / totalWeight);
62
+ cx += segDx;
63
+ cy += segDy;
64
+ d += `L ${cx.toFixed(1)} ${cy.toFixed(1)} `;
65
+ }
66
+
67
+ return d;
68
+ }
69
+
70
+ /**
71
+ * Angular path with interchange-aware direction detection.
72
+ *
73
+ * Route segments determine convergence/divergence by ROLE, not distance:
74
+ * - FORK segment (src is interchange): always DIVERGENCE
75
+ * - RETURN segment (dst is interchange): always CONVERGENCE
76
+ * - INTERNAL segment (both own): use trunk Y fallback
77
+ *
78
+ * @param {number} px - source x
79
+ * @param {number} py - source y
80
+ * @param {number} qx - destination x
81
+ * @param {number} qy - destination y
82
+ * @param {number} routeIdx - route index (used for deterministic variation)
83
+ * @param {number} segIdx - segment index within route
84
+ * @param {number} refY - reference Y for convergence/divergence detection
85
+ * @param {object} [options]
86
+ * @param {number} [options.progressivePower=2.2]
87
+ * @returns {string} SVG path data (without leading M)
88
+ */
89
+ export function angularPath(px, py, qx, qy, routeIdx, segIdx, refY, options = {}) {
90
+ const power = options.progressivePower ?? 2.2;
91
+ const dx = qx - px, dy = qy - py;
92
+ if (Math.abs(dy) < 1) return `L ${qx} ${qy}`; // horizontal
93
+ if (dx < 3) return `L ${qx} ${qy}`; // too tight
94
+
95
+ const srcDistFromRef = Math.abs(py - refY);
96
+ const dstDistFromRef = Math.abs(qy - refY);
97
+
98
+ const isConvergence = srcDistFromRef > dstDistFromRef + 0.5;
99
+ const isDivergence = srcDistFromRef + 0.5 < dstDistFromRef;
100
+
101
+ // Per-route variation for departure/arrival horizontal runs
102
+ const hash = ((routeIdx * 7 + segIdx * 13) % 17) / 17;
103
+
104
+ if (isConvergence) {
105
+ // Long horizontal at branch level (35-45%), then progressive curve to trunk
106
+ const departFrac = 0.35 + hash * 0.10;
107
+ const departX = px + dx * departFrac;
108
+ const remainDx = qx - departX;
109
+
110
+ if (remainDx < 5) return `L ${qx} ${qy}`;
111
+
112
+ let d = `L ${departX.toFixed(1)} ${py} `; // horizontal at branch level
113
+ d += progressiveCurve(departX, py, qx, qy, true, power); // progressive curve
114
+ return d;
115
+
116
+ } else if (isDivergence) {
117
+ // Progressive curve from trunk, then long horizontal at branch level (35-45%)
118
+ const arriveFrac = 0.35 + hash * 0.10;
119
+ const arriveX = qx - dx * arriveFrac;
120
+ const curveDx = arriveX - px;
121
+
122
+ if (curveDx < 5) return `L ${qx} ${qy}`;
123
+
124
+ let d = progressiveCurve(px, py, arriveX, qy, false, power); // progressive curve
125
+ d += `L ${qx} ${qy}`; // horizontal at branch level
126
+ return d;
127
+
128
+ } else {
129
+ // Same level — symmetric
130
+ const departFrac = 0.18 + hash * 0.08;
131
+ const arriveFrac = 0.18 + ((hash * 7) % 1) * 0.08;
132
+ const departX = px + dx * departFrac;
133
+ const arriveX = qx - dx * arriveFrac;
134
+ if (arriveX <= departX + 2) return `L ${qx} ${qy}`;
135
+ return `L ${departX.toFixed(1)} ${py} L ${arriveX.toFixed(1)} ${qy} L ${qx} ${qy}`;
136
+ }
137
+ }
@@ -0,0 +1,51 @@
1
+ // ================================================================
2
+ // route-bezier.js — Bezier S-curve routing (v5 style)
3
+ // ================================================================
4
+ // Cubic bezier S-curves for smooth, organic edge routing.
5
+ // Depart horizontal, curve through a C-shaped bend, arrive horizontal.
6
+ // The departure/arrival horizontal run length adapts to the dy/dx ratio.
7
+
8
+ /**
9
+ * Generate a bezier S-curve path segment from (px, py) to (qx, qy).
10
+ *
11
+ * @param {number} px - source x
12
+ * @param {number} py - source y
13
+ * @param {number} qx - destination x
14
+ * @param {number} qy - destination y
15
+ * @param {number} _routeIdx - (unused, kept for API consistency with angularPath)
16
+ * @param {number} _segIdx - (unused)
17
+ * @param {number} _refY - (unused)
18
+ * @returns {string} SVG path data (without leading M)
19
+ */
20
+ export function bezierPath(px, py, qx, qy, _routeIdx, _segIdx, _refY) {
21
+ const dx = qx - px, dy = qy - py;
22
+ if (Math.abs(dy) < 0.5) {
23
+ return `L ${qx} ${qy}`;
24
+ }
25
+
26
+ const absDy = Math.abs(dy);
27
+ const ratio = absDy / Math.max(dx, 1);
28
+
29
+ let departLen, arriveLen;
30
+ if (ratio < 0.4) {
31
+ departLen = dx * 0.30;
32
+ arriveLen = dx * 0.30;
33
+ } else if (ratio < 0.8) {
34
+ departLen = dx * 0.20;
35
+ arriveLen = dx * 0.20;
36
+ } else {
37
+ departLen = dx * 0.12;
38
+ arriveLen = dx * 0.12;
39
+ }
40
+
41
+ const x1 = px + departLen;
42
+ const x2 = qx - arriveLen;
43
+
44
+ // Cubic bezier: smooth S-curve
45
+ const cp1x = x1 + (x2 - x1) * 0.45;
46
+ const cp1y = py;
47
+ const cp2x = x1 + (x2 - x1) * 0.55;
48
+ const cp2y = qy;
49
+
50
+ return `L ${x1} ${py} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${qy} L ${qx} ${qy}`;
51
+ }