plotagents 0.0.0-stage → 0.5.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 (60) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -2
  4. package/bin/plotagents.js +49 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +7954 -0
  7. package/docs/api.md +154 -0
  8. package/docs/charts.md +218 -0
  9. package/docs/getting-started.md +138 -0
  10. package/docs/rendering.md +47 -0
  11. package/docs/themes.md +86 -0
  12. package/index.d.ts +213 -0
  13. package/package.json +74 -4
  14. package/schema.json +895 -0
  15. package/src/axes.js +144 -0
  16. package/src/charts/bar.js +339 -0
  17. package/src/charts/categorical.js +471 -0
  18. package/src/charts/chord.js +218 -0
  19. package/src/charts/choropleth.js +559 -0
  20. package/src/charts/dist.js +513 -0
  21. package/src/charts/donut.js +232 -0
  22. package/src/charts/flowchart.js +407 -0
  23. package/src/charts/heatmap.js +76 -0
  24. package/src/charts/kpi.js +97 -0
  25. package/src/charts/line.js +419 -0
  26. package/src/charts/network.js +344 -0
  27. package/src/charts/pyramid.js +177 -0
  28. package/src/charts/radar.js +152 -0
  29. package/src/charts/regplot.js +364 -0
  30. package/src/charts/sankey.js +133 -0
  31. package/src/charts/scatter.js +136 -0
  32. package/src/charts/sunburst.js +135 -0
  33. package/src/charts/treemap.js +194 -0
  34. package/src/context.js +10 -0
  35. package/src/dashed.js +90 -0
  36. package/src/data.js +137 -0
  37. package/src/facet.js +84 -0
  38. package/src/format.js +171 -0
  39. package/src/frame.js +157 -0
  40. package/src/geo/chile.js +7 -0
  41. package/src/geo/data/chile-regiones.js +12 -0
  42. package/src/geo/data/usa-estados.js +12 -0
  43. package/src/geo/data/world.js +12 -0
  44. package/src/geo/projection.js +152 -0
  45. package/src/geo/registry.js +31 -0
  46. package/src/geo/usa.js +8 -0
  47. package/src/geo/world.js +7 -0
  48. package/src/hierarchy.js +139 -0
  49. package/src/index.js +271 -0
  50. package/src/infer.js +49 -0
  51. package/src/interact.js +362 -0
  52. package/src/lint.js +137 -0
  53. package/src/palette.js +59 -0
  54. package/src/runtime.js +304 -0
  55. package/src/scale.js +171 -0
  56. package/src/schema.js +181 -0
  57. package/src/stats.js +246 -0
  58. package/src/svg.js +85 -0
  59. package/src/text.js +61 -0
  60. package/src/theme.js +224 -0
@@ -0,0 +1,152 @@
1
+ // Radar (araña): perfil de una o más series en varios ejes comparables.
2
+ // Formato largo { eje, valor, serie } o ancho (una fila por serie + `axes`).
3
+ import { el, g, text, tip } from '../svg.js';
4
+ import { measure, wrap } from '../text.js';
5
+ import { makeFormat } from '../format.js';
6
+ import { niceDomain, niceStep, linear } from '../scale.js';
7
+ import { colorMap } from '../theme.js';
8
+ import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, aggregate } from '../data.js';
9
+ import { createFrame } from '../frame.js';
10
+
11
+ export function radar(spec, theme) {
12
+ const data = ensureData(spec);
13
+ const fields = fieldsOf(data);
14
+ const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
15
+ const locale = spec.locale || 'es';
16
+ const en = String(locale).startsWith('en');
17
+ let series = spec.hue ?? spec.series ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : undefined);
18
+ let axes, seriesKeys;
19
+ const values = new Map(); // serie → Map(eje → valor)
20
+
21
+ if (Array.isArray(spec.axes) && spec.axes.length) {
22
+ // Formato ancho: cada fila es una serie; las columnas `axes` son los ejes.
23
+ axes = spec.axes.map(String);
24
+ axes.forEach((a) => requireField(data, a, 'axes'));
25
+ const labelF = spec.label ?? series ?? fields.find((f) => kinds[f] === 'category' && !axes.includes(f));
26
+ if (labelF) requireField(data, labelF, 'label');
27
+ seriesKeys = data.map((r, i) => (labelF ? String(r[labelF]) : `${en ? 'Series' : 'Serie'} ${i + 1}`));
28
+ data.forEach((r, i) => values.set(seriesKeys[i], new Map(axes.map((a) => [a, num(r[a])]))));
29
+ series = labelF;
30
+ } else {
31
+ const axisF = spec.axis ?? spec.x ?? fields.find((f) => kinds[f] === 'category' && f !== series);
32
+ const valueF = spec.value ?? spec.y ?? fields.find((f) => kinds[f] === 'number' && f !== series);
33
+ requireField(data, axisF, 'axis (x)');
34
+ requireField(data, valueF, 'value (y)');
35
+ if (series) requireField(data, series, 'series');
36
+ axes = uniq(data.map((r) => String(r[axisF])));
37
+ seriesKeys = series ? uniq(data.map((r) => String(r[series]))) : [humanize(valueF)];
38
+ const acc = new Map();
39
+ for (const r of data) {
40
+ const s = series ? String(r[series]) : seriesKeys[0];
41
+ const k = s + '\u0000' + String(r[axisF]);
42
+ if (!acc.has(k)) acc.set(k, []);
43
+ acc.get(k).push(num(r[valueF]));
44
+ }
45
+ for (const s of seriesKeys) values.set(s, new Map(axes.map((a) => [a, aggregate(acc.get(s + '\u0000' + a) || [], spec.aggregate || 'mean')])));
46
+ }
47
+ const n = axes.length;
48
+ const raw = [...values.values()].flatMap((m) => [...m.values()]).filter(Number.isFinite);
49
+ const fmt = makeFormat(spec.format, locale, raw);
50
+
51
+ // Normalización por eje (cada eje a 0–1 según su máximo).
52
+ const axisMax = new Map(axes.map((a) => [a, Math.max(...seriesKeys.map((s) => values.get(s).get(a)).filter(Number.isFinite), 0)]));
53
+ const norm = !!spec.normalize;
54
+ const val = (s, a) => { const v = values.get(s).get(a); return norm ? v / (axisMax.get(a) || 1) : v; };
55
+ const vals = seriesKeys.flatMap((s) => axes.map((a) => val(s, a))).filter(Number.isFinite);
56
+ let dom;
57
+ if (spec.domain || spec.valueDomain || spec.yDomain) { const d = spec.domain || spec.valueDomain || spec.yDomain; dom = { min: d[0], max: d[1], step: niceStep(d[1] - d[0], 5) }; }
58
+ else if (norm) dom = { min: 0, max: 1, step: 0.25 };
59
+ else dom = niceDomain(Math.min(0, ...vals), Math.max(...vals), 4);
60
+ const rings = linear(dom.min, dom.max, 0, 1).ticks(dom.step).filter((t) => t > dom.min + 1e-9 && t <= dom.max + 1e-9);
61
+ if (!rings.length || Math.abs(rings[rings.length - 1] - dom.max) > 1e-9) rings.push(dom.max);
62
+ const ringFmt = norm ? (v) => Math.round(v * 100) + '%' : fmt.tick(dom.step);
63
+
64
+ // Colores y foco
65
+ const cmap = colorMap(seriesKeys, theme, spec.colors);
66
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
67
+ const focus = (s) => !hl || hl.has(s);
68
+ const colorOf = (s) => (focus(s) ? (seriesKeys.length === 1 ? cmap.get(s) || theme.categorical[0] : cmap.get(s)) : theme.dim);
69
+ const legend = seriesKeys.length > 1 ? seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: 'square' })) : [];
70
+
71
+ const frame = createFrame(spec, theme, { height: (spec.width || 720) < 560 ? Math.round((spec.width || 720) * 0.95 + 170) : 460 });
72
+ let area = frame.layout(spec.legend === false ? [] : legend);
73
+
74
+ // Etiquetas de eje: ancho máximo y alto (hasta 2 líneas).
75
+ const ls = (theme.type?.label ?? 12) + 0.5;
76
+ const maxLabelW = Math.min(130, Math.max(60, area.w * 0.2));
77
+ const labelLines = axes.map((a) => wrap(a, maxLabelW, ls, 500, 2));
78
+ const labW = Math.max(...labelLines.map((ls2) => Math.max(...ls2.map((l) => measure(l, ls, 500)))));
79
+ const labH = ls * 1.25 * Math.max(...labelLines.map((l) => l.length));
80
+ const gapL = 10;
81
+ let R = Math.min((area.w - 2 * (labW + gapL)) / 2, (area.h - 2 * (labH + gapL)) / 2);
82
+ if (spec.height === undefined) {
83
+ R = Math.max(60, Math.min((area.w - 2 * (labW + gapL)) / 2, spec.maxRadius ?? 210));
84
+ frame.setHeight(area.y + 2 * (R + labH + gapL) + 4 + frame.chrome);
85
+ area = { ...area, h: frame.bottom - area.y };
86
+ }
87
+ R = Math.max(40, R);
88
+ const cx = area.x + area.w / 2;
89
+ const cy = area.y + area.h / 2;
90
+ const ang = (i) => -Math.PI / 2 + (2 * Math.PI * i) / n;
91
+ const rOf = (v) => Math.max(0, ((v - dom.min) / (dom.max - dom.min || 1)) * R);
92
+ const pt = (i, r) => [cx + r * Math.cos(ang(i)), cy + r * Math.sin(ang(i))];
93
+ const shape = spec.grid === 'circle' ? 'circle' : 'polygon';
94
+ const ringPath = (r) => (shape === 'circle' ? null : 'M' + axes.map((_, i) => pt(i, r).map((v) => v.toFixed(2)).join(',')).join('L') + 'Z');
95
+
96
+ const body = [];
97
+ // Rejilla: anillos y radios, recesivos.
98
+ rings.forEach((t, k) => {
99
+ const r = rOf(t);
100
+ const outer = k === rings.length - 1;
101
+ const attrs = { fill: 'none', stroke: outer ? theme.axis : theme.grid, strokeWidth: 1 };
102
+ body.push(shape === 'circle' ? el('circle', { cx, cy, r, ...attrs }) : el('path', { d: ringPath(r), ...attrs, strokeLinejoin: 'round' }));
103
+ });
104
+ axes.forEach((_, i) => { const [x, y] = pt(i, R); body.push(el('line', { x1: cx, y1: cy, x2: x, y2: y, stroke: theme.grid, strokeWidth: 1 })); });
105
+
106
+ // Series: el foco se dibuja al final (encima).
107
+ const order = [...seriesKeys].sort((a, b) => (focus(a) ? 1 : 0) - (focus(b) ? 1 : 0));
108
+ const op = theme.marks?.bandOpacity ?? 0.16;
109
+ const lw = theme.marks?.lineWidth ?? 2;
110
+ for (const s of order) {
111
+ const c = colorOf(s);
112
+ const verts = axes.map((a, i) => { const v = val(s, a); return Number.isFinite(v) ? pt(i, rOf(v)) : [cx, cy]; });
113
+ const d = 'M' + verts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join('L') + 'Z';
114
+ const top = axes.map((a) => [a, values.get(s).get(a)]).filter(([, v]) => Number.isFinite(v)).sort((p, q) => (norm ? q[1] / (axisMax.get(q[0]) || 1) - p[1] / (axisMax.get(p[0]) || 1) : q[1] - p[1]));
115
+ const parts = [el('path', { d, fill: c, fillOpacity: focus(s) ? op : op * 0.5, stroke: c, strokeWidth: focus(s) ? lw : 1.25, strokeLinejoin: 'round' })];
116
+ if (focus(s)) verts.forEach(([x, y], i) => {
117
+ const v = values.get(s).get(axes[i]);
118
+ parts.push(el('circle', { cx: x, cy: y, r: (theme.marks?.pointRadius ?? 4) - 0.5, fill: c, stroke: theme.surface, strokeWidth: theme.marks?.ring ?? 2, 'data-tip': tip(s, `${axes[i]}: ${Number.isFinite(v) ? fmt(v) : '—'}`) }));
119
+ });
120
+ body.push(g({ class: 'pa-m', 'data-series': s, 'data-tip': tip(s, ...top.slice(0, 6).map(([a, v]) => `${a}: ${fmt(v)}`)) }, parts));
121
+ }
122
+
123
+ // Valores de los anillos sobre el primer radio (arriba), con halo.
124
+ rings.forEach((t) => {
125
+ const r = rOf(t);
126
+ body.push(text(cx + 4, cy - r - 3, ringFmt(t), { fill: theme.muted, fontSize: (theme.type?.axis ?? 11.5) - 1, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
127
+ });
128
+
129
+ // Etiquetas de eje fuera del círculo, ancladas según el ángulo.
130
+ axes.forEach((a, i) => {
131
+ const t = ang(i);
132
+ const cos = Math.cos(t), sin = Math.sin(t);
133
+ const [x, y] = pt(i, R + gapL);
134
+ const anchor = cos > 0.25 ? 'start' : cos < -0.25 ? 'end' : 'middle';
135
+ const lines = labelLines[i];
136
+ const lh = ls * 1.25;
137
+ const total = lines.length * lh;
138
+ const y0 = sin < -0.25 ? y - total + ls * 0.85 : sin > 0.25 ? y + ls * 0.85 : y - total / 2 + ls * 0.85;
139
+ lines.forEach((l, k) => body.push(text(x, y0 + k * lh, l, { fill: theme.ink, fontSize: ls, fontWeight: 500, textAnchor: anchor })));
140
+ });
141
+
142
+ // Descripción: fortaleza de cada serie.
143
+ const parts = seriesKeys.slice(0, 5).map((s) => {
144
+ const best = axes.map((a) => [a, val(s, a)]).filter(([, v]) => Number.isFinite(v)).sort((p, q) => q[1] - p[1]);
145
+ if (!best.length) return '';
146
+ const b = best[0], w = best[best.length - 1];
147
+ return en ? `${s}: strongest on ${b[0]}, weakest on ${w[0]}.` : `${s}: más alto en ${b[0]}, más bajo en ${w[0]}.`;
148
+ });
149
+ const warn = seriesKeys.length > 4 ? (en ? ' More than 4 series overlap; consider highlight.' : ' Más de 4 series se superponen; considera highlight.') : '';
150
+ const desc = (en ? `Radar chart with ${n} axes and ${seriesKeys.length} series${norm ? ' (each axis normalized to its maximum)' : ''}. ` : `Gráfico de radar con ${n} ejes y ${seriesKeys.length} series${norm ? ' (cada eje normalizado a su máximo)' : ''}. `) + parts.join(' ') + warn;
151
+ return frame.finish(body, { desc, type: 'radar' });
152
+ }
@@ -0,0 +1,364 @@
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 } 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
+ const keys = hue ? uniq(pts.map((p) => p.k)) : [''];
35
+ return { x, y, hue, labelF, pts, keys, xLog, yLog };
36
+ }
37
+
38
+ /** Ajuste de un grupo: devuelve { line: [[x,y]], band: [[x,lo,hi]] | null, r2, n }. */
39
+ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
40
+ const fx = (v) => (spec.logx ? Math.log10(v) : v);
41
+ const xs = gp.map((p) => fx(p.x)), ys = gp.map((p) => p.y);
42
+ const lo = spec.truncate === false ? xMin : Math.min(...gp.map((p) => p.x));
43
+ const hi = spec.truncate === false ? xMax : Math.max(...gp.map((p) => p.x));
44
+ const grid = Array.from({ length: steps }, (_, i) => {
45
+ const t = i / (steps - 1);
46
+ return xLog ? 10 ** (Math.log10(lo) + (Math.log10(hi) - Math.log10(lo)) * t) : lo + (hi - lo) * t;
47
+ });
48
+ if (spec.lowess) {
49
+ const lw = lowess(xs, ys, { frac: spec.frac ?? 2 / 3 });
50
+ const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
51
+ return { line: pts, band: null, r2: null, n: gp.length };
52
+ }
53
+ const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci ?? 95) / 100 });
54
+ const line = grid.map((v) => [v, fit.predict(fx(v))]);
55
+ const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
56
+ return { line, band, r2: fit.r2, n: fit.n, fit };
57
+ }
58
+
59
+ // ── regplot / lmplot ───────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Dispersión con recta (o polinomio / LOWESS) ajustada y banda de confianza.
63
+ * Opciones: x, y, hue, ci (95 | false), order, lowess, logx, truncate (true),
64
+ * scatter (true), annotate, label + labels, xScale/yScale 'log', xDomain, yDomain.
65
+ */
66
+ export function regplot(spec, theme) {
67
+ const data = ensureData(spec);
68
+ const P = readPoints(spec, data);
69
+ const { pts, keys, xLog, yLog } = P;
70
+ const en = EN(spec);
71
+ const locale = spec.locale || 'es';
72
+ const K = keys.length;
73
+ const cmap = colorMap(keys, theme, spec.colors);
74
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
75
+ const focusKey = (k) => !hl || hl.has(k);
76
+ const colorOf = (k) => (!focusKey(k) ? theme.dim : K === 1 ? (spec.colors && spec.colors[k]) || theme.categorical[0] : cmap.get(k));
77
+ const legend = K > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: colorOf(k) })) : [];
78
+ const frame = createFrame(spec, theme, { height: 440 });
79
+ const area = frame.layout(legend);
80
+ const M = theme.marks || {};
81
+
82
+ 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)) });
83
+ const fits = keys.map((k) => fitGroup(pts.filter((p) => p.k === k), spec, { xLog, xMin: xAx0.min, xMax: xAx0.max }));
84
+ const yVals = pts.map((p) => p.y).concat(fits.flatMap((f) => f.line.map((q) => q[1])));
85
+ const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
86
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
87
+ 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)) });
88
+ const yt = tickFmt(fy, yAx);
89
+ const leftM = yAxisMargin(yAx.ticks, yt, theme);
90
+
91
+ // Etiquetas al final de cada recta cuando hay grupos.
92
+ const endLabels = K > 1 && spec.labels !== false && spec.endLabels !== false;
93
+ const endText = (k, f) => (spec.annotate && f.r2 !== null ? `${k} · R² ${dec(f.r2, locale)}` : k);
94
+ const endW = endLabels ? Math.max(...keys.map((k, i) => measure(endText(k, fits[i]), 12, 600))) + 12 : 0;
95
+ const top = 18 + yAxisTop(theme);
96
+ const r = spec.radius ?? (pts.length > 400 ? 2.5 : pts.length > 120 ? 3.25 : M.pointRadius ?? 4);
97
+ const inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM - endW - 4, h: area.h - top - 44 };
98
+ const pad = r + 2;
99
+ const xS = xAx0.scale(inner.x + pad, inner.x + inner.w - pad);
100
+ const yS = yAx.scale(inner.y + inner.h - pad, inner.y + pad);
101
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx0.min, xAx0.max]; spec._domains.y = [yAx.min, yAx.max]; }
102
+ const clipId = 'pa-clip-reg-' + Math.abs(hashStr((spec.title || '') + inner.w + K)).toString(36);
103
+
104
+ const body = [];
105
+ body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
106
+ body.push(yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN }));
107
+ 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 }));
108
+
109
+ 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('');
110
+ // Bandas primero, luego puntos, luego rectas encima.
111
+ keys.forEach((k, i) => {
112
+ const f = fits[i];
113
+ if (!f.band || !focusKey(k)) return;
114
+ const upper = f.band.map(([x, , hi]) => [x, hi]);
115
+ const lower = f.band.map(([x, lo]) => [x, lo]).reverse();
116
+ body.push(el('path', { d: L(upper) + 'L' + L(lower).slice(1) + 'Z', fill: colorOf(k), fillOpacity: M.bandOpacity ?? 0.16, clipPath: `url(#${clipId})` }));
117
+ });
118
+ if (spec.scatter !== false) {
119
+ const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
120
+ const dots = order.map((p) => ({
121
+ 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,
122
+ stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
123
+ '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 : ''),
124
+ }));
125
+ body.push(g({ class: 'pa-points' }, dotLayer(dots)));
126
+ }
127
+ const ends = [];
128
+ keys.forEach((k, i) => {
129
+ const f = fits[i];
130
+ const c = K === 1 && spec.scatter !== false ? theme.ink : colorOf(k);
131
+ const stats = f.r2 !== null ? `R² = ${dec(f.r2, locale)} · n = ${formatNumber(f.n, 0, locale)}` : `n = ${formatNumber(f.n, 0, locale)}`;
132
+ 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) }, [
133
+ el('path', { d: L(f.line), fill: 'none', stroke: 'transparent', strokeWidth: 12, clipPath: `url(#${clipId})` }),
134
+ 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})` }),
135
+ ]));
136
+ const last = f.line[f.line.length - 1];
137
+ if (endLabels && focusKey(k)) ends.push({ k, y: yS(last[1]), y0: yS(last[1]), x: xS(last[0]) + 8, t: endText(k, f) });
138
+ });
139
+ // Etiquetas finales: separación mínima de 14 px, con línea guía si se mueven.
140
+ ends.sort((a, b) => a.y - b.y);
141
+ 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; } }
142
+ for (const e of ends) {
143
+ const lx = inner.x + inner.w + 6;
144
+ 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' }));
145
+ body.push(text(lx, e.y + 4, e.t, { fill: readable(colorOf(e.k), theme), fontSize: 12, fontWeight: 600 }));
146
+ }
147
+ // Rótulos de puntos seleccionados.
148
+ body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
149
+ // Resumen discreto (un solo grupo).
150
+ if (spec.annotate && K === 1 && fits[0].r2 !== null) {
151
+ const s = `R² = ${dec(fits[0].r2, locale)} · n = ${formatNumber(fits[0].n, 0, locale)}`;
152
+ // Va a la esquina con menos puntos y lejos de la recta.
153
+ const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
154
+ 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]]);
155
+ const qc = quietCorner(occ, inner, measure(s, 12) + 4, 16);
156
+ body.push(text(qc.x, qc.y, s, { fill: theme.lede, fontSize: 12, textAnchor: qc.anchor, ...halo(theme) }));
157
+ }
158
+ body.push(gridX(xAx0.ticks, xS, inner, theme, tickFmt(fx, xAx0), { lines: false }));
159
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
160
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? humanize(P.y) });
161
+
162
+ const desc = describeFits(keys, fits, P, en, locale, spec);
163
+ return frame.finish(body, { desc, type: 'regplot' });
164
+ }
165
+
166
+ function describeFits(keys, fits, P, en, locale, spec) {
167
+ const what = `${humanize(P.y).toLowerCase()} ${en ? 'vs' : 'versus'} ${humanize(P.x).toLowerCase()}`;
168
+ const parts = keys.map((k, i) => {
169
+ const f = fits[i];
170
+ const first = f.line[0][1], last = f.line[f.line.length - 1][1];
171
+ const dir = last > first ? (en ? 'rises' : 'sube') : (en ? 'falls' : 'baja');
172
+ const r2 = f.r2 !== null ? `, R² ${dec(f.r2, locale)}` : '';
173
+ return `${k ? k + ': ' : ''}${en ? 'the fit' : 'el ajuste'} ${dir}${r2}`;
174
+ });
175
+ const kind = spec.lowess ? 'LOWESS' : (spec.order ?? 1) > 1 ? (en ? 'polynomial' : 'polinomial') : en ? 'linear' : 'lineal';
176
+ return en
177
+ ? `Regression plot of ${what} with ${P.pts.length} points and a ${kind} fit. ${parts.join('; ')}.`
178
+ : `Gráfico de regresión de ${what} con ${formatNumber(P.pts.length, 0, locale)} puntos y ajuste ${kind}. ${parts.join('; ')}.`;
179
+ }
180
+
181
+ function pointLabels(pts, spec, xS, yS, r, inner, theme, P) {
182
+ if (!P.labelF || spec.labels === false || spec.labels === undefined) return '';
183
+ let chosen;
184
+ if (Array.isArray(spec.labels)) chosen = pts.filter((p) => spec.labels.map(String).includes(p.name));
185
+ else if (typeof spec.labels === 'number') chosen = [...pts].sort((a, b) => b.y - a.y).slice(0, spec.labels);
186
+ else return '';
187
+ const placed = [];
188
+ const out = [];
189
+ for (const p of chosen) {
190
+ if (!p.name) continue;
191
+ const w = measure(p.name, 12, 500);
192
+ let lx = xS(p.x) + r + 4, anchor = 'start';
193
+ const ly = yS(p.y) + 4;
194
+ if (lx + w > inner.x + inner.w) { lx = xS(p.x) - r - 4; anchor = 'end'; }
195
+ const box = anchor === 'start' ? [lx, ly - 11, lx + w, ly + 3] : [lx - w, ly - 11, lx, ly + 3];
196
+ if (placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
197
+ placed.push(box);
198
+ out.push(el('text', { x: lx, y: ly, fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, ...halo(theme) }, esc(p.name)));
199
+ }
200
+ return g({ class: 'pa-labels' }, out);
201
+ }
202
+
203
+ // ── residplot ──────────────────────────────────────────────────────────────
204
+
205
+ /** Residuos de la regresión versus x, con línea en cero y suavizado opcional (lowess: true). */
206
+ export function residplot(spec, theme) {
207
+ const data = ensureData(spec);
208
+ const P = readPoints(spec, data);
209
+ const en = EN(spec);
210
+ const locale = spec.locale || 'es';
211
+ const fxv = (v) => (spec.logx ? Math.log10(v) : v);
212
+ const fit = polyfit(P.pts.map((p) => fxv(p.x)), P.pts.map((p) => p.y), { order: spec.order ?? 1 });
213
+ const res = P.pts.map((p) => ({ ...p, r: p.y - fit.predict(fxv(p.x)) }));
214
+ const frame = createFrame(spec, theme, { height: 340 });
215
+ const area = frame.layout([]);
216
+ const M = theme.marks || {};
217
+ const fx = makeFormat(spec.xFormat, locale, res.map((p) => p.x));
218
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, res.map((p) => p.r));
219
+ const lim = Math.max(...res.map((p) => Math.abs(p.r))) || 1;
220
+ const yAx = axisDomain([-lim, lim], { domain: spec.yDomain, count: Math.max(3, Math.round(area.h / 60)) });
221
+ const yt = tickFmt(fy, yAx);
222
+ const top = 18 + yAxisTop(theme);
223
+ const inner = { x: area.x + yAxisMargin(yAx.ticks, yt, theme), y: area.y + top, w: 0, h: area.h - top - 44 };
224
+ inner.w = area.x + area.w - inner.x - 4;
225
+ const r = res.length > 300 ? 2.5 : M.pointRadius ?? 4;
226
+ 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)) });
227
+ const xS = xAx.scale(inner.x + r + 2, inner.x + inner.w - r - 2);
228
+ const yS = yAx.scale(inner.y + inner.h, inner.y);
229
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [yAx.min, yAx.max]; }
230
+ const body = [yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN })];
231
+ const y0 = Math.round(yS(0)) + 0.5;
232
+ 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 }));
233
+ 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)}`) })))));
234
+ if (spec.lowess) {
235
+ const lw = lowess(res.map((p) => p.x), res.map((p) => p.r));
236
+ 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 }));
237
+ }
238
+ body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
239
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
240
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? (en ? 'Residual' : 'Residuo') });
241
+ const sdv = Math.sqrt(res.reduce((a, p) => a + p.r * p.r, 0) / Math.max(1, res.length - 2));
242
+ 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)}.`;
243
+ return frame.finish(body, { desc, type: 'residplot' });
244
+ }
245
+
246
+ // ── jointplot ──────────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Dispersión central con distribuciones marginales arriba (x) y a la derecha (y).
250
+ * Opciones: x, y, hue, kind ('scatter' | 'reg'), marginal ('hist' | 'kde'),
251
+ * bins, ci, order, xScale/yScale 'log', xDomain, yDomain, label + labels.
252
+ */
253
+ export function jointplot(spec, theme) {
254
+ const data = ensureData(spec);
255
+ const P = readPoints(spec, data);
256
+ const { pts, keys, xLog, yLog } = P;
257
+ const en = EN(spec);
258
+ const locale = spec.locale || 'es';
259
+ const K = keys.length;
260
+ const cmap = colorMap(keys, theme, spec.colors);
261
+ const colorOf = (k) => (K === 1 ? (spec.colors && spec.colors[k]) || theme.categorical[0] : cmap.get(k));
262
+ const legend = K > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: colorOf(k) })) : [];
263
+ const frame = createFrame(spec, theme, { height: 520 });
264
+ const area = frame.layout(legend);
265
+ const M = theme.marks || {};
266
+ const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
267
+ const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
268
+ 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)) });
269
+ 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)) });
270
+ const yt = tickFmt(fy, yAx);
271
+ const mH = Math.max(44, Math.min(90, area.h * 0.17));
272
+ const gap = 6;
273
+ const leftM = yAxisMargin(yAx.ticks, yt, theme);
274
+ const topGap = gap + yAxisTop(theme);
275
+ const inner = { x: area.x + leftM, y: area.y + 18 + mH + topGap, w: 0, h: 0 };
276
+ inner.w = area.x + area.w - inner.x - mH - gap;
277
+ inner.h = area.y + area.h - 44 - inner.y;
278
+ const r = spec.radius ?? (pts.length > 400 ? 2.5 : pts.length > 120 ? 3.25 : M.pointRadius ?? 4);
279
+ const xS = xAx.scale(inner.x + r + 2, inner.x + inner.w - r - 2);
280
+ const yS = yAx.scale(inner.y + inner.h - r - 2, inner.y + r + 2);
281
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [yAx.min, yAx.max]; }
282
+ const body = [yGrid(yAx.ticks, yS, inner, theme, { baseline: NaN })];
283
+ 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 }));
284
+
285
+ // Marginales: comparten bins / grilla en el espacio de la escala.
286
+ const tx = (v) => (xLog ? Math.log10(v) : v), ty = (v) => (yLog ? Math.log10(v) : v);
287
+ const ix = (t) => (xLog ? 10 ** t : t), iy = (t) => (yLog ? 10 ** t : t);
288
+ const marginal = spec.marginal || 'hist';
289
+ const groups = keys.map((k) => pts.filter((p) => p.k === k));
290
+ const topBox = { x: inner.x, y: inner.y - topGap - mH, w: inner.w, h: mH };
291
+ const rightBox = { x: inner.x + inner.w + gap, y: inner.y, w: mH, h: inner.h };
292
+ const op = K > 1 ? 0.55 : 0.85;
293
+ if (marginal === 'kde') {
294
+ const ex = [tx(xAx.min), tx(xAx.max)], ey = [ty(yAx.min), ty(yAx.max)];
295
+ 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]) : []));
296
+ 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]) : []));
297
+ const mx = Math.max(...kx.flat().map((p) => p[1]), 1e-12), my = Math.max(...ky.flat().map((p) => p[1]), 1e-12);
298
+ kx.forEach((c, i) => {
299
+ if (!c.length) return;
300
+ 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('');
301
+ 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 }));
302
+ body.push(el('path', { d: pth, fill: 'none', stroke: colorOf(keys[i]), strokeWidth: 1.75 }));
303
+ });
304
+ ky.forEach((c, i) => {
305
+ if (!c.length) return;
306
+ const pth = c.map(([t, d], j) => `${j ? 'L' : 'M'}${(rightBox.x + (d / my) * rightBox.w).toFixed(2)},${yS(iy(t)).toFixed(2)}`).join('');
307
+ 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 }));
308
+ body.push(el('path', { d: pth, fill: 'none', stroke: colorOf(keys[i]), strokeWidth: 1.75 }));
309
+ });
310
+ } else {
311
+ // Bins exactos dentro del dominio del eje (cantidad según Freedman–Diaconis).
312
+ const nb = (vs) => spec.bins ?? Math.max(8, Math.min(30, histogram(vs).length));
313
+ const vx = pts.map((p) => tx(p.x)), vy = pts.map((p) => ty(p.y));
314
+ const bx = histogram(vx, { bins: nb(vx), domain: [tx(xAx.min), tx(xAx.max)] });
315
+ const by = histogram(vy, { bins: nb(vy), domain: [ty(yAx.min), ty(yAx.max)] });
316
+ 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);
317
+ const cx = groups.map((gp) => countIn(gp, bx, tx, (p) => p.x));
318
+ const cy = groups.map((gp) => countIn(gp, by, ty, (p) => p.y));
319
+ const mx = Math.max(...cx.flat(), 1), my = Math.max(...cy.flat(), 1);
320
+ groups.forEach((gp, gi) => {
321
+ const c = colorOf(keys[gi]);
322
+ bx.forEach((b, i) => {
323
+ const a0 = xS(ix(b.x0)) + 1, a1 = xS(ix(b.x1)) - 1;
324
+ const h = (cx[gi][i] / mx) * topBox.h;
325
+ if (h <= 0 || a1 <= a0) return;
326
+ 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]}`) }));
327
+ });
328
+ by.forEach((b, i) => {
329
+ const a0 = yS(iy(b.x1)) + 1, a1 = yS(iy(b.x0)) - 1;
330
+ const w = (cy[gi][i] / my) * rightBox.w;
331
+ if (w <= 0 || a1 <= a0) return;
332
+ 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]}`) }));
333
+ });
334
+ });
335
+ }
336
+ 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 }));
337
+ 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 }));
338
+
339
+ // Ajuste opcional (kind: 'reg').
340
+ if (spec.kind === 'reg') {
341
+ keys.forEach((k, i) => {
342
+ const f = fitGroup(groups[i], spec, { xLog, xMin: xAx.min, xMax: xAx.max });
343
+ const L = (arr) => arr.map(([a, b], j) => `${j ? 'L' : 'M'}${xS(a).toFixed(2)},${yS(b).toFixed(2)}`).join('');
344
+ 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 }));
345
+ body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
346
+ });
347
+ }
348
+ 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 : '') })))));
349
+ body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
350
+ body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
351
+ body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
352
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(P.x), y: spec.yLabel === false ? null : spec.yLabel ?? humanize(P.y) });
353
+ const fit = polyfit(pts.map((p) => tx(p.x)), pts.map((p) => p.y));
354
+ const desc = en
355
+ ? `Joint plot of ${humanize(P.y)} vs ${humanize(P.x)} with marginal distributions (${pts.length} points, R² ${dec(fit.r2, locale)}).`
356
+ : `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)}).`;
357
+ return frame.finish(body, { desc, type: 'jointplot' });
358
+ }
359
+
360
+ function hashStr(s) {
361
+ let h = 0;
362
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
363
+ return h;
364
+ }
@@ -0,0 +1,133 @@
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 } 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
+
20
+ // Enlaces agregados
21
+ const lmap = new Map();
22
+ for (const r of data) {
23
+ const v = num(r[valueF]);
24
+ if (!(v > 0)) continue;
25
+ const s = String(r[from]), t = String(r[to]);
26
+ if (s === t) continue;
27
+ const k = s + '\u0000' + t;
28
+ lmap.set(k, (lmap.get(k) || 0) + v);
29
+ }
30
+ const links = [...lmap.entries()].map(([k, v]) => { const [s, t] = k.split('\u0000'); return { s, t, v }; });
31
+ const names = [...new Set(links.flatMap((l) => [l.s, l.t]))];
32
+ const nodes = new Map(names.map((n) => [n, { name: n, in: [], out: [], depth: 0 }]));
33
+ 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); }
34
+ for (const n of nodes.values()) n.value = Math.max(sum(n.in), sum(n.out));
35
+
36
+ // Profundidad = camino más largo desde un origen (con tope por si hay ciclos).
37
+ for (let it = 0; it < names.length; it++) {
38
+ let changed = false;
39
+ for (const l of links) if (l.tn.depth < l.sn.depth + 1) { l.tn.depth = l.sn.depth + 1; changed = true; }
40
+ if (!changed) break;
41
+ }
42
+ const maxDepth = Math.max(...[...nodes.values()].map((n) => n.depth));
43
+ for (const n of nodes.values()) if (!n.out.length) n.depth = maxDepth; // sumideros a la derecha
44
+ const cols = Array.from({ length: maxDepth + 1 }, () => []);
45
+ for (const n of nodes.values()) cols[n.depth].push(n);
46
+ cols.forEach((c) => c.sort((a, b) => b.value - a.value));
47
+
48
+ const fmt = makeFormat(spec.format, locale, links.map((l) => l.v));
49
+ const frame = createFrame(spec, theme, { height: 440 });
50
+ const area = frame.layout([]);
51
+ const label = (n) => n.name;
52
+ 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;
53
+ const last = cols[cols.length - 1];
54
+ 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;
55
+ const NW = 10;
56
+ const inner = { x: area.x + leftW, y: area.y + 4, w: area.w - leftW - rightW, h: area.h - 8 };
57
+ const pad = Math.max(6, Math.min(18, inner.h / Math.max(...cols.map((c) => c.length)) / 4));
58
+ const ky = Math.min(...cols.map((c) => (inner.h - (c.length - 1) * pad) / (sum(c.map((n) => ({ v: n.value }))) || 1)));
59
+ const colX = (i) => inner.x + (cols.length === 1 ? 0 : (i * (inner.w - NW)) / (cols.length - 1));
60
+
61
+ const place = () => {
62
+ cols.forEach((c, i) => {
63
+ const H = sum(c.map((n) => ({ v: n.value }))) * ky + (c.length - 1) * pad;
64
+ let y = inner.y + (inner.h - H) / 2;
65
+ for (const n of c) { n.x = colX(i); n.y = y; n.h = Math.max(1, n.value * ky); y += n.h + pad; }
66
+ });
67
+ };
68
+ place();
69
+ // Baricentros para reducir cruces.
70
+ const center = (n) => n.y + n.h / 2;
71
+ for (let it = 0; it < 6; it++) {
72
+ for (let i = 1; i < cols.length; i++) cols[i].sort((a, b) => bary(a.in, 'sn') - bary(b.in, 'sn'));
73
+ place();
74
+ for (let i = cols.length - 2; i >= 0; i--) cols[i].sort((a, b) => bary(a.out, 'tn') - bary(b.out, 'tn'));
75
+ place();
76
+ }
77
+ function bary(ls, key) {
78
+ const w = sum(ls);
79
+ return w ? ls.reduce((a, l) => a + center(l[key]) * l.v, 0) / w : Infinity;
80
+ }
81
+
82
+ // Color: orígenes en orden fijo; los demás heredan su mayor flujo de entrada.
83
+ // Si hay un solo origen, el color nace en la primera etapa con 2+ nodos.
84
+ const cc = Math.max(0, cols.findIndex((c) => c.length > 1));
85
+ const fixed = (n) => spec.colors && spec.colors[n.name];
86
+ for (let i = 0; i < cc; i++) for (const n of cols[i]) n.color = fixed(n) || theme.ink;
87
+ [...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); });
88
+ for (let i = cc + 1; i < cols.length; i++) for (const n of cols[i]) {
89
+ const big = [...n.in].sort((a, b) => b.v - a.v)[0];
90
+ n.color = fixed(n) || (n.in.length ? big.sn.color : theme.categorical[0]);
91
+ }
92
+ const linkColor = (l) => (l.sn.depth < cc ? l.tn.color : l.sn.color);
93
+
94
+ // Posición de cada enlace dentro de sus nodos.
95
+ for (const n of nodes.values()) {
96
+ let sy = n.y;
97
+ for (const l of [...n.out].sort((a, b) => center(a.tn) - center(b.tn))) { l.y0 = sy; sy += l.v * ky; }
98
+ let ty = n.y;
99
+ for (const l of [...n.in].sort((a, b) => center(a.sn) - center(b.sn))) { l.y1 = ty; ty += l.v * ky; }
100
+ }
101
+
102
+ const body = [];
103
+ const linkG = [];
104
+ for (const l of [...links].sort((a, b) => b.v - a.v)) {
105
+ const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
106
+ 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`;
107
+ const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
108
+ 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}` }));
109
+ }
110
+ body.push(g({ class: 'pa-links' }, linkG));
111
+ for (const [ci, c] of cols.entries()) for (const n of c) {
112
+ const parts = [el('rect', { x: n.x, y: n.y, width: NW, height: n.h, rx: 2, fill: n.color })];
113
+ const isLeft = ci === 0 && cols.length > 1;
114
+ const lx = isLeft ? n.x - 8 : n.x + NW + 8;
115
+ const anchor = isLeft ? 'end' : 'start';
116
+ const maxW = isLeft ? leftW - 10 : ci === cols.length - 1 ? rightW - 10 : (inner.w / Math.max(1, cols.length - 1)) * 0.55;
117
+ const halo = ci > 0 && ci < cols.length - 1 ? { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' } : {};
118
+ const cy = n.y + n.h / 2;
119
+ if (n.h >= 30) {
120
+ parts.push(text(lx, cy - 2, truncate(label(n), maxW, 12.5, 500), { fill: theme.ink, fontSize: 12.5, fontWeight: 500, textAnchor: anchor, ...halo }));
121
+ parts.push(text(lx, cy + 13, fmt(n.value), { fill: theme.lede, fontSize: 12, textAnchor: anchor, ...halo }));
122
+ } else if (n.h >= 8 || c.length < 12) {
123
+ parts.push(text(lx, cy + 4, truncate(label(n), maxW, 12, 500), { fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, ...halo }));
124
+ }
125
+ body.push(g({ 'data-tip': `${n.name}\n${fmt(n.value)}` }, parts));
126
+ }
127
+ const en = String(locale).startsWith('en');
128
+ const top = [...links].sort((a, b) => b.v - a.v).slice(0, 3).map((l) => `${l.s} → ${l.t} (${fmt(l.v)})`).join('; ');
129
+ 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}.`;
130
+ return frame.finish(body, { desc, type: 'sankey' });
131
+ }
132
+
133
+ const sum = (ls) => ls.reduce((a, l) => a + l.v, 0);