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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/turbometro.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +107 -0
- package/dist/find-root.d.ts +2 -0
- package/dist/find-root.js +14 -0
- package/dist/graph.d.ts +14 -0
- package/dist/graph.js +74 -0
- package/dist/layout.d.ts +19 -0
- package/dist/layout.js +15 -0
- package/dist/parse-turbo.d.ts +5 -0
- package/dist/parse-turbo.js +29 -0
- package/dist/parse-workspace.d.ts +4 -0
- package/dist/parse-workspace.js +100 -0
- package/dist/render-html.d.ts +8 -0
- package/dist/render-html.js +699 -0
- package/dist/render-map.d.ts +29 -0
- package/dist/render-map.js +287 -0
- package/dist/replay.d.ts +9 -0
- package/dist/replay.js +74 -0
- package/dist/scale.d.ts +7 -0
- package/dist/scale.js +19 -0
- package/dist/scene-data.d.ts +40 -0
- package/dist/scene-data.js +146 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/fixtures/mini-mono/apps/web/package.json +11 -0
- package/fixtures/mini-mono/package.json +5 -0
- package/fixtures/mini-mono/packages/ui/package.json +11 -0
- package/fixtures/mini-mono/packages/utils/package.json +8 -0
- package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
- package/fixtures/mini-mono/turbo.json +10 -0
- package/package.json +44 -0
- package/vendor/dag-map/LICENSE +201 -0
- package/vendor/dag-map/NOTICE +6 -0
- package/vendor/dag-map/src/color-scales.js +61 -0
- package/vendor/dag-map/src/dag-map.css +63 -0
- package/vendor/dag-map/src/events.js +108 -0
- package/vendor/dag-map/src/graph-utils.js +185 -0
- package/vendor/dag-map/src/hasse.css +61 -0
- package/vendor/dag-map/src/index.js +55 -0
- package/vendor/dag-map/src/layout-flow.js +1066 -0
- package/vendor/dag-map/src/layout-hasse.js +485 -0
- package/vendor/dag-map/src/layout-metro.js +542 -0
- package/vendor/dag-map/src/occupancy.js +138 -0
- package/vendor/dag-map/src/render-flow-station.js +132 -0
- package/vendor/dag-map/src/render.js +360 -0
- package/vendor/dag-map/src/route-angular.js +137 -0
- package/vendor/dag-map/src/route-bezier.js +51 -0
- package/vendor/dag-map/src/route-metro.js +122 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|