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,370 @@
1
+ // Regresión al estilo seaborn: regplot / lmplot (puntos + ajuste + banda de
2
+ // confianza), residplot (residuos) y jointplot (dispersión con marginales).
3
+ import { el, g, text, tip, esc, barPath, dotLayer } from '../svg.js';
4
+ import { measure } from '../text.js';
5
+ import { makeFormat, formatNumber } from '../format.js';
6
+ import { colorMap, readable } from '../theme.js';
7
+ import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize, PlotError } from '../data.js';
8
+ import { yAxisMargin, yAxisTop, yGrid, yTickLabels, gridX, quietCorner } from '../axes.js';
9
+ import { createFrame } from '../frame.js';
10
+ import { polyfit, lowess, histogram, kde as kdeEst } from '../stats.js';
11
+ import { hueField, axisDomain, tickFmt, axisTitles } from './dist.js';
12
+
13
+ const EN = (spec) => String(spec.locale || 'es').startsWith('en');
14
+ const halo = (theme) => ({ stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' });
15
+ const dec = (v, locale, d = 2) => formatNumber(v, d, locale);
16
+
17
+ /** Lee puntos (x, y, grupo, nombre) válidos para escalas log si corresponde. */
18
+ function readPoints(spec, data, { needY = true } = {}) {
19
+ const fields = fieldsOf(data);
20
+ const numeric = fields.filter((f) => ['number', 'year'].includes(fieldKind(data, f)));
21
+ const x = spec.x || numeric[0];
22
+ const y = spec.y || numeric.find((f) => f !== x);
23
+ requireField(data, x, 'x');
24
+ if (needY) requireField(data, y, 'y');
25
+ const hue = hueField(spec);
26
+ if (hue) requireField(data, hue, 'hue');
27
+ const labelF = spec.label ?? null;
28
+ if (labelF) requireField(data, labelF, 'label');
29
+ const xLog = spec.xScale === 'log' || (spec.logx && spec.xScale === undefined);
30
+ const yLog = spec.yScale === 'log';
31
+ const pts = data
32
+ .map((r) => ({ x: num(r[x]), y: num(r[y]), k: hue ? String(r[hue]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
33
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!xLog || p.x > 0) && (!yLog || p.y > 0));
34
+ if (!pts.length) {
35
+ const log = xLog || yLog;
36
+ throw EN(spec)
37
+ ? new PlotError(`No row has numeric values in both \`${x}\` and \`${y}\`${log ? ' (> 0 for a log scale)' : ''}.`, `Check that \`x\` and \`y\` are measures with numbers${log ? ', or drop the log scale' : ''}; to compare a measure across categories use \`type: "box"\`.`)
38
+ : new PlotError(`Ninguna fila tiene valores numéricos en \`${x}\` y \`${y}\`${log ? ' (> 0 para escala log)' : ''}.`, `Revisa que \`x\` e \`y\` sean medidas con números${log ? ', o quita la escala log' : ''}; para comparar una medida entre categorías usa \`type: "box"\`.`);
39
+ }
40
+ const keys = hue ? uniq(pts.map((p) => p.k)) : [''];
41
+ return { x, y, hue, labelF, pts, keys, xLog, yLog };
42
+ }
43
+
44
+ /** Ajuste de un grupo: devuelve { line: [[x,y]], band: [[x,lo,hi]] | null, r2, n }. */
45
+ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
46
+ const fx = (v) => (spec.logx ? Math.log10(v) : v);
47
+ const xs = gp.map((p) => fx(p.x)), ys = gp.map((p) => p.y);
48
+ const lo = spec.truncate === false ? xMin : Math.min(...gp.map((p) => p.x));
49
+ const hi = spec.truncate === false ? xMax : Math.max(...gp.map((p) => p.x));
50
+ const grid = Array.from({ length: steps }, (_, i) => {
51
+ const t = i / (steps - 1);
52
+ return xLog ? 10 ** (Math.log10(lo) + (Math.log10(hi) - Math.log10(lo)) * t) : lo + (hi - lo) * t;
53
+ });
54
+ if (spec.lowess) {
55
+ const lw = lowess(xs, ys, { frac: spec.frac ?? 2 / 3 });
56
+ const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
57
+ return { line: pts, band: null, r2: null, n: gp.length };
58
+ }
59
+ const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci ?? 95) / 100 });
60
+ const line = grid.map((v) => [v, fit.predict(fx(v))]);
61
+ const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
62
+ return { line, band, r2: fit.r2, n: fit.n, fit };
63
+ }
64
+
65
+ // ── regplot / lmplot ───────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Dispersión con recta (o polinomio / LOWESS) ajustada y banda de confianza.
69
+ * Opciones: x, y, hue, ci (95 | false), order, lowess, logx, truncate (true),
70
+ * scatter (true), annotate, label + labels, xScale/yScale 'log', xDomain, yDomain.
71
+ */
72
+ export function regplot(spec, theme) {
73
+ const data = ensureData(spec);
74
+ const P = readPoints(spec, data);
75
+ const { pts, keys, xLog, yLog } = P;
76
+ const en = EN(spec);
77
+ const locale = spec.locale || 'es';
78
+ const K = keys.length;
79
+ const cmap = colorMap(keys, theme, spec.colors);
80
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
81
+ const focusKey = (k) => !hl || hl.has(k);
82
+ const colorOf = (k) => (!focusKey(k) ? theme.dim : K === 1 ? (spec.colors && spec.colors[k]) || theme.categorical[0] : cmap.get(k));
83
+ const legend = K > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: colorOf(k) })) : [];
84
+ const frame = createFrame(spec, theme, { height: 440 });
85
+ const area = frame.layout(legend);
86
+ const M = theme.marks || {};
87
+
88
+ const xAx0 = axisDomain(pts.map((p) => p.x), { type: xLog ? 'log' : undefined, domain: spec.xDomain, nice: 'tight', count: Math.max(3, Math.round(area.w / 110)) });
89
+ const fits = keys.map((k) => fitGroup(pts.filter((p) => p.k === k), spec, { xLog, xMin: xAx0.min, xMax: xAx0.max }));
90
+ const yVals = pts.map((p) => p.y).concat(fits.flatMap((f) => f.line.map((q) => q[1])));
91
+ const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
92
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
93
+ const yAx = axisDomain(yVals.filter((v) => !yLog || v > 0), { type: yLog ? 'log' : undefined, domain: spec.yDomain, nice: 'tight', count: Math.max(3, Math.round(area.h / 64)) });
94
+ const yt = tickFmt(fy, yAx);
95
+ const leftM = yAxisMargin(yAx.ticks, yt, theme);
96
+
97
+ // Etiquetas al final de cada recta cuando hay grupos.
98
+ const endLabels = K > 1 && spec.labels !== false && spec.endLabels !== false;
99
+ const endText = (k, f) => (spec.annotate && f.r2 !== null ? `${k} · R² ${dec(f.r2, locale)}` : k);
100
+ const endW = endLabels ? Math.max(...keys.map((k, i) => measure(endText(k, fits[i]), 12, 600))) + 12 : 0;
101
+ const top = 18 + yAxisTop(theme);
102
+ const r = spec.radius ?? (pts.length > 400 ? 2.5 : pts.length > 120 ? 3.25 : M.pointRadius ?? 4);
103
+ const inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM - endW - 4, h: area.h - top - 44 };
104
+ const pad = r + 2;
105
+ const xS = xAx0.scale(inner.x + pad, inner.x + inner.w - pad);
106
+ const yS = yAx.scale(inner.y + inner.h - pad, inner.y + pad);
107
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx0.min, xAx0.max]; spec._domains.y = [yAx.min, yAx.max]; }
108
+ const clipId = 'pa-clip-reg-' + Math.abs(hashStr((spec.title || '') + inner.w + K)).toString(36);
109
+
110
+ const body = [];
111
+ body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
112
+ body.push(yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN }));
113
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: inner.y + inner.h + 0.5, y2: inner.y + inner.h + 0.5, stroke: theme.axis }));
114
+
115
+ const L = (arr) => arr.map(([a, b], i) => `${i ? 'L' : 'M'}${xS(a).toFixed(2)},${yS(Math.max(yLog ? 1e-12 : -Infinity, b)).toFixed(2)}`).join('');
116
+ // Bandas primero, luego puntos, luego rectas encima.
117
+ keys.forEach((k, i) => {
118
+ const f = fits[i];
119
+ if (!f.band || !focusKey(k)) return;
120
+ const upper = f.band.map(([x, , hi]) => [x, hi]);
121
+ const lower = f.band.map(([x, lo]) => [x, lo]).reverse();
122
+ body.push(el('path', { d: L(upper) + 'L' + L(lower).slice(1) + 'Z', fill: colorOf(k), fillOpacity: M.bandOpacity ?? 0.16, clipPath: `url(#${clipId})` }));
123
+ });
124
+ if (spec.scatter !== false) {
125
+ const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
126
+ const dots = order.map((p) => ({
127
+ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: K === 1 ? (M.dotOpacity ?? 0.85) * 0.75 : M.dotOpacity ?? 0.85,
128
+ stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
129
+ 'data-tip': tip(p.name || p.k || '', `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : ''),
130
+ }));
131
+ body.push(g({ class: 'pa-points' }, dotLayer(dots)));
132
+ }
133
+ const ends = [];
134
+ keys.forEach((k, i) => {
135
+ const f = fits[i];
136
+ const c = K === 1 && spec.scatter !== false ? theme.ink : colorOf(k);
137
+ const stats = f.r2 !== null ? `R² = ${dec(f.r2, locale)} · n = ${formatNumber(f.n, 0, locale)}` : `n = ${formatNumber(f.n, 0, locale)}`;
138
+ body.push(g({ class: 'pa-m', 'data-tip': tip(k || (en ? 'Fit' : 'Ajuste'), spec.lowess ? 'LOWESS' : (spec.order ?? 1) > 1 ? (en ? `Polynomial (order ${spec.order})` : `Polinomio (orden ${spec.order})`) : en ? 'Linear fit' : 'Ajuste lineal', stats) }, [
139
+ el('path', { d: L(f.line), fill: 'none', stroke: 'transparent', strokeWidth: 12, clipPath: `url(#${clipId})` }),
140
+ el('path', { d: L(f.line), fill: 'none', stroke: c, strokeWidth: focusKey(k) ? (M.lineWidth ?? 2) + 0.25 : 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', opacity: K === 1 ? 0.9 : 1, clipPath: `url(#${clipId})` }),
141
+ ]));
142
+ const last = f.line[f.line.length - 1];
143
+ if (endLabels && focusKey(k)) ends.push({ k, y: yS(last[1]), y0: yS(last[1]), x: xS(last[0]) + 8, t: endText(k, f) });
144
+ });
145
+ // Etiquetas finales: separación mínima de 14 px, con línea guía si se mueven.
146
+ ends.sort((a, b) => a.y - b.y);
147
+ for (let it = 0; it < 30; it++) for (let i = 1; i < ends.length; i++) { const d = ends[i].y - ends[i - 1].y; if (d < 14) { const s = (14 - d) / 2; ends[i].y += s; ends[i - 1].y -= s; } }
148
+ for (const e of ends) {
149
+ const lx = inner.x + inner.w + 6;
150
+ if (Math.abs(e.y - e.y0) > 2 || e.x < lx - 10) body.push(el('path', { d: `M${(e.x - 6).toFixed(1)},${e.y0.toFixed(1)}L${(lx - 2).toFixed(1)},${e.y.toFixed(1)}`, stroke: colorOf(e.k), strokeOpacity: 0.7, strokeWidth: 1, fill: 'none' }));
151
+ body.push(text(lx, e.y + 4, e.t, { fill: readable(colorOf(e.k), theme), fontSize: 12, fontWeight: 600 }));
152
+ }
153
+ // Rótulos de puntos seleccionados.
154
+ body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
155
+ // Resumen discreto (un solo grupo).
156
+ if (spec.annotate && K === 1 && fits[0].r2 !== null) {
157
+ const s = `R² = ${dec(fits[0].r2, locale)} · n = ${formatNumber(fits[0].n, 0, locale)}`;
158
+ // Va a la esquina con menos puntos y lejos de la recta.
159
+ const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
160
+ if (fits[0].band) for (const b of fits[0].band) occ.push([b[0], (b[1] + b[2]) / 2], [b[0], b[1]], [b[0], b[2]]);
161
+ const qc = quietCorner(occ, inner, measure(s, 12) + 4, 16);
162
+ body.push(text(qc.x, qc.y, s, { fill: theme.lede, fontSize: 12, textAnchor: qc.anchor, ...halo(theme) }));
163
+ }
164
+ body.push(gridX(xAx0.ticks, xS, inner, theme, tickFmt(fx, xAx0), { lines: false }));
165
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
166
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? humanize(P.y) });
167
+
168
+ const desc = describeFits(keys, fits, P, en, locale, spec);
169
+ return frame.finish(body, { desc, type: 'regplot' });
170
+ }
171
+
172
+ function describeFits(keys, fits, P, en, locale, spec) {
173
+ const what = `${humanize(P.y).toLowerCase()} ${en ? 'vs' : 'versus'} ${humanize(P.x).toLowerCase()}`;
174
+ const parts = keys.map((k, i) => {
175
+ const f = fits[i];
176
+ const first = f.line[0][1], last = f.line[f.line.length - 1][1];
177
+ const dir = last > first ? (en ? 'rises' : 'sube') : (en ? 'falls' : 'baja');
178
+ const r2 = f.r2 !== null ? `, R² ${dec(f.r2, locale)}` : '';
179
+ return `${k ? k + ': ' : ''}${en ? 'the fit' : 'el ajuste'} ${dir}${r2}`;
180
+ });
181
+ const kind = spec.lowess ? 'LOWESS' : (spec.order ?? 1) > 1 ? (en ? 'polynomial' : 'polinomial') : en ? 'linear' : 'lineal';
182
+ return en
183
+ ? `Regression plot of ${what} with ${P.pts.length} points and a ${kind} fit. ${parts.join('; ')}.`
184
+ : `Gráfico de regresión de ${what} con ${formatNumber(P.pts.length, 0, locale)} puntos y ajuste ${kind}. ${parts.join('; ')}.`;
185
+ }
186
+
187
+ function pointLabels(pts, spec, xS, yS, r, inner, theme, P) {
188
+ if (!P.labelF || spec.labels === false || spec.labels === undefined) return '';
189
+ let chosen;
190
+ if (Array.isArray(spec.labels)) chosen = pts.filter((p) => spec.labels.map(String).includes(p.name));
191
+ else if (typeof spec.labels === 'number') chosen = [...pts].sort((a, b) => b.y - a.y).slice(0, spec.labels);
192
+ else return '';
193
+ const placed = [];
194
+ const out = [];
195
+ for (const p of chosen) {
196
+ if (!p.name) continue;
197
+ const w = measure(p.name, 12, 500);
198
+ let lx = xS(p.x) + r + 4, anchor = 'start';
199
+ const ly = yS(p.y) + 4;
200
+ if (lx + w > inner.x + inner.w) { lx = xS(p.x) - r - 4; anchor = 'end'; }
201
+ const box = anchor === 'start' ? [lx, ly - 11, lx + w, ly + 3] : [lx - w, ly - 11, lx, ly + 3];
202
+ if (placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
203
+ placed.push(box);
204
+ out.push(el('text', { x: lx, y: ly, fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, ...halo(theme) }, esc(p.name)));
205
+ }
206
+ return g({ class: 'pa-labels' }, out);
207
+ }
208
+
209
+ // ── residplot ──────────────────────────────────────────────────────────────
210
+
211
+ /** Residuos de la regresión versus x, con línea en cero y suavizado opcional (lowess: true). */
212
+ export function residplot(spec, theme) {
213
+ const data = ensureData(spec);
214
+ const P = readPoints(spec, data);
215
+ const en = EN(spec);
216
+ const locale = spec.locale || 'es';
217
+ const fxv = (v) => (spec.logx ? Math.log10(v) : v);
218
+ const fit = polyfit(P.pts.map((p) => fxv(p.x)), P.pts.map((p) => p.y), { order: spec.order ?? 1 });
219
+ const res = P.pts.map((p) => ({ ...p, r: p.y - fit.predict(fxv(p.x)) }));
220
+ const frame = createFrame(spec, theme, { height: 340 });
221
+ const area = frame.layout([]);
222
+ const M = theme.marks || {};
223
+ const fx = makeFormat(spec.xFormat, locale, res.map((p) => p.x));
224
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, res.map((p) => p.r));
225
+ const lim = Math.max(...res.map((p) => Math.abs(p.r))) || 1;
226
+ const yAx = axisDomain([-lim, lim], { domain: spec.yDomain, count: Math.max(3, Math.round(area.h / 60)) });
227
+ const yt = tickFmt(fy, yAx);
228
+ const top = 18 + yAxisTop(theme);
229
+ const inner = { x: area.x + yAxisMargin(yAx.ticks, yt, theme), y: area.y + top, w: 0, h: area.h - top - 44 };
230
+ inner.w = area.x + area.w - inner.x - 4;
231
+ const r = res.length > 300 ? 2.5 : M.pointRadius ?? 4;
232
+ const xAx = axisDomain(res.map((p) => p.x), { type: P.xLog ? 'log' : undefined, domain: spec.xDomain, nice: 'tight', count: Math.max(3, Math.round(inner.w / 90)) });
233
+ const xS = xAx.scale(inner.x + r + 2, inner.x + inner.w - r - 2);
234
+ const yS = yAx.scale(inner.y + inner.h, inner.y);
235
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [yAx.min, yAx.max]; }
236
+ const body = [yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN })];
237
+ const y0 = Math.round(yS(0)) + 0.5;
238
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: y0, y2: y0, stroke: theme.ink, strokeWidth: 1.25, opacity: 0.6 }));
239
+ body.push(g({}, dotLayer(res.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.r), r, fill: theme.categorical[0], fillOpacity: (M.dotOpacity ?? 0.85) * 0.8, stroke: theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name, `${humanize(P.x)}: ${fx(p.x)}`, `${en ? 'Residual' : 'Residuo'}: ${fy(p.r)}`) })))));
240
+ if (spec.lowess) {
241
+ const lw = lowess(res.map((p) => p.x), res.map((p) => p.r));
242
+ body.push(el('path', { d: lw.map(([a, b], i) => `${i ? 'L' : 'M'}${xS(a).toFixed(2)},${yS(b).toFixed(2)}`).join(''), fill: 'none', stroke: theme.categorical[1], strokeWidth: M.lineWidth ?? 2 }));
243
+ }
244
+ body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
245
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
246
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? (en ? 'Residual' : 'Residuo') });
247
+ const sdv = Math.sqrt(res.reduce((a, p) => a + p.r * p.r, 0) / Math.max(1, res.length - 2));
248
+ const desc = en ? `Residuals of ${humanize(P.y)} on ${humanize(P.x)} (${res.length} points). Residual standard error ${fy(sdv)}.` : `Residuos de ${humanize(P.y).toLowerCase()} sobre ${humanize(P.x).toLowerCase()} (${formatNumber(res.length, 0, locale)} puntos). Error estándar residual ${fy(sdv)}.`;
249
+ return frame.finish(body, { desc, type: 'residplot' });
250
+ }
251
+
252
+ // ── jointplot ──────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Dispersión central con distribuciones marginales arriba (x) y a la derecha (y).
256
+ * Opciones: x, y, hue, kind ('scatter' | 'reg'), marginal ('hist' | 'kde'),
257
+ * bins, ci, order, xScale/yScale 'log', xDomain, yDomain, label + labels.
258
+ */
259
+ export function jointplot(spec, theme) {
260
+ const data = ensureData(spec);
261
+ const P = readPoints(spec, data);
262
+ const { pts, keys, xLog, yLog } = P;
263
+ const en = EN(spec);
264
+ const locale = spec.locale || 'es';
265
+ const K = keys.length;
266
+ const cmap = colorMap(keys, theme, spec.colors);
267
+ const colorOf = (k) => (K === 1 ? (spec.colors && spec.colors[k]) || theme.categorical[0] : cmap.get(k));
268
+ const legend = K > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: colorOf(k) })) : [];
269
+ const frame = createFrame(spec, theme, { height: 520 });
270
+ const area = frame.layout(legend);
271
+ const M = theme.marks || {};
272
+ const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
273
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
274
+ const xAx = axisDomain(pts.map((p) => p.x), { type: xLog ? 'log' : undefined, domain: spec.xDomain, nice: 'tight', count: Math.max(3, Math.round(area.w / 120)) });
275
+ const yAx = axisDomain(pts.map((p) => p.y), { type: yLog ? 'log' : undefined, domain: spec.yDomain, nice: 'tight', count: Math.max(3, Math.round(area.h / 80)) });
276
+ const yt = tickFmt(fy, yAx);
277
+ const mH = Math.max(44, Math.min(90, area.h * 0.17));
278
+ const gap = 6;
279
+ const leftM = yAxisMargin(yAx.ticks, yt, theme);
280
+ const topGap = gap + yAxisTop(theme);
281
+ const inner = { x: area.x + leftM, y: area.y + 18 + mH + topGap, w: 0, h: 0 };
282
+ inner.w = area.x + area.w - inner.x - mH - gap;
283
+ inner.h = area.y + area.h - 44 - inner.y;
284
+ const r = spec.radius ?? (pts.length > 400 ? 2.5 : pts.length > 120 ? 3.25 : M.pointRadius ?? 4);
285
+ const xS = xAx.scale(inner.x + r + 2, inner.x + inner.w - r - 2);
286
+ const yS = yAx.scale(inner.y + inner.h - r - 2, inner.y + r + 2);
287
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [yAx.min, yAx.max]; }
288
+ const body = [yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN })];
289
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: inner.y + inner.h + 0.5, y2: inner.y + inner.h + 0.5, stroke: theme.axis }));
290
+
291
+ // Marginales: comparten bins / grilla en el espacio de la escala.
292
+ const tx = (v) => (xLog ? Math.log10(v) : v), ty = (v) => (yLog ? Math.log10(v) : v);
293
+ const ix = (t) => (xLog ? 10 ** t : t), iy = (t) => (yLog ? 10 ** t : t);
294
+ const marginal = spec.marginal || 'hist';
295
+ const groups = keys.map((k) => pts.filter((p) => p.k === k));
296
+ const topBox = { x: inner.x, y: inner.y - topGap - mH, w: inner.w, h: mH };
297
+ const rightBox = { x: inner.x + inner.w + gap, y: inner.y, w: mH, h: inner.h };
298
+ const op = K > 1 ? 0.55 : 0.85;
299
+ if (marginal === 'kde') {
300
+ const ex = [tx(xAx.min), tx(xAx.max)], ey = [ty(yAx.min), ty(yAx.max)];
301
+ const kx = groups.map((gp) => (gp.length > 1 ? kdeEst(gp.map((p) => tx(p.x)), { extent: ex, gridSize: 120 }).map(([t, d]) => [t, (d * gp.length) / pts.length]) : []));
302
+ const ky = groups.map((gp) => (gp.length > 1 ? kdeEst(gp.map((p) => ty(p.y)), { extent: ey, gridSize: 120 }).map(([t, d]) => [t, (d * gp.length) / pts.length]) : []));
303
+ const mx = Math.max(...kx.flat().map((p) => p[1]), 1e-12), my = Math.max(...ky.flat().map((p) => p[1]), 1e-12);
304
+ kx.forEach((c, i) => {
305
+ if (!c.length) return;
306
+ const pth = c.map(([t, d], j) => `${j ? 'L' : 'M'}${xS(ix(t)).toFixed(2)},${(topBox.y + topBox.h - (d / mx) * topBox.h).toFixed(2)}`).join('');
307
+ body.push(el('path', { d: `${pth}L${xS(ix(c[c.length - 1][0])).toFixed(2)},${topBox.y + topBox.h}L${xS(ix(c[0][0])).toFixed(2)},${topBox.y + topBox.h}Z`, fill: colorOf(keys[i]), fillOpacity: (M.areaOpacity ?? 0.12) * 1.5 }));
308
+ body.push(el('path', { d: pth, fill: 'none', stroke: colorOf(keys[i]), strokeWidth: 1.75 }));
309
+ });
310
+ ky.forEach((c, i) => {
311
+ if (!c.length) return;
312
+ const pth = c.map(([t, d], j) => `${j ? 'L' : 'M'}${(rightBox.x + (d / my) * rightBox.w).toFixed(2)},${yS(iy(t)).toFixed(2)}`).join('');
313
+ body.push(el('path', { d: `${pth}L${rightBox.x},${yS(iy(c[c.length - 1][0])).toFixed(2)}L${rightBox.x},${yS(iy(c[0][0])).toFixed(2)}Z`, fill: colorOf(keys[i]), fillOpacity: (M.areaOpacity ?? 0.12) * 1.5 }));
314
+ body.push(el('path', { d: pth, fill: 'none', stroke: colorOf(keys[i]), strokeWidth: 1.75 }));
315
+ });
316
+ } else {
317
+ // Bins exactos dentro del dominio del eje (cantidad según Freedman–Diaconis).
318
+ const nb = (vs) => spec.bins ?? Math.max(8, Math.min(30, histogram(vs).length));
319
+ const vx = pts.map((p) => tx(p.x)), vy = pts.map((p) => ty(p.y));
320
+ const bx = histogram(vx, { bins: nb(vx), domain: [tx(xAx.min), tx(xAx.max)] });
321
+ const by = histogram(vy, { bins: nb(vy), domain: [ty(yAx.min), ty(yAx.max)] });
322
+ const countIn = (gp, bins, f, sel) => bins.map((b, i) => gp.filter((p) => { const t = f(sel(p)); return t >= b.x0 && (t < b.x1 || (i === bins.length - 1 && t <= b.x1)); }).length);
323
+ const cx = groups.map((gp) => countIn(gp, bx, tx, (p) => p.x));
324
+ const cy = groups.map((gp) => countIn(gp, by, ty, (p) => p.y));
325
+ const mx = Math.max(...cx.flat(), 1), my = Math.max(...cy.flat(), 1);
326
+ groups.forEach((gp, gi) => {
327
+ const c = colorOf(keys[gi]);
328
+ bx.forEach((b, i) => {
329
+ const a0 = xS(ix(b.x0)) + 1, a1 = xS(ix(b.x1)) - 1;
330
+ const h = (cx[gi][i] / mx) * topBox.h;
331
+ if (h <= 0 || a1 <= a0) return;
332
+ body.push(el('path', { class: 'pa-m', d: barPath(a0, topBox.y + topBox.h - h, a1 - a0, h, 'up', 2), fill: c, fillOpacity: op, 'data-tip': tip(`${fx(ix(b.x0))} – ${fx(ix(b.x1))}`, `${keys[gi] ? keys[gi] + ': ' : ''}${cx[gi][i]}`) }));
333
+ });
334
+ by.forEach((b, i) => {
335
+ const a0 = yS(iy(b.x1)) + 1, a1 = yS(iy(b.x0)) - 1;
336
+ const w = (cy[gi][i] / my) * rightBox.w;
337
+ if (w <= 0 || a1 <= a0) return;
338
+ body.push(el('path', { class: 'pa-m', d: barPath(rightBox.x, a0, w, a1 - a0, 'right', 2), fill: c, fillOpacity: op, 'data-tip': tip(`${fy(iy(b.x0))} – ${fy(iy(b.x1))}`, `${keys[gi] ? keys[gi] + ': ' : ''}${cy[gi][i]}`) }));
339
+ });
340
+ });
341
+ }
342
+ body.push(el('line', { x1: topBox.x, x2: topBox.x + topBox.w, y1: topBox.y + topBox.h + 0.5, y2: topBox.y + topBox.h + 0.5, stroke: theme.axis }));
343
+ body.push(el('line', { x1: rightBox.x - 0.5, x2: rightBox.x - 0.5, y1: rightBox.y, y2: rightBox.y + rightBox.h, stroke: theme.axis }));
344
+
345
+ // Ajuste opcional (kind: 'reg').
346
+ if (spec.kind === 'reg') {
347
+ keys.forEach((k, i) => {
348
+ const f = fitGroup(groups[i], spec, { xLog, xMin: xAx.min, xMax: xAx.max });
349
+ const L = (arr) => arr.map(([a, b], j) => `${j ? 'L' : 'M'}${xS(a).toFixed(2)},${yS(b).toFixed(2)}`).join('');
350
+ if (f.band) body.push(el('path', { d: L(f.band.map(([x, , hi]) => [x, hi])) + 'L' + L(f.band.map(([x, lo]) => [x, lo]).reverse()).slice(1) + 'Z', fill: colorOf(k), fillOpacity: M.bandOpacity ?? 0.16 }));
351
+ body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
352
+ });
353
+ }
354
+ body.push(g({}, dotLayer(pts.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
355
+ body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
356
+ body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
357
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
358
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? humanize(P.y) });
359
+ const fit = polyfit(pts.map((p) => tx(p.x)), pts.map((p) => p.y));
360
+ const desc = en
361
+ ? `Joint plot of ${humanize(P.y)} vs ${humanize(P.x)} with marginal distributions (${pts.length} points, R² ${dec(fit.r2, locale)}).`
362
+ : `Gráfico conjunto de ${humanize(P.y).toLowerCase()} versus ${humanize(P.x).toLowerCase()} con distribuciones marginales (${formatNumber(pts.length, 0, locale)} puntos, R² ${dec(fit.r2, locale)}).`;
363
+ return frame.finish(body, { desc, type: 'jointplot' });
364
+ }
365
+
366
+ function hashStr(s) {
367
+ let h = 0;
368
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
369
+ return h;
370
+ }
@@ -0,0 +1,169 @@
1
+ // Sankey: flujos entre etapas (presupuesto → áreas, origen → destino).
2
+ // El grosor es proporcional al valor; el color sigue al origen.
3
+ import { el, g, text, tip } from '../svg.js';
4
+ import { measure, truncate } from '../text.js';
5
+ import { makeFormat } from '../format.js';
6
+ import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
7
+ import { createFrame } from '../frame.js';
8
+
9
+ export function sankey(spec, theme) {
10
+ const data = ensureData(spec);
11
+ const fields = fieldsOf(data);
12
+ const from = spec.from ?? spec.source_field ?? (fields.includes('source') ? 'source' : fields.includes('from') ? 'from' : fields[0]);
13
+ const to = spec.to ?? (fields.includes('target') ? 'target' : fields.includes('to') ? 'to' : fields[1]);
14
+ const valueF = spec.value ?? fields.find((f) => f !== from && f !== to && fieldKind(data, f) === 'number');
15
+ requireField(data, from, 'from');
16
+ requireField(data, to, 'to');
17
+ requireField(data, valueF, 'value');
18
+ const locale = spec.locale || 'es';
19
+ const en = String(locale).startsWith('en');
20
+
21
+ // Enlaces agregados (valores no finitos o ≤ 0 se ignoran).
22
+ const lmap = new Map();
23
+ for (const r of data) {
24
+ const v = num(r[valueF]);
25
+ if (!(v > 0) || !Number.isFinite(v)) continue;
26
+ const s = String(r[from]), t = String(r[to]);
27
+ if (s === t) continue;
28
+ const k = s + '\u0000' + t;
29
+ lmap.set(k, (lmap.get(k) || 0) + v);
30
+ }
31
+ const links = [...lmap.entries()].map(([k, v]) => { const [s, t] = k.split('\u0000'); return { s, t, v }; });
32
+ if (!links.length) {
33
+ throw en
34
+ ? new PlotError('The sankey has no positive flows between different nodes.', `Check \`${valueF}\`: it needs values > 0, and \`${from}\` must differ from \`${to}\` (self-loops are dropped).`)
35
+ : new PlotError('El sankey no tiene flujos positivos entre nodos distintos.', `Revisa \`${valueF}\`: necesita valores > 0, y \`${from}\` debe ser distinto de \`${to}\` (los bucles se omiten).`);
36
+ }
37
+ const names = [...new Set(links.flatMap((l) => [l.s, l.t]))];
38
+ const nodes = new Map(names.map((n) => [n, { name: n, in: [], out: [], depth: 0 }]));
39
+ for (const l of links) { l.sn = nodes.get(l.s); l.tn = nodes.get(l.t); l.sn.out.push(l); l.tn.in.push(l); }
40
+ for (const n of nodes.values()) n.value = Math.max(sum(n.in), sum(n.out));
41
+
42
+ // Un sankey fluye en una dirección: un ciclo (a → b → a) no tiene etapas.
43
+ const cycle = findCycle(nodes);
44
+ if (cycle) {
45
+ const path = cycle.join(' → ');
46
+ throw en
47
+ ? new PlotError(`The sankey has a cycle (${path}): flows must go one way, stage by stage.`, 'Name each stage apart ("Chile (origin)" → "Chile (destination)"), drop the return flows, or use `type: "chord"` for two-way exchanges.')
48
+ : new PlotError(`El sankey tiene un ciclo (${path}): los flujos deben ir en una sola dirección, etapa por etapa.`, 'Distingue las etapas ("Chile (origen)" → "Chile (destino)"), quita los flujos de vuelta o usa `type: "chord"` para intercambios en ambos sentidos.');
49
+ }
50
+
51
+ // Profundidad = camino más largo desde un origen.
52
+ for (let it = 0; it < names.length; it++) {
53
+ let changed = false;
54
+ for (const l of links) if (l.tn.depth < l.sn.depth + 1) { l.tn.depth = l.sn.depth + 1; changed = true; }
55
+ if (!changed) break;
56
+ }
57
+ const maxDepth = Math.max(...[...nodes.values()].map((n) => n.depth));
58
+ for (const n of nodes.values()) if (!n.out.length) n.depth = maxDepth; // sumideros a la derecha
59
+ const cols = Array.from({ length: maxDepth + 1 }, () => []);
60
+ for (const n of nodes.values()) cols[n.depth].push(n);
61
+ cols.forEach((c) => c.sort((a, b) => b.value - a.value));
62
+
63
+ const fmt = makeFormat(spec.format, locale, links.map((l) => l.v));
64
+ const frame = createFrame(spec, theme, { height: 440 });
65
+ const area = frame.layout([]);
66
+ const label = (n) => n.name;
67
+ const leftW = Math.min(area.w * 0.22, Math.max(...cols[0].map((n) => Math.max(measure(label(n), 12.5, 500), measure(fmt(n.value), 12))))) + 10;
68
+ const last = cols[cols.length - 1];
69
+ const rightW = Math.min(area.w * 0.22, Math.max(...last.map((n) => Math.max(measure(label(n), 12.5, 500), measure(fmt(n.value), 12))))) + 10;
70
+ const NW = 10;
71
+ const inner = { x: area.x + leftW, y: area.y + 4, w: area.w - leftW - rightW, h: area.h - 8 };
72
+ const pad = Math.max(6, Math.min(18, inner.h / Math.max(...cols.map((c) => c.length)) / 4));
73
+ const ky = Math.min(...cols.map((c) => (inner.h - (c.length - 1) * pad) / (sum(c.map((n) => ({ v: n.value }))) || 1)));
74
+ const colX = (i) => inner.x + (cols.length === 1 ? 0 : (i * (inner.w - NW)) / (cols.length - 1));
75
+
76
+ const place = () => {
77
+ cols.forEach((c, i) => {
78
+ const H = sum(c.map((n) => ({ v: n.value }))) * ky + (c.length - 1) * pad;
79
+ let y = inner.y + (inner.h - H) / 2;
80
+ for (const n of c) { n.x = colX(i); n.y = y; n.h = Math.max(1, n.value * ky); y += n.h + pad; }
81
+ });
82
+ };
83
+ place();
84
+ // Baricentros para reducir cruces.
85
+ const center = (n) => n.y + n.h / 2;
86
+ for (let it = 0; it < 6; it++) {
87
+ for (let i = 1; i < cols.length; i++) cols[i].sort((a, b) => bary(a.in, 'sn') - bary(b.in, 'sn'));
88
+ place();
89
+ for (let i = cols.length - 2; i >= 0; i--) cols[i].sort((a, b) => bary(a.out, 'tn') - bary(b.out, 'tn'));
90
+ place();
91
+ }
92
+ function bary(ls, key) {
93
+ const w = sum(ls);
94
+ return w ? ls.reduce((a, l) => a + center(l[key]) * l.v, 0) / w : Infinity;
95
+ }
96
+
97
+ // Color: orígenes en orden fijo; los demás heredan su mayor flujo de entrada.
98
+ // Si hay un solo origen, el color nace en la primera etapa con 2+ nodos.
99
+ const cc = Math.max(0, cols.findIndex((c) => c.length > 1));
100
+ const fixed = (n) => spec.colors && spec.colors[n.name];
101
+ for (let i = 0; i < cc; i++) for (const n of cols[i]) n.color = fixed(n) || theme.ink;
102
+ [...cols[cc]].sort((a, b) => b.value - a.value).forEach((n, i) => { n.color = fixed(n) || (i < theme.categorical.length ? theme.categorical[i] : theme.other); });
103
+ for (let i = cc + 1; i < cols.length; i++) for (const n of cols[i]) {
104
+ const big = [...n.in].sort((a, b) => b.v - a.v)[0];
105
+ n.color = fixed(n) || (n.in.length ? big.sn.color : theme.categorical[0]);
106
+ }
107
+ const linkColor = (l) => (l.sn.depth < cc ? l.tn.color : l.sn.color);
108
+
109
+ // Posición de cada enlace dentro de sus nodos.
110
+ for (const n of nodes.values()) {
111
+ let sy = n.y;
112
+ for (const l of [...n.out].sort((a, b) => center(a.tn) - center(b.tn))) { l.y0 = sy; sy += l.v * ky; }
113
+ let ty = n.y;
114
+ for (const l of [...n.in].sort((a, b) => center(a.sn) - center(b.sn))) { l.y1 = ty; ty += l.v * ky; }
115
+ }
116
+
117
+ const body = [];
118
+ const linkG = [];
119
+ for (const l of [...links].sort((a, b) => b.v - a.v)) {
120
+ const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
121
+ const d = `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}L${x1},${l.y1 + w}C${xm},${l.y1 + w} ${xm},${l.y0 + w} ${x0},${l.y0 + w}Z`;
122
+ const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
123
+ linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% de ${l.s}` }));
124
+ }
125
+ body.push(g({ class: 'pa-links' }, linkG));
126
+ for (const [ci, c] of cols.entries()) for (const n of c) {
127
+ const parts = [el('rect', { x: n.x, y: n.y, width: NW, height: n.h, rx: 2, fill: n.color })];
128
+ const isLeft = ci === 0 && cols.length > 1;
129
+ const lx = isLeft ? n.x - 8 : n.x + NW + 8;
130
+ const anchor = isLeft ? 'end' : 'start';
131
+ const maxW = isLeft ? leftW - 10 : ci === cols.length - 1 ? rightW - 10 : (inner.w / Math.max(1, cols.length - 1)) * 0.55;
132
+ const halo = ci > 0 && ci < cols.length - 1 ? { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' } : {};
133
+ const cy = n.y + n.h / 2;
134
+ if (n.h >= 30) {
135
+ parts.push(text(lx, cy - 2, truncate(label(n), maxW, 12.5, 500), { fill: theme.ink, fontSize: 12.5, fontWeight: 500, textAnchor: anchor, ...halo }));
136
+ parts.push(text(lx, cy + 13, fmt(n.value), { fill: theme.lede, fontSize: 12, textAnchor: anchor, ...halo }));
137
+ } else if (n.h >= 8 || c.length < 12) {
138
+ parts.push(text(lx, cy + 4, truncate(label(n), maxW, 12, 500), { fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, ...halo }));
139
+ }
140
+ body.push(g({ 'data-tip': `${n.name}\n${fmt(n.value)}` }, parts));
141
+ }
142
+ const top = [...links].sort((a, b) => b.v - a.v).slice(0, 3).map((l) => `${l.s} → ${l.t} (${fmt(l.v)})`).join('; ');
143
+ const desc = en ? `Sankey diagram with ${nodes.size} nodes and ${links.length} flows. Largest flows: ${top}.` : `Diagrama de Sankey con ${nodes.size} nodos y ${links.length} flujos. Mayores flujos: ${top}.`;
144
+ return frame.finish(body, { desc, type: 'sankey' });
145
+ }
146
+
147
+ const sum = (ls) => ls.reduce((a, l) => a + l.v, 0);
148
+
149
+ /** Primer ciclo encontrado (lista de nombres, cerrada) o null. DFS iterativo. */
150
+ function findCycle(nodes) {
151
+ const state = new Map(); // 1 = en la pila, 2 = terminado
152
+ for (const start of nodes.values()) {
153
+ if (state.has(start)) continue;
154
+ const stack = [[start, 0]];
155
+ const trail = [start];
156
+ state.set(start, 1);
157
+ while (stack.length) {
158
+ const top = stack[stack.length - 1];
159
+ const [n, i] = top;
160
+ if (i < n.out.length) {
161
+ top[1]++;
162
+ const m = n.out[i].tn;
163
+ if (state.get(m) === 1) return [...trail.slice(trail.indexOf(m)), m].map((x) => x.name);
164
+ if (!state.has(m)) { state.set(m, 1); stack.push([m, 0]); trail.push(m); }
165
+ } else { state.set(n, 2); stack.pop(); trail.pop(); }
166
+ }
167
+ }
168
+ return null;
169
+ }