plotagents 0.7.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 (53) hide show
  1. package/AGENTS.md +5 -1
  2. package/README.md +18 -1
  3. package/bin/plotagents.js +3 -1
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +1857 -110
  6. package/docs/charts.md +86 -1
  7. package/docs/es/agents.md +102 -0
  8. package/docs/es/api.md +172 -0
  9. package/docs/es/charts.md +436 -0
  10. package/docs/es/colorscales.md +46 -0
  11. package/docs/es/dashboards.md +79 -0
  12. package/docs/es/getting-started.md +142 -0
  13. package/docs/es/rendering.md +47 -0
  14. package/docs/es/themes.md +116 -0
  15. package/index.d.ts +92 -8
  16. package/package.json +3 -2
  17. package/schema.json +359 -9
  18. package/skills/plotagents/SKILL.md +105 -0
  19. package/src/charts/bar.js +13 -5
  20. package/src/charts/chord.js +3 -1
  21. package/src/charts/choropleth.js +3 -1
  22. package/src/charts/corr.js +512 -0
  23. package/src/charts/dist.js +2 -1
  24. package/src/charts/donut.js +3 -1
  25. package/src/charts/kpi.js +2 -2
  26. package/src/charts/line.js +19 -7
  27. package/src/charts/network.js +13 -8
  28. package/src/charts/pointmap.js +9 -6
  29. package/src/charts/regplot.js +9 -3
  30. package/src/charts/sankey.js +1 -1
  31. package/src/charts/scatter.js +4 -2
  32. package/src/charts/sunburst.js +2 -2
  33. package/src/charts/table.js +882 -0
  34. package/src/charts/treemap.js +2 -2
  35. package/src/charts/wordcloud.js +17 -3
  36. package/src/colorscales.js +12 -1
  37. package/src/dashboard.js +21 -8
  38. package/src/data.js +2 -0
  39. package/src/facet.js +4 -3
  40. package/src/format.js +36 -13
  41. package/src/frame.js +8 -5
  42. package/src/geo/projection.js +4 -2
  43. package/src/hierarchy.js +6 -0
  44. package/src/i18n.js +107 -0
  45. package/src/index.js +17 -4
  46. package/src/infer.js +9 -1
  47. package/src/lint.js +66 -4
  48. package/src/runtime.js +94 -6
  49. package/src/scale.js +3 -2
  50. package/src/schema.js +54 -11
  51. package/src/stats.js +5 -3
  52. package/src/svg.js +4 -1
  53. package/src/text.js +5 -0
@@ -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
+ }
@@ -116,7 +116,8 @@ export function hist(spec, theme) {
116
116
  const locale = spec.locale || 'es';
117
117
  const isLog = (horizontal ? spec.yScale : spec.xScale) === 'log';
118
118
  const tr = T(isLog);
119
- const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
119
+ // Sin valores > 0 en log, un grupo queda vacío: fuera (leyenda fantasma y 0/0 al normalizar).
120
+ const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values })).filter((gr) => gr.values.length);
120
121
  const all = groups.flatMap((gr) => gr.values);
121
122
  if (!all.length) throw noNumbers(field, en, isLog);
122
123
  const N = all.length;
@@ -5,7 +5,7 @@ import { el, g, text, tip } from '../svg.js';
5
5
  import { measure, truncate } from '../text.js';
6
6
  import { makeFormat, formatNumber } from '../format.js';
7
7
  import { colorMap, textOn } from '../theme.js';
8
- import { ensureData, requireField, num, fieldsOf, fieldKind, humanize } from '../data.js';
8
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
9
9
  import { createFrame, hash } from '../frame.js';
10
10
  import { dashedMatcher, hatcher, dashedDefaultLabel, DASH } from '../dashed.js';
11
11
  import { ctx } from '../context.js';
@@ -39,6 +39,8 @@ export function donut(spec, theme) {
39
39
  const k = String(r[labelF]);
40
40
  sums.set(k, (sums.get(k) || 0) + v);
41
41
  }
42
+ // Sin ningún valor positivo no hay nada que repartir: error claro en vez de un anillo vacío con "0".
43
+ if (!sums.size) throw new PlotError(en ? `No positive values in \`${valueF}\`.` : `No hay valores positivos en \`${valueF}\`.`, en ? 'A donut or pie needs at least one value greater than 0 (zero, negative and empty values are skipped).' : 'La dona o torta necesita al menos un valor mayor que 0 (se omiten ceros, negativos y vacíos).');
42
44
  let slices = [...sums.entries()].map(([k, v]) => ({ k, v }));
43
45
  if ((spec.sort ?? 'desc') === 'desc') slices.sort((a, b) => b.v - a.v);
44
46
  const max = spec.maxSlices ?? 6;
package/src/charts/kpi.js CHANGED
@@ -72,14 +72,14 @@ export function kpi(spec, theme) {
72
72
  if (it.target !== undefined) {
73
73
  y += 24 * X;
74
74
  const t = num(it.target);
75
- const f = Math.max(0, Math.min(1, value / (t || 1)));
75
+ const f = Number.isFinite(value) ? Math.max(0, Math.min(1, value / (t || 1))) : 0; // valor no numérico: riel vacío, nunca NaN
76
76
  const w = tw - P * 2;
77
77
  const upGood = (it.good ?? 'up') === 'up';
78
78
  const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
79
79
  const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
80
80
  parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
81
81
  parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
82
- const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
82
+ const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + (Number.isFinite(value) ? formatNumber((value / (t || 1)) * 100, 0, locale) + '%' : '—');
83
83
  parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
84
84
  }
85
85
 
@@ -118,6 +118,14 @@ export function line(spec, theme, variant = 'line') {
118
118
  }
119
119
  }
120
120
 
121
+ // Escala log: un valor ≤ 0 no tiene posición; es un hueco, no razón para volver a lineal.
122
+ if (spec.yScale === 'log' && !stack && m.lines.some((l) => l.points.some((p) => p.y > 0))) {
123
+ for (const l of m.lines) for (const p of l.points) {
124
+ if (!(p.y > 0)) p.y = NaN;
125
+ if (!(p.lo > 0)) p.lo = NaN;
126
+ if (!(p.hi > 0)) p.hi = NaN;
127
+ }
128
+ }
121
129
  const allY = [], rawY = [];
122
130
  for (const l of m.lines) for (const p of l.points) {
123
131
  if (Number.isFinite(p.y)) allY.push(p.y);
@@ -197,7 +205,7 @@ export function line(spec, theme, variant = 'line') {
197
205
  const intY = !pct && !spec.index && rawY.length > 0 && rawY.every(Number.isInteger);
198
206
  const nd = yLog ? logDomain(ymin, ymax) : spec.yDomain ? { min: ymin, max: ymax, step: niceStep(ymax - ymin, Math.max(3, Math.round(area.h / 64))) } : niceDomain(ymin, ymax, Math.max(3, Math.round(area.h / 64)), { integer: intY });
199
207
  const yTicksArr = (yLog ? log(nd.min, nd.max, 0, 1) : linear(nd.min, nd.max, 0, 1)).ticks(nd.step);
200
- const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step, yTicksArr);
208
+ const tickF = yLog ? (v) => fmt.tick(v, v)(v) : fmt.tick(nd.step, yTicksArr);
201
209
  const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
202
210
  const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
203
211
  const inner = { x: area.x + leftM, y: yTop, w: Math.max(80, area.w - leftM - endW), h: area.y + area.h - yTop - 24 * ctx.textScale - (spec.xLabel ? 16 * ctx.textScale : 0) };
@@ -248,7 +256,7 @@ export function line(spec, theme, variant = 'line') {
248
256
  xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
249
257
  } else if (xLog) {
250
258
  const lf = makeFormat(spec.xFormat, locale, xsAll);
251
- xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t)(t) }));
259
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t, t)(t) }));
252
260
  } else {
253
261
  let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
254
262
  const ints = xsAll.every(Number.isInteger);
@@ -307,6 +315,8 @@ export function line(spec, theme, variant = 'line') {
307
315
  if (spec.dashedLabel !== false) body.push(text(cutX + 6, inner.y + 11, dashedLabel, { fill: theme.muted, fontSize: 11.5, fontWeight: 500, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
308
316
  }
309
317
  }
318
+ // Con xDomain/yDomain los datos fuera del rango no se salen del área de trazado.
319
+ const clipD = spec.xDomain || spec.yDomain ? 'pa-clip-' + Math.abs(hash(String(spec.title) + m.y + inner.w + inner.h)).toString(36) : null;
310
320
  const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
311
321
  const H = hatcher(theme, gidD);
312
322
  // Recortes para pintar áreas: firme a la izquierda de cutX, rayado a la derecha.
@@ -386,7 +396,7 @@ export function line(spec, theme, variant = 'line') {
386
396
  else for (const sg of segs) if (sg.length === 1 && sg[0] !== pts[pts.length - 1]) parts.push(dot(sg[0], (M.pointRadius ?? 4) - 0.5));
387
397
  }
388
398
  if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
389
- body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
399
+ body.push(g({ class: 'pa-m', 'data-series': l.key, ...(clipD ? { 'clip-path': `url(#${clipD})` } : {}), ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
390
400
  }
391
401
 
392
402
  body.push(refLinesY(refs, yS, inner, theme, fmt));
@@ -397,7 +407,8 @@ export function line(spec, theme, variant = 'line') {
397
407
  let labs = labeled.map((l) => {
398
408
  const last = lastOf(l);
399
409
  const t = endText(l);
400
- const yy = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
410
+ const yy0 = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
411
+ const yy = Math.min(inner.y + inner.h, Math.max(inner.y, yy0)); // fuera del dominio y: pegada al borde
401
412
  // Un solo x en área: la guía parte del borde derecho de la columna, no la cruza.
402
413
  const px = xS(last.x) + (singleX && isArea ? Math.min(inner.w * 0.2, 40) : 0);
403
414
  return { ...t, key: l.key, y: yy, y0: yy, px, color: colorOf(l.key) };
@@ -428,10 +439,10 @@ export function line(spec, theme, variant = 'line') {
428
439
 
429
440
  // Capa de interacción: crosshair con todos los valores en ese x.
430
441
  // Con más x que píxeles, una entrada por columna de píxel (la última).
431
- let crossXs = xsAll;
432
- if (xsAll.length > inner.w) {
442
+ let crossXs = xsAll.filter((xv) => xv >= x0 && xv <= x1);
443
+ if (crossXs.length > inner.w) {
433
444
  const byCol = new Map();
434
- for (const xv of xsAll) byCol.set(Math.round(xS(xv)), xv);
445
+ for (const xv of crossXs) byCol.set(Math.round(xS(xv)), xv);
435
446
  crossXs = [...byCol.values()];
436
447
  }
437
448
  const focusLines = m.lines.filter((l) => focus(l.key)).map((l) => ({ l, at: new Map(l.points.map((q) => [q.x, q])) }));
@@ -448,6 +459,7 @@ export function line(spec, theme, variant = 'line') {
448
459
  body.push(el('rect', { class: 'pa-cross', x: inner.x - 4, y: inner.y, width: inner.w + 8, height: inner.h, fill: 'transparent', 'data-cross': JSON.stringify(cross), 'data-y0': inner.y, 'data-y1': inner.y + inner.h }));
449
460
 
450
461
  const desc = describeLine(m, fmt, xFmt, locale);
462
+ if (clipD) defs += el('clipPath', { id: clipD }, el('rect', { x: inner.x - 6, y: inner.y - 6, width: inner.w + 12, height: inner.h + 12 }));
451
463
  if (cutX !== null) defs += el('clipPath', { id: clipL }, el('rect', { x: 0, y: 0, width: cutX, height: frame.height })) + el('clipPath', { id: clipR }, el('rect', { x: cutX, y: 0, width: frame.width, height: frame.height }));
452
464
  return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
453
465
  }