plotagents 0.0.0-stage → 0.7.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 (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
@@ -0,0 +1,232 @@
1
+ // Dona y torta: parte-todo de un vistazo, con 6 segmentos como máximo (el
2
+ // resto se agrupa en "Otros"). Etiquetas dentro cuando caben, afuera con
3
+ // líneas guía sin choques, o como tabla-leyenda al lado. Media dona opcional.
4
+ import { el, g, text, tip } from '../svg.js';
5
+ import { measure, truncate } from '../text.js';
6
+ import { makeFormat, formatNumber } from '../format.js';
7
+ import { colorMap, textOn } from '../theme.js';
8
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize } from '../data.js';
9
+ import { createFrame, hash } from '../frame.js';
10
+ import { dashedMatcher, hatcher, dashedDefaultLabel, DASH } from '../dashed.js';
11
+ import { ctx } from '../context.js';
12
+
13
+ const f = (n) => n.toFixed(2);
14
+
15
+ function arc(cx, cy, R, r, a0, a1) {
16
+ if (a1 - a0 >= Math.PI * 2 - 1e-6) a1 = a0 + Math.PI * 2 - 1e-4;
17
+ const large = a1 - a0 > Math.PI ? 1 : 0;
18
+ const p = (rad, a) => `${f(cx + rad * Math.cos(a))},${f(cy + rad * Math.sin(a))}`;
19
+ if (r <= 0.01) return `M${f(cx)},${f(cy)}L${p(R, a0)}A${f(R)},${f(R)} 0 ${large} 1 ${p(R, a1)}Z`;
20
+ return `M${p(R, a0)}A${f(R)},${f(R)} 0 ${large} 1 ${p(R, a1)}L${p(r, a1)}A${f(r)},${f(r)} 0 ${large} 0 ${p(r, a0)}Z`;
21
+ }
22
+
23
+ export function donut(spec, theme) {
24
+ const data = ensureData(spec);
25
+ const fields = fieldsOf(data);
26
+ const labelF = spec.label ?? spec.x ?? fields.find((k) => fieldKind(data, k) === 'category') ?? fields[0];
27
+ const valueF = spec.value ?? spec.y ?? fields.find((k) => k !== labelF && fieldKind(data, k) === 'number');
28
+ requireField(data, labelF, 'label');
29
+ requireField(data, valueF, 'value');
30
+ const locale = spec.locale || 'es';
31
+ const en = String(locale).startsWith('en');
32
+ const isPie = spec.thickness !== undefined && spec.thickness >= 1;
33
+ const half = !!spec.half;
34
+
35
+ const sums = new Map();
36
+ for (const r of data) {
37
+ const v = num(r[valueF]);
38
+ if (!(v > 0) || !Number.isFinite(v)) continue; // ≤ 0, vacíos e Infinity no tienen ángulo
39
+ const k = String(r[labelF]);
40
+ sums.set(k, (sums.get(k) || 0) + v);
41
+ }
42
+ let slices = [...sums.entries()].map(([k, v]) => ({ k, v }));
43
+ if ((spec.sort ?? 'desc') === 'desc') slices.sort((a, b) => b.v - a.v);
44
+ const max = spec.maxSlices ?? 6;
45
+ if (slices.length > max) {
46
+ const sorted = [...slices].sort((a, b) => b.v - a.v);
47
+ const keep = new Set(sorted.slice(0, max - 1).map((s) => s.k));
48
+ const rest = slices.filter((s) => !keep.has(s.k));
49
+ slices = slices.filter((s) => keep.has(s.k));
50
+ slices.push({ k: en ? 'Other' : 'Otros', v: rest.reduce((a, b) => a + b.v, 0), other: true });
51
+ }
52
+ const total = slices.reduce((a, b) => a + b.v, 0);
53
+ const fmt = makeFormat(spec.format, locale, slices.map((s) => s.v));
54
+ const cmap = colorMap(slices.filter((s) => !s.other).map((s) => s.k), theme, spec.colors);
55
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
56
+ const colorOf = (s) => (s.other ? theme.other : hl && !hl.has(s.k) ? theme.dim : cmap.get(s.k));
57
+ const pct = (v) => formatNumber((v / (total || 1)) * 100, (v / (total || 1)) * 100 < 10 ? 1 : 0, locale, true) + '%';
58
+ // Segmentos punteados: rayado del mismo color (p. ej. una parte estimada).
59
+ const dm = dashedMatcher(spec, data, labelF, null);
60
+ const isDashed = (s) => !!(dm && !s.other && dm(s.k));
61
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
62
+ const H = hatcher(theme, 'pa-d' + Math.abs(hash(String(spec.title) + theme.mode + labelF)).toString(36));
63
+ const paint = (s) => (isDashed(s) ? { fill: H.fill(colorOf(s)), 'data-dashed': '1', 'data-color': colorOf(s) } : { fill: colorOf(s) });
64
+ const tipOf = (s) => tip(s.k, `${fmt(s.v)} · ${pct(s.v)}`, isDashed(s) ? dashedLabel : '');
65
+
66
+ // Modo de etiquetas
67
+ let mode = spec.labels === false ? 'none' : spec.labels ?? 'auto';
68
+ if (mode === true) mode = 'auto';
69
+
70
+ const frame = createFrame(spec, theme, { height: half ? 320 : 380 });
71
+ const area = frame.layout([]);
72
+ const narrow = area.w < 470;
73
+ // Etiquetas exteriores solo si dejan un radio digno; si no, tabla-leyenda.
74
+ if (mode === 'outside') {
75
+ const lw = Math.min(area.w * 0.3, Math.max(...slices.map((s) => measure(s.k, 12.5) + 6 + measure(pct(s.v), 12.5, 600))));
76
+ if ((area.w - 2 * (lw + 30)) / 2 < Math.min(90, area.h / 2 - 10)) mode = 'auto';
77
+ }
78
+ const outside = mode === 'outside';
79
+ const table = mode === 'auto' || mode === 'legend' || mode === 'inside';
80
+ const inner = mode === 'auto' || mode === 'inside';
81
+ const body = [];
82
+
83
+ // Geometría
84
+ const rowH = Math.round(28 * Math.max(1, ctx.textScale * 0.9));
85
+ const tableW = table ? (narrow ? area.w : Math.min(340, area.w * 0.5)) : 0;
86
+ let cx, cy, R;
87
+ let tx = 0, ty = 0;
88
+ const outLabW = outside ? Math.min(area.w * 0.3, Math.max(...slices.map((s) => measure(s.k, 12.5) + 6 + measure(pct(s.v), 12.5, 600)))) : 0;
89
+ if (outside) {
90
+ const availW = (area.w - 2 * (outLabW + 30)) / 2;
91
+ R = half ? Math.min(availW, area.h - 46) : Math.min(availW, area.h / 2 - 10);
92
+ R = Math.max(50, R);
93
+ cx = area.x + area.w / 2;
94
+ cy = half ? area.y + Math.min(area.h - 40, R + 14) : area.y + area.h / 2;
95
+ } else if (table && narrow) {
96
+ R = half ? Math.min(area.w / 2 - 4, 170) : Math.min(area.w / 2 - 4, 150);
97
+ cx = area.x + area.w / 2;
98
+ cy = half ? area.y + R + 4 : area.y + R + 4;
99
+ tx = area.x;
100
+ ty = (half ? cy + 50 : cy + R + 22);
101
+ if (spec.height === undefined) frame.setHeight(ty + slices.length * rowH + frame.chrome);
102
+ } else if (table) {
103
+ const chartW = area.w - tableW - 32;
104
+ R = half ? Math.min(chartW / 2, area.h - 48) : Math.min(chartW / 2, area.h / 2);
105
+ cx = area.x + R;
106
+ cy = half ? area.y + Math.max(R + 6, (area.h + R) / 2 - 10) : area.y + area.h / 2;
107
+ tx = area.x + 2 * R + 32;
108
+ ty = (half ? cy - R / 2 : cy) - (slices.length * rowH) / 2;
109
+ ty = Math.max(area.y, ty);
110
+ } else {
111
+ R = half ? Math.min(area.w / 2, area.h - 48) : Math.min(area.w, area.h) / 2;
112
+ cx = area.x + area.w / 2;
113
+ cy = half ? area.y + Math.min(area.h - 40, R + 8) : area.y + area.h / 2;
114
+ }
115
+ const r0 = isPie ? 0 : R * (1 - (spec.thickness ?? (half ? 0.32 : 0.38)));
116
+ // Media dona: el alto se ajusta al semicírculo (o a la tabla, si es más alta).
117
+ if (half && spec.height === undefined && !(table && narrow)) {
118
+ const tableBottom = table ? ty + slices.length * rowH : 0;
119
+ frame.setHeight(Math.max(cy + 12, tableBottom) + frame.chrome);
120
+ }
121
+
122
+ // Arcos
123
+ const start = half ? Math.PI : -Math.PI / 2;
124
+ const span = half ? Math.PI : Math.PI * 2;
125
+ let a = start;
126
+ const geo = [];
127
+ for (const s of slices) {
128
+ const a1 = a + (s.v / total) * span;
129
+ geo.push({ s, a0: a, a1, mid: (a + a1) / 2 });
130
+ a = a1;
131
+ }
132
+ const sep = theme.marks?.gap ?? 2;
133
+ for (const q of geo) {
134
+ body.push(el('path', { class: 'pa-m', d: arc(cx, cy, R, r0, q.a0, q.a1), ...paint(q.s), stroke: theme.surface, strokeWidth: sep, strokeLinejoin: 'round', 'data-tip': tipOf(q.s) }));
135
+ // Borde discontinuo por dentro del segmento punteado (el borde exterior separa segmentos).
136
+ if (isDashed(q.s)) body.push(el('path', { d: arc(cx, cy, R - sep, r0 > 0 ? r0 + sep : 0, q.a0 + sep / R, q.a1 - sep / R), fill: 'none', stroke: H.stroke(colorOf(q.s)), strokeWidth: 1.2, strokeDasharray: DASH.outline, 'pointer-events': 'none' }));
137
+ }
138
+
139
+ // Porcentajes dentro de los segmentos que tienen espacio.
140
+ if (inner) {
141
+ const rl = isPie ? R * 0.64 : (R + r0) / 2;
142
+ const thick = R - r0;
143
+ for (const q of geo) {
144
+ const fill = colorOf(q.s);
145
+ const ink = isDashed(q.s) ? theme.ink : textOn(fill, theme);
146
+ const halo = isDashed(q.s) ? { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' } : {};
147
+ const p = pct(q.s.v);
148
+ const size = isPie ? 13 : 12;
149
+ const chord = (q.a1 - q.a0) * rl;
150
+ const x = cx + rl * Math.cos(q.mid), y = cy + rl * Math.sin(q.mid);
151
+ const showName = mode === 'inside' && chord >= measure(q.s.k, 11.5) + 12 && (isPie || thick >= 40);
152
+ if (chord >= measure(p, size, 600) + 10 && (isPie || thick >= size + 8)) {
153
+ body.push(text(x, y + (showName ? -2 : size * 0.35), p, { fill: ink, fontSize: size, fontWeight: 600, textAnchor: 'middle', pointerEvents: 'none', ...halo }));
154
+ if (showName) body.push(text(x, y + 13, q.s.k, { fill: ink, fontSize: 11.5, textAnchor: 'middle', opacity: 0.9, pointerEvents: 'none', ...halo }));
155
+ }
156
+ }
157
+ }
158
+
159
+ // Etiquetas exteriores con líneas guía, sin choques (una columna por lado).
160
+ if (outside) {
161
+ const lh = 17;
162
+ const items = geo.map((q) => ({ q, side: Math.cos(q.mid) >= 0 ? 1 : -1, y: cy + (R + 14) * Math.sin(q.mid) }));
163
+ const yMin = area.y + 8, yMax = half ? cy - 4 : area.y + area.h - 4;
164
+ for (const side of [1, -1]) {
165
+ const col = items.filter((it) => it.side === side).sort((m, n) => m.y - n.y);
166
+ for (let i = 0; i < col.length; i++) col[i].y = Math.max(col[i].y, i ? col[i - 1].y + lh : yMin);
167
+ for (let i = col.length - 1; i >= 0; i--) col[i].y = Math.min(col[i].y, i < col.length - 1 ? col[i + 1].y - lh : yMax);
168
+ for (let i = 0; i < col.length; i++) col[i].y = Math.max(col[i].y, i ? col[i - 1].y + lh : yMin);
169
+ }
170
+ for (const it of items) {
171
+ const { q, side } = it;
172
+ const p1x = cx + (R + 3) * Math.cos(q.mid), p1y = cy + (R + 3) * Math.sin(q.mid);
173
+ const ex = cx + (R + 14) * Math.cos(q.mid);
174
+ const lx = cx + side * (R + 26);
175
+ const strong = !hl || hl.has(q.s.k);
176
+ body.push(el('path', { d: `M${f(p1x)},${f(p1y)}L${f(ex)},${f(it.y)}L${f(lx - side * 4)},${f(it.y)}`, fill: 'none', stroke: theme.muted, strokeWidth: 1, opacity: 0.7 }));
177
+ const name = truncate(q.s.k, outLabW - measure(pct(q.s.v), 12.5, 600) - 5, 12.5);
178
+ const nameW = measure(name, 12.5);
179
+ const anchor = side > 0 ? 'start' : 'end';
180
+ const parts = side > 0
181
+ ? [text(lx, it.y + 4, name, { fill: strong ? theme.ink : theme.muted, fontSize: 12.5 }), text(lx + nameW + 6, it.y + 4, pct(q.s.v), { fill: theme.ink, fontSize: 12.5, fontWeight: 600 })]
182
+ : [text(lx, it.y + 4, pct(q.s.v), { fill: theme.ink, fontSize: 12.5, fontWeight: 600, textAnchor: anchor }), text(lx - measure(pct(q.s.v), 12.5, 600) - 6, it.y + 4, name, { fill: strong ? theme.ink : theme.muted, fontSize: 12.5, textAnchor: anchor })];
183
+ body.push(g({ 'data-tip': tipOf(q.s) }, parts));
184
+ }
185
+ }
186
+
187
+ // Centro: total o segmento destacado (solo dona).
188
+ if (!isPie) {
189
+ const focusSlice = hl ? slices.find((s) => hl.has(s.k)) : null;
190
+ const big = spec.centerValue ?? (focusSlice ? pct(focusSlice.v) : fmt(total));
191
+ const small = spec.centerLabel ?? (focusSlice ? focusSlice.k : 'Total');
192
+ let bigSize = Math.min(half ? 38 : 34, r0 * (half ? 0.55 : 0.5));
193
+ while (bigSize > 12 && measure(big, bigSize, 600) > r0 * 1.7) bigSize -= 1;
194
+ if (half) {
195
+ body.push(text(cx, cy - 6, big, { fill: theme.ink, fontSize: bigSize, fontWeight: 600, textAnchor: 'middle' }));
196
+ body.push(text(cx, cy - 6 - bigSize * ctx.textScale - 2, truncate(small, r0 * 1.6, 12), { fill: theme.lede, fontSize: 12, textAnchor: 'middle' }));
197
+ } else {
198
+ body.push(text(cx, cy + bigSize * ctx.textScale * 0.32 - 4 * (ctx.textScale - 1), big, { fill: theme.ink, fontSize: bigSize, fontWeight: 600, textAnchor: 'middle' }));
199
+ body.push(text(cx, cy + bigSize * ctx.textScale * 0.32 + 18 * ctx.textScale, truncate(small, r0 * 1.6, 12), { fill: theme.lede, fontSize: 12, textAnchor: 'middle' }));
200
+ }
201
+ }
202
+
203
+ // Tabla-leyenda: nombre, % y valor. La identidad nunca depende solo del color.
204
+ if (table) {
205
+ const lw = narrow ? area.w : Math.min(tableW, area.x + area.w - tx);
206
+ const valW = Math.max(...slices.map((s) => measure(fmt(s.v), 12.5)));
207
+ const pctW = Math.max(...slices.map((s) => measure(pct(s.v), 13, 600)));
208
+ slices.forEach((s, i) => {
209
+ const y = ty + i * rowH + rowH / 2;
210
+ const strong = !hl || hl.has(s.k);
211
+ const parts = [
212
+ el('rect', { x: tx, y: y - 15, width: lw, height: rowH, fill: 'transparent' }),
213
+ isDashed(s)
214
+ ? el('rect', { x: tx + 0.6, y: y - 4.4, width: 8.8, height: 8.8, rx: 2, fill: H.fill(colorOf(s)), stroke: H.stroke(colorOf(s)), strokeWidth: 1.1, strokeDasharray: DASH.legend })
215
+ : el('rect', { x: tx, y: y - 5, width: 10, height: 10, rx: 2.5, fill: colorOf(s) }),
216
+ text(tx + 18, y + 4.5, truncate(s.k, lw - 18 - valW - pctW - 28, 13), { fill: strong ? theme.ink : theme.muted, fontSize: 13 }),
217
+ text(tx + lw - valW - 14, y + 4.5, pct(s.v), { fill: theme.ink, fontSize: 13, fontWeight: 600, textAnchor: 'end' }),
218
+ text(tx + lw, y + 4.5, fmt(s.v), { fill: theme.muted, fontSize: 12.5, textAnchor: 'end' }),
219
+ ];
220
+ body.push(g({ 'data-tip': tipOf(s) }, parts));
221
+ });
222
+ }
223
+
224
+ const kind = isPie ? (en ? 'Pie chart' : 'Gráfico de torta') : en ? 'Donut chart' : 'Gráfico de dona';
225
+ const desc = `${kind} ${en ? 'of' : 'de'} ${humanize(valueF).toLowerCase()}. ` + slices.map((s) => `${s.k} ${pct(s.v)}`).join(', ') + '.';
226
+ return frame.finish(body, { desc, type: isPie ? 'pie' : 'donut', defs: H.defs() });
227
+ }
228
+
229
+ /** Torta: dona sin agujero, con porcentajes dentro. */
230
+ export function pie(spec, theme) {
231
+ return donut({ ...spec, thickness: 1 }, theme);
232
+ }
@@ -0,0 +1,407 @@
1
+ // Diagrama de flujo: nodos y conexiones con capas automáticas (Sugiyama
2
+ // simplificado), carriles opcionales por unidad, formas por tipo y
3
+ // conectores ortogonales con esquinas suaves que no atraviesan nodos.
4
+ import { el, g, text, tip } from '../svg.js';
5
+ import { measure, wrap } from '../text.js';
6
+ import { colorMap, readable, textOn, hexToRgb, rgbToHex } from '../theme.js';
7
+ import { ensureData, uniq, PlotError } from '../data.js';
8
+ import { createFrame, hash } from '../frame.js';
9
+
10
+ const T = (en, es, locale) => (String(locale || 'es').startsWith('en') ? en : es);
11
+ const FS = 12.5, LH = 16, EDGE_FS = 11;
12
+ const mix = (a, b, t) => { const A = hexToRgb(a), B = hexToRgb(b); return rgbToHex(A.map((v, i) => v + (B[i] - v) * t)); };
13
+
14
+ export function flowchart(spec, theme) {
15
+ const locale = spec.locale || 'es';
16
+ const en = String(locale).startsWith('en');
17
+
18
+ // ── Entrada ───────────────────────────────────────────────────────────────
19
+ const edgeRows = spec.edges ? ensureData({ data: spec.edges }) : spec.data ? ensureData(spec) : [];
20
+ const ff = spec.from ?? (edgeRows[0] && ('from' in edgeRows[0] ? 'from' : 'source' in edgeRows[0] ? 'source' : 'from'));
21
+ const tf = spec.to ?? (edgeRows[0] && ('to' in edgeRows[0] ? 'to' : 'target' in edgeRows[0] ? 'target' : 'to'));
22
+ const lf = spec.edgeLabel ?? 'label';
23
+ const nodeRows = spec.nodes ? ensureData({ data: spec.nodes }) : [];
24
+ const idF = spec.id ?? 'id';
25
+ const nodes = new Map();
26
+ for (const r of nodeRows) {
27
+ const id = String(r[idF] ?? r.label);
28
+ nodes.set(id, { id, label: String(r.label ?? r.name ?? id), type: String(r.type || 'process').toLowerCase(), lane: r.lane ?? r[spec.lane] ?? null, group: r.group ?? r[spec.group] ?? null, detail: r.detail ?? r.description ?? '' });
29
+ }
30
+ const edges = [];
31
+ for (const r of edgeRows) {
32
+ const a = r[ff], b = r[tf];
33
+ if (a === undefined || b === undefined || a === null || b === null) continue;
34
+ for (const id of [String(a), String(b)]) if (!nodes.has(id)) nodes.set(id, { id, label: id, type: 'process', lane: null, group: null, detail: '' });
35
+ edges.push({ s: String(a), t: String(b), label: r[lf] != null ? String(r[lf]) : '' });
36
+ }
37
+ if (!nodes.size) throw new PlotError(T('The flow chart has no nodes.', 'El diagrama de flujo no tiene nodos.', locale), T('Pass `nodes: [{ id, label, type }]` and `data: [{ from, to }]`.', 'Entrega `nodes: [{ id, label, type }]` y `data: [{ from, to }]`.', locale));
38
+ const N = [...nodes.values()];
39
+ const out = new Map(N.map((n) => [n.id, []])), inc = new Map(N.map((n) => [n.id, []]));
40
+ for (const e of edges) { out.get(e.s).push(e); inc.get(e.t).push(e); }
41
+
42
+ // ── Ciclos: DFS marca aristas de retorno ─────────────────────────────────
43
+ const state = new Map();
44
+ const roots = N.filter((n) => !inc.get(n.id).length);
45
+ const visit = (id) => {
46
+ state.set(id, 1);
47
+ for (const e of out.get(id)) {
48
+ const st = state.get(e.t);
49
+ if (st === 1) e.back = true;
50
+ else if (!st) visit(e.t);
51
+ }
52
+ state.set(id, 2);
53
+ };
54
+ for (const n of [...roots, ...N]) if (!state.get(n.id)) visit(n.id);
55
+ const fwd = edges.filter((e) => !e.back && e.s !== e.t);
56
+
57
+ // ── Capas: camino más largo ──────────────────────────────────────────────
58
+ const layer = new Map(N.map((n) => [n.id, 0]));
59
+ for (let it = 0; it < N.length; it++) {
60
+ let ch = false;
61
+ for (const e of fwd) if (layer.get(e.t) < layer.get(e.s) + 1) { layer.set(e.t, layer.get(e.s) + 1); ch = true; }
62
+ if (!ch) break;
63
+ }
64
+ // Los nodos sin entradas bajan junto a su primer sucesor (más compacto).
65
+ for (const n of N) if (!inc.get(n.id).length && out.get(n.id).length && n.type !== 'start') {
66
+ const m = Math.min(...out.get(n.id).filter((e) => !e.back).map((e) => layer.get(e.t)));
67
+ if (Number.isFinite(m)) layer.set(n.id, Math.max(0, m - 1));
68
+ }
69
+ const L = Math.max(...layer.values()) + 1;
70
+
71
+ // ── Carriles y orden dentro de cada capa (baricentros) ───────────────────
72
+ const laneKeys = spec.lanes ?? uniq(N.map((n) => n.lane).filter((v) => v !== null && v !== undefined && v !== '').map(String));
73
+ const hasLanes = laneKeys.length > 0 && spec.swimlanes !== false;
74
+ const laneOf = (n) => (hasLanes ? Math.max(0, laneKeys.indexOf(String(n.lane ?? laneKeys[0]))) : 0);
75
+ const layers = Array.from({ length: L }, () => []);
76
+ for (const n of N) layers[layer.get(n.id)].push(n);
77
+ const pos = new Map();
78
+ const reindex = () => layers.forEach((ls) => ls.forEach((n, i) => pos.set(n.id, laneOf(n) * 1000 + i)));
79
+ layers.forEach((ls) => ls.sort((a, b) => laneOf(a) - laneOf(b)));
80
+ reindex();
81
+ const bary = (n, nbrs) => { const ps = nbrs.map((id) => pos.get(id)).filter((v) => v !== undefined); return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : pos.get(n.id); };
82
+ for (let sweep = 0; sweep < 6; sweep++) {
83
+ for (let l = 1; l < L; l++) { layers[l].sort((a, b) => laneOf(a) - laneOf(b) || bary(a, inc.get(a.id).filter((e) => !e.back).map((e) => e.s)) - bary(b, inc.get(b.id).filter((e) => !e.back).map((e) => e.s))); reindex(); }
84
+ for (let l = L - 2; l >= 0; l--) { layers[l].sort((a, b) => laneOf(a) - laneOf(b) || bary(a, out.get(a.id).filter((e) => !e.back).map((e) => e.t)) - bary(b, out.get(b.id).filter((e) => !e.back).map((e) => e.t))); reindex(); }
85
+ }
86
+
87
+ // ── Colores por grupo (o neutros) ────────────────────────────────────────
88
+ const groupKeys = uniq(N.map((n) => n.group).filter((v) => v !== null && v !== undefined && v !== '').map(String));
89
+ const cmap = groupKeys.length ? colorMap(groupKeys, theme, spec.colors) : null;
90
+ const colorOf = (n) => (cmap && n.group != null ? cmap.get(String(n.group)) : null);
91
+ const legend = cmap && spec.legend !== false && groupKeys.length > 1 ? groupKeys.map((k) => ({ label: k, color: cmap.get(k), shape: 'square' })) : [];
92
+
93
+ const frame = createFrame(spec, theme, { height: 520 });
94
+ let area = frame.layout(legend);
95
+ const backs = edges.filter((e) => e.back || e.s === e.t);
96
+ // Los retornos rodean por el margen más cercano (izquierda/arriba o derecha/abajo).
97
+ const relPos = (n) => { const ls = layers[layer.get(n.id)]; return hasLanes ? (laneOf(n) + 0.5) / laneKeys.length : (ls.indexOf(n) + 0.5) / ls.length; };
98
+ for (const e of backs) e.side = (relPos(nodes.get(e.s)) + relPos(nodes.get(e.t))) / 2 < 0.5 ? -1 : 1;
99
+ const nBackL = backs.filter((e) => e.side < 0).length, nBackR = backs.length - nBackL;
100
+
101
+ // ── Dirección y tamaños ──────────────────────────────────────────────────
102
+ const maxPerCell = (lane) => Math.max(1, ...layers.map((ls) => ls.filter((n) => laneOf(n) === lane).length));
103
+ const roomL = nBackL ? 16 + nBackL * 9 : 0, roomR = nBackR ? 16 + nBackR * 9 : 0;
104
+ const backRoom = roomL + roomR;
105
+ const lrColW = area.w / L;
106
+ // LR cuando hay pocas capas y cada columna deja nodos de ≥ 92 px; si no, TB.
107
+ const longestWord = Math.max(...N.map((n) => Math.max(...String(n.label).split(/\s+/).map((w) => measure(w, FS, 600)))));
108
+ const dir = spec.direction ? String(spec.direction).toUpperCase() : L <= 6 && lrColW - 46 >= Math.max(hasLanes ? 110 : 80, longestWord + 26) ? 'LR' : 'TB';
109
+ // LR imposible (columnas de menos de 60 px): se vuelve a TB.
110
+ const TB = dir !== 'LR' || lrColW < 60;
111
+ // Espacio entre capas: en LR se ajusta para que todas las columnas quepan.
112
+ const gapU = TB ? 40 : Math.max(20, Math.min(46, lrColW - 64));
113
+ const gapV = area.w < 480 ? 10 : 18; // entre nodos de una misma capa
114
+ let laneHead = hasLanes ? 26 : 0;
115
+ const lanePad = hasLanes ? (area.w < 480 ? 5 : 10) : 0;
116
+ const nLanes = hasLanes ? laneKeys.length : 1;
117
+
118
+ // Ancho de nodo (en pantalla).
119
+ const laneBand = []; // límites del carril en el eje transversal (v)
120
+ const laneW = []; // ancho de nodo por carril (o uno global)
121
+ const maxW = spec.nodeWidth ?? (TB ? 220 : 200);
122
+ if (TB) {
123
+ const crossW = area.w - backRoom;
124
+ const x0 = area.x + roomL;
125
+ if (hasLanes) {
126
+ // Carriles con más nodos por capa reciben más ancho.
127
+ const wts = laneKeys.map((_, k) => 1 + 0.75 * (maxPerCell(k) - 1));
128
+ const unit = crossW / wts.reduce((a, b) => a + b, 0);
129
+ let bx = x0;
130
+ for (let k = 0; k < nLanes; k++) {
131
+ const bw = unit * wts[k];
132
+ laneBand.push([bx + 3, bx + bw - 3]);
133
+ bx += bw;
134
+ const c = maxPerCell(k);
135
+ laneW.push(Math.min(maxW, (bw - 6 - 2 * lanePad - (c - 1) * gapV) / c));
136
+ }
137
+ } else {
138
+ const C = Math.max(...layers.map((l) => l.length));
139
+ laneW.push(Math.min(maxW, (crossW - (C - 1) * gapV) / C));
140
+ }
141
+ } else {
142
+ laneW.push(Math.max(40, Math.min(maxW, lrColW - gapU)));
143
+ }
144
+ const widthOf = (n) => (TB ? Math.max(64, hasLanes ? laneW[laneOf(n)] : laneW[0]) : laneW[0]);
145
+ // Títulos de carril: hasta 2 líneas si no caben en el ancho de la banda.
146
+ const laneTitleLines = hasLanes ? laneKeys.map((k, i) => (TB ? wrap(k, laneBand[i][1] - laneBand[i][0] - 12, 12, 600, 2) : [k])) : [];
147
+ if (hasLanes && TB) laneHead = laneTitleLines.some((l) => l.length > 1) ? 40 : 26;
148
+
149
+ // Texto y alto de cada nodo. Si una palabra no cabe, se achica la letra de ese nodo.
150
+ for (const n of N) {
151
+ const isDec = n.type === 'decision';
152
+ const w = widthOf(n);
153
+ const tw = Math.max(30, isDec ? w * 0.64 : n.type === 'data' ? w - 34 : w - 22);
154
+ const wt = n.type === 'start' || n.type === 'end' ? 600 : 500;
155
+ // Margen de seguridad: las fuentes reales pueden ser algo más anchas que la medición.
156
+ const twSafe = tw * 0.92;
157
+ const longest = Math.max(...String(n.label).split(/\s+/).map((wd) => measure(wd, FS, wt)));
158
+ n.fs = longest > twSafe ? Math.max(9.5, FS * (twSafe / longest)) : FS;
159
+ n.lh = Math.round(n.fs * 1.3);
160
+ n.lines = wrap(n.label, twSafe, n.fs, wt);
161
+ const th = n.lines.length * n.lh;
162
+ n.w = w;
163
+ n.h = isDec ? Math.max(58, Math.ceil(th / 0.4) + 4) : Math.max(n.type === 'start' || n.type === 'end' ? 36 : 42, th + 20);
164
+ }
165
+
166
+ // ── Posiciones en coordenadas abstractas: u = flujo, v = transversal ─────
167
+ const fu = (n) => (TB ? n.h : n.w), fv = (n) => (TB ? n.w : n.h);
168
+ const rowSize = layers.map((ls) => Math.max(0, ...ls.map(fu)));
169
+ const u0 = TB ? area.y + laneHead : area.x;
170
+ const rowStart = [];
171
+ let acc = u0;
172
+ for (let l = 0; l < L; l++) { rowStart.push(acc); acc += rowSize[l] + gapU; }
173
+ const contentU = acc - gapU - u0;
174
+ for (let l = 0; l < L; l++) for (const n of layers[l]) n.u0 = rowStart[l] + (rowSize[l] - fu(n)) / 2;
175
+
176
+ if (TB) {
177
+ if (hasLanes) {
178
+ for (let l = 0; l < L; l++) for (let k = 0; k < nLanes; k++) {
179
+ const cell = layers[l].filter((n) => laneOf(n) === k);
180
+ const [b0, b1] = laneBand[k];
181
+ const slot = (b1 - b0) / Math.max(1, cell.length);
182
+ cell.forEach((n, i) => { n.vc = b0 + slot * (i + 0.5); });
183
+ }
184
+ } else {
185
+ const C = Math.max(...layers.map((l) => l.length));
186
+ const crossW = area.w - backRoom;
187
+ const slot = crossW / C;
188
+ for (const ls of layers) ls.forEach((n, i) => { n.vc = area.x + roomL + slot * ((C - ls.length) / 2 + i + 0.5); });
189
+ }
190
+ } else {
191
+ // LR: carriles como bandas horizontales con alto según su contenido.
192
+ const cellH = (k) => Math.max(0, ...layers.map((ls) => { const c = ls.filter((n) => laneOf(n) === k); return c.reduce((a, n) => a + n.h, 0) + Math.max(0, c.length - 1) * gapV; }));
193
+ let v = area.y + roomL;
194
+ for (let k = 0; k < nLanes; k++) {
195
+ const h = cellH(k) + 2 * lanePad + laneHead;
196
+ laneBand.push([v, v + h]);
197
+ v += h + (hasLanes ? 6 : 0);
198
+ }
199
+ for (let l = 0; l < L; l++) for (let k = 0; k < nLanes; k++) {
200
+ const cell = layers[l].filter((n) => laneOf(n) === k);
201
+ const [b0, b1] = laneBand[k];
202
+ const inner0 = b0 + laneHead + lanePad, inner1 = b1 - lanePad;
203
+ const tot = cell.reduce((a, n) => a + n.h, 0) + Math.max(0, cell.length - 1) * gapV;
204
+ let y = (inner0 + inner1) / 2 - tot / 2;
205
+ cell.forEach((n) => { n.vc = y + n.h / 2; y += n.h + gapV; });
206
+ }
207
+ }
208
+ for (const n of N) { n.u1 = n.u0 + fu(n); n.uc = (n.u0 + n.u1) / 2; n.v0 = n.vc - fv(n) / 2; n.v1 = n.vc + fv(n) / 2; }
209
+
210
+ // Alto final.
211
+ const contentBottom = TB ? u0 + contentU : Math.max(...laneBand.map((b) => b[1]));
212
+ const extraBack = !TB ? roomR : 0;
213
+ if (spec.height === undefined) {
214
+ frame.setHeight(contentBottom + extraBack + 8 + frame.chrome);
215
+ area = { ...area, h: frame.bottom - area.y };
216
+ }
217
+ const P = (u, v) => (TB ? [v, u] : [u, v]); // abstracto → pantalla
218
+
219
+ // ── Ruteo ortogonal ──────────────────────────────────────────────────────
220
+ const free = (v, l, pad = 8) => layers[l].every((n) => v < n.v0 - pad || v > n.v1 + pad);
221
+ const corridorCandidates = (s, t) => {
222
+ const c = [t.vc, s.vc];
223
+ const allV = N.flatMap((n) => [n.v0 - gapV / 2, n.v1 + gapV / 2]);
224
+ c.push(...allV.sort((a, b) => Math.abs(a - (s.vc + t.vc) / 2) - Math.abs(b - (s.vc + t.vc) / 2)));
225
+ return c;
226
+ };
227
+ let maxV = Math.max(...N.map((n) => n.v1)), minV = Math.min(...N.map((n) => n.v0));
228
+ if (hasLanes) { maxV = Math.max(maxV, laneBand[laneBand.length - 1][1]); minV = Math.min(minV, laneBand[0][0]); }
229
+ const routes = [];
230
+ let kL = 0, kR = 0;
231
+ for (const e of edges) {
232
+ const s = nodes.get(e.s), t = nodes.get(e.t);
233
+ let pts;
234
+ if (e.back || e.s === e.t) {
235
+ // Sale por el hueco tras su capa, rodea por el margen y entra por arriba del destino.
236
+ const vb = e.side < 0 ? minV - 10 - 9 * kL++ : maxV + 10 + 9 * kR++;
237
+ const ls = layer.get(e.s), lt = layer.get(e.t);
238
+ const ugS = rowStart[ls] + rowSize[ls] + gapU / 2;
239
+ const ugT = rowStart[lt] - gapU / 2;
240
+ void ugT;
241
+ // Entra por el costado del destino que mira al margen.
242
+ pts = [[s.u1, s.vc], [ugS, s.vc], [ugS, vb], [t.uc, vb], [t.uc, e.side < 0 ? t.v0 : t.v1]];
243
+ routes.push({ e, pts: simplify(pts), back: true });
244
+ continue;
245
+ }
246
+ const ls = layer.get(e.s), lt = layer.get(e.t);
247
+ const ugS = rowStart[ls] + rowSize[ls] + gapU / 2;
248
+ const ugT = rowStart[lt] - gapU / 2;
249
+ // Decisiones: la salida "principal" (siguiente capa, alineada) va abajo; las demás, por el costado.
250
+ const fwdOut = out.get(s.id).filter((x) => !x.back && x.s !== x.t);
251
+ const main = fwdOut.find((x) => layer.get(x.t) === ls + 1 && Math.abs(nodes.get(x.t).vc - s.vc) < 1) || null;
252
+ const side = s.type === 'decision' && fwdOut.length > 1 && main !== e;
253
+ const between = (v) => { for (let l = ls + 1; l < lt; l++) if (!free(v, l)) return false; return true; };
254
+ if (side && Math.abs(t.vc - s.vc) < 1) {
255
+ // Mismo eje que la decisión: sale por un costado libre y baja por un corredor.
256
+ const dirV = layers[ls].some((n) => n !== s && n.vc < s.vc) ? 1 : -1;
257
+ const sv = dirV > 0 ? s.v1 : s.v0;
258
+ const lanesEdge = hasLanes ? laneBand[laneOf(s)] : null;
259
+ let vc = sv + dirV * 22;
260
+ if (lanesEdge) vc = dirV > 0 ? Math.min(vc, lanesEdge[1] - 6) : Math.max(vc, lanesEdge[0] + 6);
261
+ const ok = (v) => { for (let l = ls + 1; l < lt; l++) if (!free(v, l, 6)) return false; return true; };
262
+ if (!ok(vc)) vc = corridorCandidates(s, t).find((v) => ok(v) && Math.sign(v - s.vc) === dirV) ?? (dirV > 0 ? maxV + 12 : minV - 12);
263
+ pts = [[s.uc, sv], [s.uc, vc], [ugT, vc], [ugT, t.vc], [t.u0, t.vc]];
264
+ } else if (side) {
265
+ // Decisión: sale por el costado hacia la columna del destino.
266
+ const sv = t.vc > s.vc ? s.v1 : s.v0;
267
+ if (between(t.vc)) {
268
+ pts = [[s.uc, sv], [s.uc, t.vc], [t.u0, t.vc]];
269
+ // Si el tramo transversal a la altura del nodo choca con un vecino, baja al hueco.
270
+ const hit = layers[ls].some((n) => n !== s && Math.min(sv, t.vc) < n.v1 && Math.max(sv, t.vc) > n.v0);
271
+ if (hit) pts = [[s.uc, sv], [s.uc, sv + (t.vc > s.vc ? 12 : -12)], [ugS, sv + (t.vc > s.vc ? 12 : -12)], [ugS, t.vc], [t.u0, t.vc]];
272
+ }
273
+ }
274
+ if (!pts) {
275
+ if (lt === ls + 1) {
276
+ pts = Math.abs(s.vc - t.vc) < 1 ? [[s.u1, s.vc], [t.u0, t.vc]] : [[s.u1, s.vc], [ugS, s.vc], [ugS, t.vc], [t.u0, t.vc]];
277
+ } else {
278
+ const vc = corridorCandidates(s, t).find(between) ?? maxV + 14;
279
+ pts = [[s.u1, s.vc], [ugS, s.vc], [ugS, vc], [ugT, vc], [ugT, t.vc], [t.u0, t.vc]];
280
+ }
281
+ }
282
+ routes.push({ e, pts: simplify(pts), back: false });
283
+ }
284
+
285
+ // ── Dibujo ───────────────────────────────────────────────────────────────
286
+ const body = [];
287
+ const mid = 'pa-arrow-' + Math.abs(hash(String(spec.title) + N.length + area.w)).toString(36);
288
+ const edgeColor = theme.muted;
289
+ // Marcador escrito a mano: refX/markerWidth deben conservar mayúsculas.
290
+ const defs = `<marker id="${mid}" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="10" markerHeight="10" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M1,1.5L9,5L1,8.5Z" fill="${edgeColor}"/></marker>`;
291
+
292
+ // Carriles: bandas tintadas con título.
293
+ if (hasLanes) {
294
+ const parts = [];
295
+ laneKeys.forEach((k, i) => {
296
+ const [b0, b1] = laneBand[i];
297
+ if (TB) {
298
+ const y0 = area.y, y1 = contentBottom + (backs.length ? 0 : 0) + 6;
299
+ parts.push(el('rect', { x: b0, y: y0, width: b1 - b0, height: y1 - y0, rx: 10, fill: theme.tint, opacity: 0.7 }));
300
+ laneTitleLines[i].forEach((ln, j) => parts.push(text((b0 + b1) / 2, y0 + 17 + j * 14, ln, { fill: theme.lede, fontSize: 12, fontWeight: 600, textAnchor: 'middle' })));
301
+ } else {
302
+ const x0 = area.x, x1 = area.x + area.w;
303
+ parts.push(el('rect', { x: x0, y: b0, width: x1 - x0, height: b1 - b0, rx: 10, fill: theme.tint, opacity: 0.7 }));
304
+ parts.push(text(x0 + 12, b0 + 17, k, { fill: theme.lede, fontSize: 12, fontWeight: 600 }));
305
+ }
306
+ });
307
+ body.push(g({ class: 'pa-lanes' }, parts));
308
+ }
309
+
310
+ // Conectores
311
+ const edgeParts = [], labelParts = [];
312
+ for (const r of routes) {
313
+ const screen = r.pts.map(([u, v]) => P(u, v));
314
+ edgeParts.push(el('path', { d: roundPath(screen, 8), fill: 'none', stroke: edgeColor, strokeWidth: 1.4, strokeLinejoin: 'round', markerEnd: `url(#${mid})` }));
315
+ if (r.e.label) {
316
+ // Etiqueta sobre el tramo más largo de los dos primeros.
317
+ let best = 0, bl = -1;
318
+ for (let i = 0; i < Math.min(2, screen.length - 1); i++) {
319
+ const l = Math.hypot(screen[i + 1][0] - screen[i][0], screen[i + 1][1] - screen[i][1]);
320
+ if (l > bl) { bl = l; best = i; }
321
+ }
322
+ const [ax, ay] = screen[best], [bx, by] = screen[best + 1];
323
+ const t = bl > 70 ? 0.4 : 0.5;
324
+ const lx = ax + (bx - ax) * t, ly = ay + (by - ay) * t;
325
+ const w = measure(r.e.label, EDGE_FS, 600) + 12;
326
+ labelParts.push(g({ class: 'pa-edge-label' }, [
327
+ el('rect', { x: lx - w / 2, y: ly - 9, width: w, height: 18, rx: 9, fill: theme.surface, stroke: theme.grid, strokeWidth: 1 }),
328
+ text(lx, ly + 4, r.e.label, { fill: theme.lede, fontSize: EDGE_FS, fontWeight: 600, textAnchor: 'middle' }),
329
+ ]));
330
+ }
331
+ }
332
+ body.push(g({ class: 'pa-edges' }, edgeParts));
333
+
334
+ // Nodos
335
+ const nodeParts = [];
336
+ for (const n of N) {
337
+ const [x, y] = TB ? [n.v0, n.u0] : [n.u0, n.v0];
338
+ const c = colorOf(n);
339
+ const neutral = theme.ink;
340
+ let shape, fill = theme.surface, stroke = c || (theme.mode === 'dark' ? theme.lede : theme.lede), sw = 1.5, tcol = theme.ink;
341
+ if (n.type === 'start' || n.type === 'end') {
342
+ fill = c || neutral; stroke = 'none'; tcol = textOn(fill, theme);
343
+ shape = el('rect', { x, y, width: n.w, height: n.h, rx: n.h / 2, fill, stroke });
344
+ } else if (n.type === 'decision') {
345
+ stroke = c || theme.categorical[0];
346
+ fill = mix(theme.surface, stroke, theme.mode === 'dark' ? 0.16 : 0.07);
347
+ shape = el('path', { d: `M${x + n.w / 2},${y}L${x + n.w},${y + n.h / 2}L${x + n.w / 2},${y + n.h}L${x},${y + n.h / 2}Z`, fill, stroke, strokeWidth: sw, strokeLinejoin: 'round' });
348
+ } else if (n.type === 'data') {
349
+ const k = 10;
350
+ shape = el('path', { d: `M${x + k},${y}H${x + n.w}L${x + n.w - k},${y + n.h}H${x}Z`, fill, stroke, strokeWidth: sw, strokeLinejoin: 'round' });
351
+ } else if (n.type === 'note') {
352
+ fill = c ? mix(theme.surface, c, 0.12) : theme.tint2; stroke = 'none'; tcol = theme.lede;
353
+ shape = el('rect', { x, y, width: n.w, height: n.h, rx: 6, fill });
354
+ } else {
355
+ fill = c ? mix(theme.surface, c, theme.mode === 'dark' ? 0.14 : 0.06) : theme.surface;
356
+ shape = el('rect', { x, y, width: n.w, height: n.h, rx: 9, fill, stroke, strokeWidth: sw });
357
+ }
358
+ const bold = n.type === 'start' || n.type === 'end' ? 600 : 500;
359
+ const ty0 = y + n.h / 2 - (n.lines.length * n.lh) / 2 + n.lh * 0.74;
360
+ const texts = n.lines.map((l, i) => text(x + n.w / 2, ty0 + i * n.lh, l, { fill: tcol, fontSize: n.fs, fontWeight: bold, textAnchor: 'middle' }));
361
+ const kind = { start: T('Start', 'Inicio', locale), end: T('End', 'Fin', locale), decision: T('Decision', 'Decisión', locale), data: T('Data', 'Dato', locale), note: T('Note', 'Nota', locale), process: T('Step', 'Paso', locale) }[n.type] || '';
362
+ nodeParts.push(g({ class: 'pa-m', 'data-tip': tip(n.label, n.detail, [kind, n.lane, n.group].filter(Boolean).join(' · ')) }, [shape, ...texts]));
363
+ }
364
+ body.push(g({ class: 'pa-nodes' }, nodeParts));
365
+ body.push(g({}, labelParts));
366
+ void readable;
367
+
368
+ const decisions = N.filter((n) => n.type === 'decision').length;
369
+ const desc = en
370
+ ? `Flow chart with ${N.length} steps${decisions ? `, ${decisions} decisions` : ''}${hasLanes ? ` across ${nLanes} lanes (${laneKeys.join(', ')})` : ''}. Starts at "${(roots[0] || N[0]).label}".`
371
+ : `Diagrama de flujo con ${N.length} pasos${decisions ? `, ${decisions} decisiones` : ''}${hasLanes ? ` en ${nLanes} carriles (${laneKeys.join(', ')})` : ''}. Comienza en "${(roots[0] || N[0]).label}".`;
372
+ const svg = frame.finish(body, { desc, type: 'flowchart', defs });
373
+ if (spec._layout) Object.assign(spec._layout, { direction: TB ? 'TB' : 'LR', nodes: N.map((n) => { const [x, y] = TB ? [n.v0, n.u0] : [n.u0, n.v0]; return { id: n.id, x, y, w: n.w, h: n.h }; }) });
374
+ return svg;
375
+ }
376
+
377
+ function simplify(pts) {
378
+ const out = [];
379
+ for (const p of pts) {
380
+ const last = out[out.length - 1];
381
+ if (last && Math.abs(last[0] - p[0]) < 0.5 && Math.abs(last[1] - p[1]) < 0.5) continue;
382
+ out.push(p);
383
+ }
384
+ // Quita puntos colineales.
385
+ return out.filter((p, i) => {
386
+ if (i === 0 || i === out.length - 1) return true;
387
+ const a = out[i - 1], b = out[i + 1];
388
+ return !((Math.abs(a[0] - p[0]) < 0.5 && Math.abs(p[0] - b[0]) < 0.5) || (Math.abs(a[1] - p[1]) < 0.5 && Math.abs(p[1] - b[1]) < 0.5));
389
+ });
390
+ }
391
+
392
+ /** Polilínea con esquinas redondeadas. */
393
+ function roundPath(pts, r) {
394
+ if (pts.length < 2) return '';
395
+ const f = (n) => n.toFixed(1);
396
+ let d = `M${f(pts[0][0])},${f(pts[0][1])}`;
397
+ for (let i = 1; i < pts.length - 1; i++) {
398
+ const [x0, y0] = pts[i - 1], [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
399
+ const l1 = Math.hypot(x1 - x0, y1 - y0), l2 = Math.hypot(x2 - x1, y2 - y1);
400
+ const rr = Math.min(r, l1 / 2, l2 / 2);
401
+ const ax = x1 - ((x1 - x0) / l1) * rr, ay = y1 - ((y1 - y0) / l1) * rr;
402
+ const bx = x1 + ((x2 - x1) / l2) * rr, by = y1 + ((y2 - y1) / l2) * rr;
403
+ d += `L${f(ax)},${f(ay)}Q${f(x1)},${f(y1)} ${f(bx)},${f(by)}`;
404
+ }
405
+ const [lx, ly] = pts[pts.length - 1];
406
+ return d + `L${f(lx)},${f(ly)}`;
407
+ }