plotagents 0.5.0 → 0.8.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 +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -0,0 +1,512 @@
1
+ // Matrices de relación al estilo seaborn / pandas:
2
+ // corr matriz de correlación (df.corr() + sns.heatmap con máscara triangular)
3
+ // clustermap la misma matriz reordenada por agrupamiento jerárquico, con dendrograma
4
+ // pairplot matriz de dispersión: dispersión abajo, distribución en la diagonal, r arriba
5
+ // La escala de color es divergente y SIEMPRE simétrica en [-1, 1]: el mismo
6
+ // color significa la misma fuerza en cualquier gráfico.
7
+ import { el, g, text, tip, barPath, dotLayer } from '../svg.js';
8
+ import { measure, truncate } from '../text.js';
9
+ import { makeFormat, formatNumber } from '../format.js';
10
+ import { ramp, textOn, colorMap, readable } from '../theme.js';
11
+ import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
12
+ import { createFrame } from '../frame.js';
13
+ import { ctx } from '../context.js';
14
+ import { pearson, polyfit, histogram, kde as kdeEst } from '../stats.js';
15
+ import { hueField, axisDomain, tickFmt } from './dist.js';
16
+
17
+ const isEN = (spec) => String(spec.locale || 'es').startsWith('en');
18
+ const halo = (theme) => ({ stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' });
19
+
20
+ // ── Estadística ────────────────────────────────────────────────────────────
21
+
22
+ /** Rangos con empates promediados (para Spearman). */
23
+ function ranks(vs) {
24
+ const idx = vs.map((_, i) => i).sort((a, b) => vs[a] - vs[b]);
25
+ const out = new Array(vs.length);
26
+ for (let i = 0; i < idx.length;) {
27
+ let j = i;
28
+ while (j + 1 < idx.length && vs[idx[j + 1]] === vs[idx[i]]) j++;
29
+ const avg = (i + j) / 2 + 1;
30
+ for (let t = i; t <= j; t++) out[idx[t]] = avg;
31
+ i = j + 1;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** Φ(z) (Abramowitz–Stegun 7.1.26, error < 1,5e-7). */
37
+ function normCdf(z) {
38
+ const x = Math.abs(z) / Math.SQRT2;
39
+ const t = 1 / (1 + 0.3275911 * x);
40
+ const erf = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
41
+ return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2;
42
+ }
43
+
44
+ /** ln Γ(x) (Lanczos, g = 7). */
45
+ function lnGamma(x) {
46
+ const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
47
+ x -= 1;
48
+ let a = c[0];
49
+ const t = x + 7.5;
50
+ for (let i = 1; i < 9; i++) a += c[i] / (x + i);
51
+ return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a);
52
+ }
53
+
54
+ /** Beta incompleta regularizada I_x(a, b) (fracción continua de Lentz). */
55
+ function betaInc(x, a, b) {
56
+ if (x <= 0) return 0;
57
+ if (x >= 1) return 1;
58
+ const front = Math.exp(lnGamma(a + b) - lnGamma(a) - lnGamma(b) + a * Math.log(x) + b * Math.log(1 - x));
59
+ const cf = (xx, aa, bb) => {
60
+ const tiny = 1e-300;
61
+ let c = 1, d = 1 - ((aa + bb) * xx) / (aa + 1);
62
+ d = 1 / (Math.abs(d) < tiny ? tiny : d);
63
+ let h = d;
64
+ for (let m = 1; m <= 200; m++) {
65
+ const m2 = 2 * m;
66
+ let k = (m * (bb - m) * xx) / ((aa + m2 - 1) * (aa + m2));
67
+ d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
68
+ c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
69
+ h *= d * c;
70
+ k = (-(aa + m) * (aa + bb + m) * xx) / ((aa + m2) * (aa + m2 + 1));
71
+ d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
72
+ c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
73
+ const del = d * c;
74
+ h *= del;
75
+ if (Math.abs(del - 1) < 1e-14) break;
76
+ }
77
+ return h;
78
+ };
79
+ return x < (a + 1) / (a + b + 2) ? (front * cf(x, a, b)) / a : 1 - (front * cf(1 - x, b, a)) / b;
80
+ }
81
+
82
+ /**
83
+ * p bilateral de H0: ρ = 0, con la t de Student de n − 2 grados de libertad
84
+ * (la misma de scipy.stats.pearsonr / spearmanr y de pandas). La aproximación
85
+ * normal de Fisher se queda corta con pocas filas y cambia quién pasa `alpha`.
86
+ */
87
+ function pValue(r, n) {
88
+ if (!Number.isFinite(r) || n < 3) return NaN;
89
+ if (Math.abs(r) >= 1) return 0;
90
+ // x = df / (df + t²) = 1 − r²
91
+ return betaInc(1 - r * r, (n - 2) / 2, 0.5);
92
+ }
93
+
94
+ /** Campos numéricos a correlacionar: `vars`, `y: [...]` o todas las medidas. */
95
+ export function corrVars(spec, data) {
96
+ const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
97
+ if (list) {
98
+ for (const f of list) requireField(data, f, 'vars');
99
+ return uniq(list.map(String));
100
+ }
101
+ const hue = hueField(spec);
102
+ return fieldsOf(data).filter((f) => f !== hue && f !== spec.label && fieldKind(data, f) === 'number');
103
+ }
104
+
105
+ /**
106
+ * Correlaciones por pares (casos completos de cada par).
107
+ * Devuelve { r, n, p } como matrices k × k. Un par sin variación da NaN.
108
+ */
109
+ export function correlations(data, vars, method = 'pearson') {
110
+ const cols = vars.map((f) => data.map((row) => num(row[f])));
111
+ const k = vars.length;
112
+ const M = () => Array.from({ length: k }, () => new Array(k).fill(NaN));
113
+ const r = M(), n = M(), p = M();
114
+ for (let i = 0; i < k; i++) {
115
+ const c = cols[i].filter(Number.isFinite);
116
+ const varies = c.some((v) => v !== c[0]);
117
+ r[i][i] = varies ? 1 : NaN; n[i][i] = c.length; p[i][i] = varies ? 0 : NaN;
118
+ for (let j = i + 1; j < k; j++) {
119
+ const xs = [], ys = [];
120
+ for (let t = 0; t < data.length; t++) {
121
+ const a = cols[i][t], b = cols[j][t];
122
+ if (Number.isFinite(a) && Number.isFinite(b)) { xs.push(a); ys.push(b); }
123
+ }
124
+ const m = xs.length;
125
+ let v = NaN;
126
+ if (m >= 3 && xs.some((x) => x !== xs[0]) && ys.some((y) => y !== ys[0])) {
127
+ v = method === 'spearman' ? pearson(ranks(xs), ranks(ys)) : pearson(xs, ys);
128
+ v = Math.max(-1, Math.min(1, v));
129
+ }
130
+ r[i][j] = r[j][i] = v;
131
+ n[i][j] = n[j][i] = m;
132
+ p[i][j] = p[j][i] = pValue(v, m);
133
+ }
134
+ }
135
+ return { r, n, p };
136
+ }
137
+
138
+ /**
139
+ * Agrupamiento jerárquico aglomerativo (enlace promedio, distancia 1 − r),
140
+ * como el de sns.clustermap. Devuelve el árbol: hojas { leaf }, nodos { left, right, h }.
141
+ */
142
+ export function clusterTree(r, idx = r.map((_, i) => i)) {
143
+ let nodes = idx.map((i) => ({ leaf: i, members: [i], h: 0 }));
144
+ const dist = (A, B) => {
145
+ let s = 0, c = 0;
146
+ for (const a of A.members) for (const b of B.members) { const v = r[a][b]; if (Number.isFinite(v)) { s += 1 - v; c++; } }
147
+ return c ? s / c : 2;
148
+ };
149
+ while (nodes.length > 1) {
150
+ let best = [0, 1, Infinity];
151
+ for (let a = 0; a < nodes.length; a++) for (let b = a + 1; b < nodes.length; b++) {
152
+ const d = dist(nodes[a], nodes[b]);
153
+ if (d < best[2] - 1e-12) best = [a, b, d];
154
+ }
155
+ const [a, b, d] = best;
156
+ const node = { left: nodes[a], right: nodes[b], h: Math.max(d, nodes[a].h, nodes[b].h), members: [...nodes[a].members, ...nodes[b].members] };
157
+ nodes = nodes.filter((_, i) => i !== a && i !== b);
158
+ nodes.splice(a, 0, node);
159
+ }
160
+ return nodes[0];
161
+ }
162
+
163
+ const leaves = (node) => (node.left ? [...leaves(node.left), ...leaves(node.right)] : [node.leaf]);
164
+
165
+ // ── corr / clustermap ──────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Matriz de correlación. Desde filas crudas (`vars` o todas las medidas) o
169
+ * desde una matriz ya calculada en formato largo (`x`, `y`, `value`).
170
+ * Opciones: method ("pearson" | "spearman"), triangle ("lower" | "full"),
171
+ * cluster (reordena y dibuja el dendrograma), alpha (apaga las no significativas),
172
+ * highlight (una variable), labels, cellSize.
173
+ */
174
+ export function corr(spec, theme) {
175
+ const data = ensureData(spec);
176
+ const en = isEN(spec);
177
+ const locale = spec.locale || 'es';
178
+ const method = String(spec.method || 'pearson').toLowerCase() === 'spearman' ? 'spearman' : 'pearson';
179
+ const long = typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
180
+
181
+ let vars, R, N, P;
182
+ if (long) {
183
+ requireField(data, spec.x, 'x'); requireField(data, spec.y, 'y'); requireField(data, spec.value, 'value');
184
+ vars = uniq([...data.map((row) => String(row[spec.x])), ...data.map((row) => String(row[spec.y]))]);
185
+ const at = new Map(vars.map((v, i) => [v, i]));
186
+ const k = vars.length;
187
+ R = Array.from({ length: k }, (_, i) => Array.from({ length: k }, (_, j) => (i === j ? 1 : NaN)));
188
+ N = R.map((row) => row.map(() => NaN));
189
+ P = N.map((row) => [...row]);
190
+ for (const row of data) {
191
+ const i = at.get(String(row[spec.x])), j = at.get(String(row[spec.y])), v = num(row[spec.value]);
192
+ if (!Number.isFinite(v)) continue;
193
+ R[i][j] = v;
194
+ if (!Number.isFinite(R[j][i]) || i === j) R[j][i] = v;
195
+ }
196
+ } else {
197
+ vars = corrVars(spec, data);
198
+ ({ r: R, n: N, p: P } = correlations(data, vars, method));
199
+ }
200
+ if (vars.length < 2) {
201
+ throw en
202
+ ? new PlotError('A correlation matrix needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area", "age"]`.')
203
+ : new PlotError('La matriz de correlación necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie", "antiguedad"]`.');
204
+ }
205
+ const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || (long ? vars[i] : humanize(vars[i]));
206
+
207
+ // Orden: agrupamiento jerárquico, lista explícita o el de los datos.
208
+ const k = vars.length;
209
+ let tree = null;
210
+ let order = vars.map((_, i) => i);
211
+ if (spec.cluster && k > 2) { tree = clusterTree(R); order = leaves(tree); }
212
+ else if (Array.isArray(spec.order)) {
213
+ const pos = (i) => { const a = spec.order.indexOf(vars[i]); return a < 0 ? spec.order.indexOf(name(i)) : a; };
214
+ order = [...order].sort((a, b) => ((pos(a) + 1 || 1e9) - (pos(b) + 1 || 1e9)) || a - b);
215
+ }
216
+ const triangle = spec.triangle === false ? 'full' : spec.triangle === true ? 'lower' : (spec.triangle ?? (spec.cluster ? 'full' : 'lower'));
217
+ const full = triangle === 'full' || k === 2 && triangle !== 'lower';
218
+ const rows = full ? order : order.slice(1);
219
+ const cols = full ? order : order.slice(0, -1);
220
+ const showDendro = !!tree && full && spec.dendrogram !== false;
221
+
222
+ // Escala: [-1, 1] para correlaciones; simétrica alrededor de 0 si la matriz trae otra cosa (covarianzas).
223
+ const vals = R.flat().filter(Number.isFinite);
224
+ const span = long ? Math.max(1, ...vals.map(Math.abs)) : 1;
225
+ const stops = theme.diverging;
226
+ const colorOf = (v) => (Number.isFinite(v) ? ramp(stops, 0.5 + v / span / 2) : theme.tint);
227
+ const dec = (v) => formatNumber(v, span > 1 ? 1 : 2, locale);
228
+ const fmt = long && span > 1 ? makeFormat(spec.format, locale, vals) : dec;
229
+ const endFmt = (v) => (span === 1 ? formatNumber(v, 0, locale) : fmt(v));
230
+ const alpha = Number.isFinite(+spec.alpha) && +spec.alpha > 0 && +spec.alpha < 1 ? +spec.alpha : null;
231
+ const insignificant = (i, j) => alpha !== null && i !== j && Number.isFinite(P[i][j]) && P[i][j] >= alpha;
232
+ const sym = method === 'spearman' ? 'ρ' : 'r';
233
+ const pText = (p) => (Number.isFinite(p) ? (p < 0.001 ? `p < ${formatNumber(0.001, 3, locale)}` : `p = ${formatNumber(p, p < 0.01 ? 3 : 2, locale)}`) : '');
234
+
235
+ const hl = new Set([].concat(spec.highlight ?? []).map(String));
236
+ const isHL = (i) => hl.has(vars[i]) || hl.has(name(i));
237
+ const anyHL = hl.size > 0 && vars.some((_, i) => isHL(i));
238
+
239
+ const rampTitle = spec.legendTitle ?? (long ? humanize(spec.value) : method === 'spearman' ? (en ? 'Spearman ρ' : 'ρ de Spearman') : (en ? 'Pearson r' : 'r de Pearson'));
240
+ const legend = spec.legend === false ? [] : [
241
+ { type: 'ramp', stops, min: endFmt(-span), max: endFmt(span), title: rampTitle },
242
+ ...(alpha !== null ? [{ label: en ? `p ≥ ${formatNumber(alpha, 2, locale)}: not significant` : `p ≥ ${formatNumber(alpha, 2, locale)}: no significativa`, color: theme.tint }] : []),
243
+ ];
244
+ const frame = createFrame(spec, theme, { height: 520 });
245
+ const area = frame.layout(legend);
246
+
247
+ const X = ctx.textScale;
248
+ const fs = 12;
249
+ const rowW = Math.min(area.w * 0.34, Math.max(...rows.map((i) => measure(name(i), fs)))) + 12;
250
+ const dendroH = showDendro ? 48 : 0;
251
+ const colW = Math.max(...cols.map((i) => measure(name(i), fs)));
252
+ const colLabMax = Math.min(colW, 150, spec.height ? Math.max(40, area.h * 0.28) : 150);
253
+ const labH = (ang) => (ang === 90 ? colLabMax + 14 * X : ang ? colLabMax * 0.71 + 14 * X : 20 * X);
254
+ // Celdas cuadradas; con alto fijo, la grilla deja lugar a las etiquetas de columna.
255
+ const fitCell = (reserve) => Math.max(14, Math.min(+spec.cellSize || 96, (area.w - rowW) / cols.length, (spec.height ? Math.max(80, area.h - dendroH - reserve) : 600) / rows.length));
256
+ // Derechas si caben; a 45° la separación es cell·sen45°, así que con celdas chicas van verticales.
257
+ const angleFor = (c) => (colW + 8 <= c ? 0 : c * 0.71 < fs * X + 3 ? 90 : 45);
258
+ let cell = fitCell(labH(0));
259
+ let angle = angleFor(cell);
260
+ if (spec.height && angle) {
261
+ cell = fitCell(labH(angle));
262
+ angle = angleFor(cell);
263
+ if (angle === 90) cell = fitCell(labH(90));
264
+ }
265
+ const colLabH = labH(angle);
266
+ const gx = area.x + rowW, gy = area.y + dendroH;
267
+ const gw = cell * cols.length, gh = cell * rows.length;
268
+ if (spec.height === undefined) frame.setHeight(gy + gh + colLabH + 8 + frame.chrome);
269
+
270
+ const lab = cell >= 70 ? 13 : 11;
271
+ const showLabels = spec.labels ?? cell >= measure(dec(-0.88), lab) + 8;
272
+ const body = [];
273
+
274
+ // Dendrograma arriba (columnas = hojas en orden).
275
+ if (showDendro) {
276
+ const cx = new Map(cols.map((i, c) => [i, gx + c * cell + cell / 2]));
277
+ const maxH = tree.h || 1;
278
+ const yOf = (h) => gy - 6 - (h / maxH) * (dendroH - 12);
279
+ const segs = [];
280
+ const walk = (nd) => {
281
+ if (!nd.left) return [cx.get(nd.leaf), gy - 6];
282
+ const [xl, yl] = walk(nd.left), [xr, yr] = walk(nd.right);
283
+ const y = yOf(nd.h);
284
+ segs.push(`M${xl.toFixed(1)},${yl.toFixed(1)}V${y.toFixed(1)}H${xr.toFixed(1)}V${yr.toFixed(1)}`);
285
+ return [(xl + xr) / 2, y];
286
+ };
287
+ walk(tree);
288
+ body.push(el('path', { d: segs.join(''), fill: 'none', stroke: theme.muted, strokeWidth: 1.1, strokeLinejoin: 'round' }));
289
+ }
290
+
291
+ rows.forEach((i, ri) => {
292
+ const on = !anyHL || isHL(i);
293
+ body.push(text(gx - 10, gy + ri * cell + cell / 2 + 4, truncate(name(i), rowW - 12, fs), { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, textAnchor: 'end', fontWeight: anyHL && on ? 600 : null }));
294
+ cols.forEach((j, ci) => {
295
+ if (!full && order.indexOf(i) <= order.indexOf(j)) return;
296
+ const v = R[i][j];
297
+ const ns = insignificant(i, j);
298
+ const fill = ns ? theme.tint : colorOf(v);
299
+ const x = gx + ci * cell + 1, y = gy + ri * cell + 1, w = Math.max(0, cell - 2);
300
+ const dim = anyHL && !isHL(i) && !isHL(j);
301
+ const parts = [el('rect', { x, y, width: w, height: w, rx: Math.min(4, cell / 8), fill, opacity: dim ? 0.22 : null })];
302
+ if (showLabels && Number.isFinite(v) && i !== j) {
303
+ parts.push(text(x + w / 2, y + w / 2 + lab * 0.36, fmt(v), { fill: ns || dim ? theme.muted : textOn(fill, theme), fontSize: lab, textAnchor: 'middle', fontWeight: 500, opacity: dim ? 0.7 : null }));
304
+ }
305
+ const lines = Number.isFinite(v)
306
+ ? [`${long ? humanize(spec.value) : sym} = ${fmt(v)}`, i !== j && Number.isFinite(N[i][j]) ? `n = ${formatNumber(N[i][j], 0, locale)}${Number.isFinite(P[i][j]) ? ' · ' + pText(P[i][j]) : ''}` : '']
307
+ : [en ? 'No variation or too few rows' : 'Sin variación o muy pocas filas'];
308
+ body.push(g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, ...lines) }, parts));
309
+ });
310
+ });
311
+
312
+ // Etiquetas de columna abajo; en diagonal (−45°) si no caben derechas.
313
+ cols.forEach((j, ci) => {
314
+ const on = !anyHL || isHL(j);
315
+ const cx = gx + ci * cell + cell / 2;
316
+ const attrs = { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, fontWeight: anyHL && on ? 600 : null };
317
+ if (angle) {
318
+ const y = gy + gh + (angle === 90 ? 8 : 12);
319
+ const ax = angle === 90 ? cx + fs * 0.35 : cx + 4;
320
+ body.push(text(ax, y, truncate(name(j), colLabMax, fs), { ...attrs, textAnchor: 'end', transform: `rotate(-${angle} ${ax.toFixed(1)} ${y.toFixed(1)})` }));
321
+ } else body.push(text(cx, gy + gh + 16 * X, name(j), { ...attrs, textAnchor: 'middle' }));
322
+ });
323
+
324
+ // Descripción: el par más fuerte de cada signo.
325
+ const pairs = [];
326
+ for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
327
+ pairs.sort((p, q) => q[2] - p[2]);
328
+ const pos = pairs.find((p) => p[2] > 0), neg = [...pairs].reverse().find((p) => p[2] < 0);
329
+ const kind = long ? '' : method === 'spearman' ? ' (Spearman)' : ' (Pearson)';
330
+ const say = (p) => `${name(p[0])} ${en ? 'and' : 'y'} ${name(p[1])} (${fmt(p[2])})`;
331
+ const desc = en
332
+ ? `Correlation matrix${kind} of ${k} variables${tree ? ', ordered by hierarchical clustering' : ''}.` + (pos ? ` Strongest positive: ${say(pos)}.` : '') + (neg ? ` Strongest negative: ${say(neg)}.` : '') + (!pairs.length ? ' No pair has enough data.' : '')
333
+ : `Matriz de correlación${kind} de ${k} variables${tree ? ', ordenadas por agrupamiento jerárquico' : ''}.` + (pos ? ` Más fuerte positiva: ${say(pos)}.` : '') + (neg ? ` Más fuerte negativa: ${say(neg)}.` : '') + (!pairs.length ? ' Ningún par tiene datos suficientes.' : '');
334
+ return frame.finish(body, { desc, type: 'corr' });
335
+ }
336
+
337
+ // ── pairplot ───────────────────────────────────────────────────────────────
338
+
339
+ /**
340
+ * Matriz de dispersión (sns.pairplot). Abajo de la diagonal, dispersión de
341
+ * cada par; en la diagonal, la distribución de cada variable; arriba, el r
342
+ * de Pearson (o dispersión con `upper: "scatter"`, nada con `corner: true`).
343
+ * Opciones: vars, hue, diag ("hist" | "kde"), kind ("scatter" | "reg"), bins, radius.
344
+ */
345
+ export function pairplot(spec, theme) {
346
+ const data = ensureData(spec);
347
+ const en = isEN(spec);
348
+ const locale = spec.locale || 'es';
349
+ let vars = corrVars(spec, data);
350
+ const explicit = spec.vars || Array.isArray(spec.y);
351
+ if (!explicit && vars.length > 6) vars = vars.slice(0, 6);
352
+ if (vars.length < 2) {
353
+ throw en
354
+ ? new PlotError('A pair plot needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area"]`.')
355
+ : new PlotError('La matriz de dispersión necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie"]`.');
356
+ }
357
+ const hue = hueField(spec);
358
+ if (hue) requireField(data, hue, 'hue');
359
+ const k = vars.length;
360
+ const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || humanize(vars[i]);
361
+ const cols = vars.map((f) => data.map((row) => num(row[f])));
362
+ const hk = data.map((row) => (hue ? String(row[hue] ?? '') : ''));
363
+ const keys = hue ? uniq(hk) : [''];
364
+ const K = keys.length;
365
+ const cmap = colorMap(keys, theme, spec.colors);
366
+ const colorOf = (key) => (K === 1 ? (spec.colors && spec.colors['']) || theme.categorical[0] : cmap.get(key));
367
+ const legend = K > 1 && spec.legend !== false ? keys.map((key) => ({ label: key, color: colorOf(key) })) : [];
368
+
369
+ const frame = createFrame(spec, theme, { height: 640 });
370
+ const area = frame.layout(legend);
371
+ const X = ctx.textScale;
372
+ const fmts = vars.map((_, i) => makeFormat(spec.format, locale, cols[i].filter(Number.isFinite)));
373
+ const axes = vars.map((_, i) => axisDomain(cols[i], { nice: 'tight', count: 3 }));
374
+ const tfs = vars.map((_, i) => tickFmt(fmts[i], axes[i]));
375
+ const ticksIn = (ax) => ax.ticks.filter((t) => t >= ax.min && t <= ax.max);
376
+ const fs = 11;
377
+ const leftM = Math.max(...vars.slice(1).map((_, i) => Math.max(0, ...ticksIn(axes[i + 1]).map((t) => measure(tfs[i + 1](t), fs))))) + 8;
378
+ const gap = 8;
379
+ const bottom = 20 * X;
380
+ const fitW = (area.w - leftM - gap * (k - 1)) / k;
381
+ const fitH = spec.height ? (area.h - bottom - gap * (k - 1)) / k : Infinity;
382
+ const s = Math.max(40, Math.min(fitW, fitH, +spec.panelSize || 220));
383
+ const px = (j) => area.x + leftM + j * (s + gap);
384
+ const py = (i) => area.y + i * (s + gap);
385
+ if (spec.height === undefined) frame.setHeight(py(k - 1) + s + bottom + 6 + frame.chrome);
386
+
387
+ const { r: R, n: N } = correlations(data, vars, 'pearson');
388
+ const upper = spec.corner ? 'none' : spec.upper === false ? 'none' : (spec.upper ?? 'corr');
389
+ // Con `hue`, densidades en la diagonal (como seaborn): histogramas superpuestos se mezclan.
390
+ const diag = spec.diag === 'kde' || spec.diag === 'hist' ? spec.diag : hue ? 'kde' : 'hist';
391
+ const n = data.length;
392
+ const rad = spec.radius ?? (n > 600 ? 1.5 : n > 200 ? 2 : n > 60 ? 2.6 : 3.2);
393
+ const M = theme.marks || {};
394
+ const body = [];
395
+ const frameRect = (x, y) => el('rect', { x, y, width: s, height: s, fill: 'none', stroke: theme.grid, strokeWidth: 1 });
396
+ const gridLines = (i, j) => {
397
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
398
+ const out = [];
399
+ for (const t of ticksIn(axes[j])) out.push(el('line', { x1: xs(t), x2: xs(t), y1: py(i), y2: py(i) + s, stroke: theme.grid, strokeWidth: 0.75 }));
400
+ for (const t of ticksIn(axes[i])) out.push(el('line', { x1: px(j), x2: px(j) + s, y1: ys(t), y2: ys(t), stroke: theme.grid, strokeWidth: 0.75 }));
401
+ return out;
402
+ };
403
+
404
+ const scatterPanel = (i, j) => {
405
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
406
+ const pts = [];
407
+ for (let t = 0; t < n; t++) {
408
+ const a = cols[j][t], b = cols[i][t];
409
+ if (Number.isFinite(a) && Number.isFinite(b)) pts.push({ x: a, y: b, key: hk[t], label: spec.label ? String(data[t][spec.label] ?? '') : '' });
410
+ }
411
+ const out = [...gridLines(i, j)];
412
+ out.push(g({}, dotLayer(pts.map((p) => ({
413
+ class: 'pa-m', cx: xs(p.x), cy: ys(p.y), r: rad, fill: colorOf(p.key), fillOpacity: M.dotOpacity ?? 0.72,
414
+ stroke: pts.length > 300 ? 'none' : theme.surface, strokeWidth: 0.75,
415
+ 'data-tip': tip(p.label || p.key || `${name(i)} · ${name(j)}`, `${name(j)}: ${fmts[j](p.x)}`, `${name(i)}: ${fmts[i](p.y)}`, p.label && p.key ? p.key : ''),
416
+ })))));
417
+ if (spec.kind === 'reg') {
418
+ for (const key of keys) {
419
+ const gp = pts.filter((p) => p.key === key);
420
+ if (gp.length < 3) continue;
421
+ const f = polyfit(gp.map((p) => p.x), gp.map((p) => p.y));
422
+ const lo = Math.min(...gp.map((p) => p.x)), hi = Math.max(...gp.map((p) => p.x));
423
+ const clampY = (v) => Math.max(py(i), Math.min(py(i) + s, ys(v)));
424
+ out.push(el('line', { x1: xs(lo), y1: clampY(f.predict(lo)), x2: xs(hi), y2: clampY(f.predict(hi)), stroke: K === 1 ? theme.ink : colorOf(key), strokeWidth: 1.75, opacity: 0.9 }));
425
+ }
426
+ }
427
+ return out;
428
+ };
429
+
430
+ const diagPanel = (i) => {
431
+ const ax = axes[i];
432
+ const xs = ax.scale(px(i) + 4, px(i) + s - 4);
433
+ const base = py(i) + s, top = py(i) + 22 * X;
434
+ const out = [];
435
+ const groups = keys.map((key) => cols[i].filter((v, t) => Number.isFinite(v) && hk[t] === key));
436
+ const allV = cols[i].filter(Number.isFinite);
437
+ if (diag === 'kde') {
438
+ const curves = groups.map((vs) => (vs.length > 1 && vs.some((v) => v !== vs[0]) ? kdeEst(vs, { extent: [ax.min, ax.max], gridSize: 90 }).map(([x, d]) => [x, (d * vs.length) / allV.length]) : []));
439
+ const mx = Math.max(1e-12, ...curves.flat().map((p) => p[1]));
440
+ curves.forEach((c, gi) => {
441
+ if (!c.length) return;
442
+ const yOf = (d) => base - (d / mx) * (base - top);
443
+ const line = c.map(([x, d], t) => `${t ? 'L' : 'M'}${xs(x).toFixed(2)},${yOf(d).toFixed(2)}`).join('');
444
+ out.push(el('path', { d: `${line}L${xs(c[c.length - 1][0]).toFixed(2)},${base}L${xs(c[0][0]).toFixed(2)},${base}Z`, fill: colorOf(keys[gi]), fillOpacity: (M.areaOpacity ?? 0.12) * 1.6 }));
445
+ out.push(el('path', { d: line, fill: 'none', stroke: colorOf(keys[gi]), strokeWidth: 1.6 }));
446
+ });
447
+ } else {
448
+ const nb = spec.bins ?? Math.max(6, Math.min(s < 120 ? 14 : 22, histogram(allV).length));
449
+ const bins = histogram(allV, { bins: nb, domain: [ax.min, ax.max] });
450
+ const counts = groups.map((vs) => bins.map((b, bi) => vs.filter((v) => v >= b.x0 && (v < b.x1 || (bi === bins.length - 1 && v <= b.x1))).length));
451
+ const mx = Math.max(1, ...counts.flat());
452
+ const op = K > 1 ? 0.55 : 0.85;
453
+ counts.forEach((cs, gi) => bins.forEach((b, bi) => {
454
+ const h = (cs[bi] / mx) * (base - top);
455
+ const a0 = xs(b.x0) + 0.5, a1 = xs(b.x1) - 0.5;
456
+ if (h <= 0 || a1 <= a0) return;
457
+ out.push(el('path', { class: 'pa-m', d: barPath(a0, base - h, a1 - a0, h, 'up', 1.5), fill: colorOf(keys[gi]), fillOpacity: op, 'data-tip': tip(name(i), `${fmts[i](b.x0)} – ${fmts[i](b.x1)}`, `${keys[gi] ? keys[gi] + ': ' : ''}${cs[bi]}`) }));
458
+ }));
459
+ }
460
+ out.push(el('line', { x1: px(i), x2: px(i) + s, y1: base - 0.5, y2: base - 0.5, stroke: theme.axis }));
461
+ out.push(text(px(i) + 6, py(i) + 14 * X, truncate(name(i), s - 12, 12, 600), { fill: theme.ink, fontSize: 12, fontWeight: 600, ...halo(theme) }));
462
+ return out;
463
+ };
464
+
465
+ const corrPanel = (i, j) => {
466
+ const v = R[i][j];
467
+ const cx = px(j) + s / 2, cy = py(i) + s / 2;
468
+ const out = [el('rect', { x: px(j), y: py(i), width: s, height: s, fill: Number.isFinite(v) ? ramp(theme.diverging, 0.5 + v / 2) : theme.tint, fillOpacity: Number.isFinite(v) ? 0.08 + 0.32 * Math.abs(v) : 0.5 })];
469
+ if (Number.isFinite(v)) {
470
+ const size = Math.min(s * 0.24, 13 + 15 * Math.abs(v));
471
+ const showGroups = K > 1 && K <= 4 && s >= 110;
472
+ const y0 = showGroups ? cy - (K * 14) / 2 + 2 : cy + size * 0.36;
473
+ out.push(text(cx, y0, formatNumber(v, 2, locale), { fill: readable(ramp(theme.diverging, 0.5 + v / 2), theme, 4.5), fontSize: size, fontWeight: 600, textAnchor: 'middle' }));
474
+ if (showGroups) {
475
+ keys.forEach((key, gi) => {
476
+ const sub = correlations(data.filter((_, t) => hk[t] === key), [vars[i], vars[j]]).r[0][1];
477
+ out.push(text(cx, y0 + 18 + gi * 14, `${truncate(key, s * 0.55, 10.5)}: ${Number.isFinite(sub) ? formatNumber(sub, 2, locale) : '—'}`, { fill: readable(colorOf(key), theme, 3), fontSize: 10.5, textAnchor: 'middle' }));
478
+ });
479
+ }
480
+ }
481
+ return [g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, `r = ${Number.isFinite(v) ? formatNumber(v, 2, locale) : '—'}`, `n = ${formatNumber(N[i][j], 0, locale)}`) }, out)];
482
+ };
483
+
484
+ for (let i = 0; i < k; i++) {
485
+ for (let j = 0; j < k; j++) {
486
+ if (i < j && upper === 'none') continue;
487
+ const part = i === j ? diagPanel(i) : i > j || upper === 'scatter' ? scatterPanel(i, j) : corrPanel(i, j);
488
+ body.push(g({}, [...part, frameRect(px(j), py(i))]));
489
+ }
490
+ }
491
+
492
+ // Marcas: x abajo (cada columna), y a la izquierda (filas con dispersión en la primera columna).
493
+ for (let j = 0; j < k; j++) {
494
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4);
495
+ let ts = ticksIn(axes[j]);
496
+ if (ts.length > 2 && ts.some((t, ti) => ti && xs(t) - xs(ts[ti - 1]) < Math.max(...ts.map((u) => measure(tfs[j](u), fs))) + 6)) ts = ts.filter((_, ti) => ti % 2 === 0);
497
+ for (const t of ts) body.push(text(xs(t), py(k - 1) + s + 14 * X, tfs[j](t), { fill: theme.muted, fontSize: fs, textAnchor: 'middle' }));
498
+ }
499
+ for (let i = 1; i < k; i++) {
500
+ const ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
501
+ for (const t of ticksIn(axes[i])) body.push(text(px(0) - 6, ys(t) + 4, tfs[i](t), { fill: theme.muted, fontSize: fs, textAnchor: 'end' }));
502
+ }
503
+
504
+ const pairs = [];
505
+ for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
506
+ pairs.sort((p, q) => Math.abs(q[2]) - Math.abs(p[2]));
507
+ const top = pairs[0];
508
+ const desc = en
509
+ ? `Pair plot of ${k} variables (${formatNumber(n, 0, locale)} rows)${hue ? ` colored by ${humanize(hue)}` : ''}: scatter plots below the diagonal, distributions on it.` + (top ? ` Strongest correlation: ${name(top[0])} and ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '')
510
+ : `Matriz de dispersión de ${k} variables (${formatNumber(n, 0, locale)} filas)${hue ? ` por ${humanize(hue).toLowerCase()}` : ''}: dispersión bajo la diagonal y distribuciones en ella.` + (top ? ` Correlación más fuerte: ${name(top[0])} y ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '');
511
+ return frame.finish(body, { desc, type: 'pairplot' });
512
+ }