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.
- package/AGENTS.md +5 -1
- package/README.md +18 -1
- package/bin/plotagents.js +3 -1
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +1857 -110
- package/docs/charts.md +86 -1
- package/docs/es/agents.md +102 -0
- package/docs/es/api.md +172 -0
- package/docs/es/charts.md +436 -0
- package/docs/es/colorscales.md +46 -0
- package/docs/es/dashboards.md +79 -0
- package/docs/es/getting-started.md +142 -0
- package/docs/es/rendering.md +47 -0
- package/docs/es/themes.md +116 -0
- package/index.d.ts +92 -8
- package/package.json +3 -2
- package/schema.json +359 -9
- package/skills/plotagents/SKILL.md +105 -0
- package/src/charts/bar.js +13 -5
- package/src/charts/chord.js +3 -1
- package/src/charts/choropleth.js +3 -1
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +2 -1
- package/src/charts/donut.js +3 -1
- package/src/charts/kpi.js +2 -2
- package/src/charts/line.js +19 -7
- package/src/charts/network.js +13 -8
- package/src/charts/pointmap.js +9 -6
- package/src/charts/regplot.js +9 -3
- package/src/charts/sankey.js +1 -1
- package/src/charts/scatter.js +4 -2
- package/src/charts/sunburst.js +2 -2
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +2 -2
- package/src/charts/wordcloud.js +17 -3
- package/src/colorscales.js +12 -1
- package/src/dashboard.js +21 -8
- package/src/data.js +2 -0
- package/src/facet.js +4 -3
- package/src/format.js +36 -13
- package/src/frame.js +8 -5
- package/src/geo/projection.js +4 -2
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +17 -4
- package/src/infer.js +9 -1
- package/src/lint.js +66 -4
- package/src/runtime.js +94 -6
- package/src/scale.js +3 -2
- package/src/schema.js +54 -11
- package/src/stats.js +5 -3
- package/src/svg.js +4 -1
- 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
|
+
}
|
package/src/charts/dist.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/charts/donut.js
CHANGED
|
@@ -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
|
|
package/src/charts/line.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
}
|