plotagents 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
@@ -0,0 +1,530 @@
1
+ // Distribuciones al estilo seaborn: histograma (histplot), densidad (kdeplot)
2
+ // y distribución acumulada (ecdfplot). Ejes recesivos, marcas finas y color
3
+ // por grupo (`hue`) en orden fijo.
4
+ import { el, g, text, tip, barPath } from '../svg.js';
5
+ import { measure } from '../text.js';
6
+ import { makeFormat, formatNumber } from '../format.js';
7
+ import { niceDomain, niceStep, linear, log, logDomain, intStep } from '../scale.js';
8
+ import { colorMap } from '../theme.js';
9
+ import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize, PlotError } from '../data.js';
10
+ import { yAxisMargin, yAxisTop, yGrid, yTickLabels, gridX, ticksWidth } from '../axes.js';
11
+ import { createFrame } from '../frame.js';
12
+ import { ctx } from '../context.js';
13
+ import { histogram as binValues, kde as kdeEst, scottBandwidth, ecdf as ecdfEst, quantile, finite, median, extent } from '../stats.js';
14
+
15
+ // ── Utilidades compartidas (también las usa regplot.js) ───────────────────
16
+
17
+ const EN = (spec) => String(spec.locale || 'es').startsWith('en');
18
+
19
+ /** Error con pista cuando el campo no tiene ningún número graficable. */
20
+ function noNumbers(field, en, isLog) {
21
+ const logNote = isLog ? (en ? ' (on a log scale only values > 0 count)' : ' (en escala log solo cuentan valores > 0)') : '';
22
+ return en
23
+ ? new PlotError(`No numeric values in \`${field}\`${logNote}.`, `\`${field}\` must hold numbers (empty, null and text values are skipped), or pick another field with x: "field".`)
24
+ : new PlotError(`No hay valores numéricos en \`${field}\`${logNote}.`, `\`${field}\` debe tener números (se omiten vacíos, nulos y textos), o elige otro campo con x: "campo".`);
25
+ }
26
+
27
+ /** Campo de grupo: `hue` (seaborn), `series` o `color` (si no es un hex). */
28
+ export function hueField(spec) {
29
+ const h = spec.hue ?? spec.series ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : undefined);
30
+ return h || null;
31
+ }
32
+
33
+ /** Agrupa valores numéricos de `field` por grupo. Devuelve [{ key, values }]. */
34
+ export function groupValues(data, field, hue) {
35
+ const keys = hue ? uniq(data.map((r) => String(r[hue]))) : [''];
36
+ const m = new Map(keys.map((k) => [k, []]));
37
+ for (const r of data) {
38
+ const v = num(r[field]);
39
+ if (!Number.isFinite(v)) continue;
40
+ m.get(hue ? String(r[hue]) : '').push(v);
41
+ }
42
+ return keys.map((k) => ({ key: k, values: m.get(k) })).filter((gr) => gr.values.length);
43
+ }
44
+
45
+ /**
46
+ * Dominio de un eje numérico (lineal o log), con override `[min, max]`.
47
+ * Devuelve { min, max, step, isLog, ticks, scale(r0, r1) }.
48
+ */
49
+ export function axisDomain(values, { domain, type, count = 5, zero = false, nice = true, integer = false } = {}) {
50
+ const fin = finite(values);
51
+ if (type === 'log') {
52
+ const pos = fin.filter((v) => v > 0);
53
+ const d = domain ? { min: domain[0], max: domain[1] } : logDomain(...extent(pos));
54
+ const ticks = log(d.min, d.max, 0, 1).ticks();
55
+ return { min: d.min, max: d.max, step: null, isLog: true, ticks, scale: (r0, r1) => log(d.min, d.max, r0, r1) };
56
+ }
57
+ let [lo, hi] = fin.length ? extent(fin) : [0, 1];
58
+ if (zero) { lo = Math.min(0, lo); hi = Math.max(0, hi); }
59
+ let d;
60
+ if (domain) {
61
+ const step = niceStep(domain[1] - domain[0], count);
62
+ d = { min: domain[0], max: domain[1], step };
63
+ } else if (nice === 'tight') {
64
+ // Ajustado a los datos (+4%): aprovecha el espacio en dispersión y regresión.
65
+ const span = hi - lo || Math.abs(hi) || 1;
66
+ const a = zero && lo >= 0 ? Math.max(0, lo - span * 0.04) : lo - span * 0.04;
67
+ d = { min: a, max: hi + span * 0.04, step: niceStep(span * 1.08, count) };
68
+ } else if (!nice) {
69
+ d = { min: lo, max: hi === lo ? lo + 1 : hi, step: niceStep((hi === lo ? 1 : hi - lo), count) };
70
+ } else d = niceDomain(lo, hi, count, { integer });
71
+ // Sin pasos de 2,5: con decimales redondeados se leen mal (2,5 → "3").
72
+ const mag = 10 ** Math.floor(Math.log10(d.step));
73
+ if (Math.abs(d.step / mag - 2.5) < 1e-9) d.step = 2 * mag;
74
+ // Conteos: paso entero (nunca marcas en 0,5).
75
+ if (integer) d.step = intStep(d.step);
76
+ const ticks = linear(d.min, d.max, 0, 1).ticks(d.step);
77
+ return { min: d.min, max: d.max, step: d.step, isLog: false, ticks, scale: (r0, r1) => linear(d.min, d.max, r0, r1) };
78
+ }
79
+
80
+ /** Formateador de marcas para un eje (lineal o log). */
81
+ export const tickFmt = (fmt, ax) => (ax.isLog ? fmt : fmt.tick(ax.step, ax.ticks));
82
+
83
+ /** Títulos de eje horizontales: y arriba a la izquierda, x abajo a la derecha. */
84
+ export function axisTitles(body, theme, area, inner, { x, y }) {
85
+ const size = theme.type?.axis ?? 11.5;
86
+ if (y) body.push(text(area.x, area.y + 10, y, { fill: theme.lede, fontSize: size + 0.5, fontWeight: 500 }));
87
+ if (x) body.push(text(inner.x + inner.w, inner.y + inner.h + 22 + 16 * ctx.textScale, x, { fill: theme.lede, fontSize: size + 0.5, fontWeight: 500, textAnchor: 'end' }));
88
+ }
89
+
90
+ /** Escala log10 → valor, para bins en espacio logarítmico. */
91
+ const T = (isLog) => (isLog ? { f: (v) => Math.log10(v), inv: (t) => 10 ** t } : { f: (v) => v, inv: (t) => t });
92
+
93
+ const halo = (theme) => ({ stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' });
94
+
95
+ function rangeLabel(a, b, fx) {
96
+ return `${fx(a)} – ${fx(b)}`;
97
+ }
98
+
99
+ // ── histplot ───────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Histograma. Opciones: x (o y para horizontal), hue, bins, binWidth,
103
+ * stat ('count' | 'percent' | 'density' | 'probability'), kde, multiple
104
+ * ('layer' | 'stack' | 'dodge' | 'fill'), element ('bars' | 'step'),
105
+ * cumulative, commonNorm (true), xScale 'log', xDomain, yDomain.
106
+ */
107
+ export function hist(spec, theme) {
108
+ const data = ensureData(spec);
109
+ const fields = fieldsOf(data);
110
+ const horizontal = !spec.x && !!spec.y;
111
+ const field = spec.x ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
112
+ requireField(data, field, horizontal ? 'y' : 'x');
113
+ const hue = hueField(spec);
114
+ if (hue) requireField(data, hue, 'hue');
115
+ const en = EN(spec);
116
+ const locale = spec.locale || 'es';
117
+ const isLog = (horizontal ? spec.yScale : spec.xScale) === 'log';
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 }));
120
+ const all = groups.flatMap((gr) => gr.values);
121
+ if (!all.length) throw noNumbers(field, en, isLog);
122
+ const N = all.length;
123
+ const stat = spec.stat || 'count';
124
+ const multiple = groups.length > 1 ? spec.multiple || 'layer' : 'layer';
125
+ const commonNorm = spec.commonNorm ?? spec.common_norm ?? true;
126
+
127
+ // Bins compartidos (en espacio log si corresponde).
128
+ const dom = spec[horizontal ? 'yDomain' : 'xDomain'];
129
+ const tAll = all.map(tr.f);
130
+ const edges0 = binValues(tAll, { bins: spec.bins, binWidth: spec.binWidth && !isLog ? spec.binWidth : undefined, domain: dom ? dom.map(tr.f) : undefined });
131
+ const bins = edges0.map((b) => ({ t0: b.x0, t1: b.x1, x0: tr.inv(b.x0), x1: tr.inv(b.x1) }));
132
+ const step = bins.length ? bins[0].t1 - bins[0].t0 : 1;
133
+ const start = bins[0].t0;
134
+ const counts = groups.map((gr) => {
135
+ const c = new Array(bins.length).fill(0);
136
+ for (const v of gr.values) {
137
+ let i = Math.floor((tr.f(v) - start) / step + 1e-9);
138
+ if (i === bins.length) i = bins.length - 1;
139
+ if (i >= 0 && i < bins.length) c[i]++;
140
+ }
141
+ return c;
142
+ });
143
+ // Estadístico
144
+ const norm = (gi) => (commonNorm ? N : groups[gi].values.length);
145
+ let H = counts.map((c, gi) => c.map((n) => {
146
+ if (stat === 'percent') return (n / norm(gi)) * 100;
147
+ if (stat === 'probability' || stat === 'proportion') return n / norm(gi);
148
+ if (stat === 'density') return n / (norm(gi) * step);
149
+ return n;
150
+ }));
151
+ if (spec.cumulative) H = H.map((h) => { let acc = 0; return h.map((v) => (acc += stat === 'density' ? v * step : v)); });
152
+
153
+ // Apilado / relleno
154
+ const K = groups.length;
155
+ const base = H.map((h) => h.map(() => 0));
156
+ if (multiple === 'stack' || multiple === 'fill') {
157
+ for (let i = 0; i < bins.length; i++) {
158
+ let acc = 0;
159
+ const tot = H.reduce((a, h) => a + h[i], 0) || 1;
160
+ for (let gi = 0; gi < K; gi++) {
161
+ const v = multiple === 'fill' ? H[gi][i] / tot : H[gi][i];
162
+ base[gi][i] = acc;
163
+ acc += v;
164
+ H[gi][i] = acc;
165
+ }
166
+ }
167
+ }
168
+ const tops = H.flat();
169
+ const statLabel = {
170
+ count: en ? 'Count' : 'Frecuencia', percent: en ? 'Percent' : 'Porcentaje', density: en ? 'Density' : 'Densidad',
171
+ probability: en ? 'Probability' : 'Proporción', proportion: en ? 'Proportion' : 'Proporción',
172
+ }[stat] || stat;
173
+ const fy = multiple === 'fill' ? makeFormat({ type: 'percent', scale: 100 }, locale) : stat === 'percent' ? makeFormat({ type: 'percent', scale: 1 }, locale) : makeFormat(spec.yFormat ?? (stat === 'count' ? 'integer' : undefined), locale, tops);
174
+ const fx = makeFormat(spec.xFormat ?? spec.format, locale, all);
175
+
176
+ // KDE superpuesta (en la escala del estadístico)
177
+ const kdes = spec.kde && multiple !== 'fill' && !spec.cumulative
178
+ ? groups.map((gr, gi) => {
179
+ const share = gr.values.length / norm(gi);
180
+ const scale = stat === 'count' ? gr.values.length * step : stat === 'percent' ? share * step * 100 : stat === 'density' ? share : share * step;
181
+ return kdeEst(gr.values.map(tr.f), { adjust: spec.bwAdjust ?? 1, extent: [bins[0].t0, bins[bins.length - 1].t1], gridSize: 140 }).map(([t, d]) => [t, d * scale]);
182
+ })
183
+ : null;
184
+ if (kdes && multiple === 'stack') {
185
+ for (let i = 0; i < kdes[0].length; i++) { let acc = 0; for (const k of kdes) { acc += k[i][1]; k[i][1] = acc; } }
186
+ }
187
+ const kdeMax = kdes ? extent(kdes.flatMap((k) => k.map((p) => p[1])))[1] : 0;
188
+
189
+ const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
190
+ const single = K === 1;
191
+ const colorOf = (gi) => (single ? (spec.colors && spec.colors[groups[0].key]) || theme.categorical[0] : cmap.get(groups[gi].key));
192
+ const legend = !single && spec.legend !== false ? groups.map((gr) => ({ label: gr.key, color: cmap.get(gr.key), shape: 'square' })) : [];
193
+
194
+ const frame = createFrame(spec, theme, { height: 400 });
195
+ const area = frame.layout(legend);
196
+ const body = [];
197
+ const M = theme.marks || {};
198
+ const vMax = Math.max(extent(tops)[1], kdeMax);
199
+ const valueOverride = horizontal ? spec.xDomain : spec.yDomain;
200
+ const vAx = axisDomain([0, vMax], { domain: multiple === 'fill' ? [0, 1] : valueOverride, count: Math.max(3, Math.round((horizontal ? area.w / 110 : area.h / 64))), zero: true, integer: stat === 'count' && multiple !== 'fill' });
201
+ const binDom = dom ? [dom[0], dom[1]] : [bins[0].x0, bins[bins.length - 1].x1];
202
+ const clipId = 'pa-clip-h' + Math.abs(hashStr((spec.title || '') + field + area.w)).toString(36);
203
+ const writeDomains = (bin, val) => { if (spec._domains && typeof spec._domains === 'object') { spec._domains[horizontal ? 'y' : 'x'] = bin; spec._domains[horizontal ? 'x' : 'y'] = val; } };
204
+
205
+ if (!horizontal) {
206
+ const ticksF = tickFmt(fy, vAx);
207
+ const leftM = yAxisMargin(vAx.ticks, ticksF, theme, false);
208
+ const top = (spec.yLabel !== false ? 18 : 0) + yAxisTop(theme, false);
209
+ const inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM - 4, h: area.h - top - 44 };
210
+ const yS = vAx.scale(inner.y + inner.h, inner.y);
211
+ const bAx = axisDomain(binDom, { type: isLog ? 'log' : undefined, domain: binDom, count: Math.max(3, Math.round(inner.w / 90)) });
212
+ const xS = bAx.scale(inner.x, inner.x + inner.w);
213
+ writeDomains([bAx.min, bAx.max], [vAx.min, vAx.max]);
214
+ body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y - 2, width: inner.w, height: inner.h + 2 })));
215
+ body.push(yGrid(vAx.ticks, yS, inner, theme));
216
+ body.push(g({ clipPath: `url(#${clipId})` }, drawBins({ bins, H, base, K, multiple, element: spec.element, xS, yS, theme, M, colorOf, single, inner, groups, fy, fx, stat, horizontal: false, statLabel })));
217
+ if (kdes) kdes.forEach((k, gi) => body.push(el('path', { d: k.map(([t, d], i) => `${i ? 'L' : 'M'}${xS(tr.inv(t)).toFixed(2)},${yS(d).toFixed(2)}`).join(''), fill: 'none', stroke: single ? theme.ink : colorOf(gi), strokeWidth: M.lineWidth ?? 2, strokeLinejoin: 'round', strokeLinecap: 'round', opacity: single ? 0.8 : 1 })));
218
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(yS(0)) + 0.5, y2: Math.round(yS(0)) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
219
+ body.push(gridX(isLog ? bAx.ticks : bAx.ticks, xS, inner, theme, tickFmt(fx, bAx), { lines: false }));
220
+ body.push(yTickLabels(vAx.ticks, yS, inner, theme, ticksF, { inside: false }));
221
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(field), y: spec.yLabel === false ? null : spec.yLabel ?? (multiple === 'fill' ? (en ? 'Share' : 'Proporción') : statLabel) });
222
+ } else {
223
+ const binTicks = axisDomain(binDom, { type: isLog ? 'log' : undefined, domain: binDom, count: Math.max(3, Math.round(area.h / 60)) });
224
+ const bf = tickFmt(fx, binTicks);
225
+ const leftM = ticksWidth(binTicks.ticks, bf, theme.type?.axis) + 10;
226
+ const top = spec.yLabel !== false ? 22 : 6;
227
+ const inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM - 8, h: area.h - top - 44 };
228
+ const yS = binTicks.scale(inner.y + inner.h, inner.y);
229
+ const xS = vAx.scale(inner.x, inner.x + inner.w);
230
+ writeDomains([binTicks.min, binTicks.max], [vAx.min, vAx.max]);
231
+ body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y, width: inner.w + 2, height: inner.h })));
232
+ body.push(gridX(vAx.ticks, xS, inner, theme, tickFmt(fy, vAx), { lines: true }));
233
+ body.push(drawBins({ bins, H, base, K, multiple, element: spec.element, xS, yS, theme, M, colorOf, single, inner, groups, fy, fx, stat, horizontal: true, statLabel }));
234
+ if (kdes) kdes.forEach((k, gi) => body.push(el('path', { d: k.map(([t, d], i) => `${i ? 'L' : 'M'}${xS(d).toFixed(2)},${yS(tr.inv(t)).toFixed(2)}`).join(''), fill: 'none', stroke: single ? theme.ink : colorOf(gi), strokeWidth: M.lineWidth ?? 2, strokeLinejoin: 'round', opacity: single ? 0.8 : 1 })));
235
+ body.push(el('line', { x1: Math.round(xS(0)) + 0.5, x2: Math.round(xS(0)) + 0.5, y1: inner.y, y2: inner.y + inner.h, stroke: theme.axis, strokeWidth: 1 }));
236
+ body.push(g({ class: 'pa-axis' }, binTicks.ticks.map((t) => text(inner.x - 8, yS(t) + 4, bf(t), { fill: theme.muted, fontSize: theme.type?.axis ?? 11.5, textAnchor: 'end' }))));
237
+ axisTitles(body, theme, area, inner, { y: spec.yLabel === false ? null : spec.yLabel ?? humanize(field), x: spec.xLabel ?? statLabel });
238
+ }
239
+
240
+ const sorted = [...all].sort((a, b) => a - b);
241
+ const allCounts = bins.map((_, i) => counts.reduce((a, c) => a + c[i], 0));
242
+ const mi = allCounts.indexOf(Math.max(...allCounts));
243
+ const what = humanize(field).toLowerCase();
244
+ const desc = en
245
+ ? `Histogram of ${what} with ${N} observations${K > 1 ? ` in ${K} groups` : ''}. Median ${fx(quantile(sorted, 0.5))}; the most frequent range is ${rangeLabel(bins[mi].x0, bins[mi].x1, fx)}.`
246
+ : `Histograma de ${what} con ${formatNumber(N, 0, locale)} observaciones${K > 1 ? ` en ${K} grupos` : ''}. Mediana ${fx(quantile(sorted, 0.5))}; el rango más frecuente es ${rangeLabel(bins[mi].x0, bins[mi].x1, fx)}.`;
247
+ return frame.finish(body, { desc, type: 'hist' });
248
+ }
249
+
250
+ function drawBins({ bins, H, base, K, multiple, element, xS, yS, theme, M, colorOf, single, inner, groups, fy, fx, stat, horizontal, statLabel }) {
251
+ const out = [];
252
+ const layered = multiple === 'layer' && K > 1;
253
+ const op = layered ? 0.55 : 1;
254
+ if (element === 'step') {
255
+ // Escalones: contorno + lavado de color.
256
+ for (let gi = 0; gi < K; gi++) {
257
+ const c = colorOf(gi);
258
+ const pts = [];
259
+ bins.forEach((b, i) => {
260
+ if (!horizontal) { pts.push([xS(b.x0), yS(H[gi][i])], [xS(b.x1), yS(H[gi][i])]); }
261
+ else { pts.push([xS(H[gi][i]), yS(b.x0)], [xS(H[gi][i]), yS(b.x1)]); }
262
+ });
263
+ const line = pts.map(([a, b], i) => `${i ? 'L' : 'M'}${a.toFixed(2)},${b.toFixed(2)}`).join('');
264
+ const baseline = bins.map((b, i) => (!horizontal ? [[xS(b.x1), yS(base[gi][i])], [xS(b.x0), yS(base[gi][i])]] : [[xS(base[gi][i]), yS(b.x1)], [xS(base[gi][i]), yS(b.x0)]])).reverse().flat();
265
+ const area = line + 'L' + baseline.map(([a, b]) => `${a.toFixed(2)},${b.toFixed(2)}`).join('L') + 'Z';
266
+ out.push(g({ class: 'pa-m', 'data-tip': tip(groups[gi].key || statLabel, `n = ${groups[gi].values.length}`) }, [
267
+ el('path', { d: area, fill: c, fillOpacity: (M.areaOpacity ?? 0.12) * 1.6 }),
268
+ el('path', { d: line, fill: 'none', stroke: c, strokeWidth: M.lineWidth ?? 2, strokeLinejoin: 'round' }),
269
+ ]));
270
+ }
271
+ return g({}, out);
272
+ }
273
+ bins.forEach((b, i) => {
274
+ const p0 = !horizontal ? xS(b.x0) : yS(b.x1);
275
+ const p1 = !horizontal ? xS(b.x1) : yS(b.x0);
276
+ const full = p1 - p0;
277
+ const gap = full > 8 ? (M.gap ?? 2) : full > 3 ? 1 : 0;
278
+ const groupTip = tip(rangeLabel(b.x0, b.x1, fx), ...groups.map((gr, gi) => {
279
+ const v = multiple === 'stack' || multiple === 'fill' ? H[gi][i] - base[gi][i] : H[gi][i];
280
+ return `${gr.key ? gr.key + ': ' : ''}${fy(v)}`;
281
+ }));
282
+ const parts = [];
283
+ for (let gi = 0; gi < K; gi++) {
284
+ let a0 = p0 + gap / 2, a1 = p1 - gap / 2;
285
+ if (multiple === 'dodge' && K > 1) { const w = (a1 - a0) / K; a0 = a0 + gi * w; a1 = a0 + w - (w > 4 ? 1 : 0); }
286
+ const v1 = H[gi][i], v0 = base[gi][i];
287
+ if (!(v1 - v0 > 0) && multiple !== 'layer') continue;
288
+ if (!(v1 > 0)) continue;
289
+ const isTop = multiple !== 'stack' && multiple !== 'fill' ? true : gi === K - 1 || H.slice(gi + 1).every((h, k) => !(h[i] - base[gi + 1 + k][i] > 0));
290
+ const radius = Math.min(M.barRadius ?? 4, 2.5);
291
+ let d;
292
+ if (!horizontal) {
293
+ const ya = yS(v1), yb = yS(v0) - (v0 > 0 ? 1 : 0);
294
+ d = barPath(a0, ya, Math.max(0, a1 - a0), Math.max(0, yb - ya), 'up', isTop ? radius : 0);
295
+ } else {
296
+ const xa = xS(v0) + (v0 > 0 ? 1 : 0), xb = xS(v1);
297
+ d = barPath(xa, a0, Math.max(0, xb - xa), Math.max(0, a1 - a0), 'right', isTop ? radius : 0);
298
+ }
299
+ if (d) parts.push(el('path', { d, fill: colorOf(gi), fillOpacity: op }));
300
+ }
301
+ const hit = !horizontal
302
+ ? el('rect', { x: p0, y: inner.y, width: Math.max(0, full), height: inner.h, fill: 'transparent' })
303
+ : el('rect', { x: inner.x, y: p0, width: inner.w, height: Math.max(0, full), fill: 'transparent' });
304
+ out.push(g({ class: 'pa-m', 'data-tip': groupTip }, [hit, ...parts]));
305
+ });
306
+ return g({}, out);
307
+ }
308
+
309
+ // ── kdeplot ────────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Densidad por kernel. Opciones: x, hue, fill (true), multiple ('layer' |
313
+ * 'stack' | 'fill'), bwAdjust, cut (3), clip [a,b], cumulative, commonNorm,
314
+ * rug, labels ('auto' rotula el pico de cada grupo), xScale 'log', xDomain, yDomain.
315
+ */
316
+ export function kde(spec, theme) {
317
+ const data = ensureData(spec);
318
+ const fields = fieldsOf(data);
319
+ const field = spec.x ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
320
+ requireField(data, field, 'x');
321
+ const hue = hueField(spec);
322
+ if (hue) requireField(data, hue, 'hue');
323
+ const en = EN(spec);
324
+ const locale = spec.locale || 'es';
325
+ const isLog = spec.xScale === 'log';
326
+ const tr = T(isLog);
327
+ const groups0 = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
328
+ if (!groups0.some((gr) => gr.values.length)) throw noNumbers(field, en, isLog);
329
+ const groups = groups0.filter((gr) => gr.values.length > 1);
330
+ if (!groups.length) return emptyChart(spec, theme, en);
331
+ const all = groups.flatMap((gr) => gr.values);
332
+ const N = all.length;
333
+ const K = groups.length;
334
+ const multiple = K > 1 ? spec.multiple || 'layer' : 'layer';
335
+ // Acumulada: cada grupo llega a 100% (salvo que se pida normalización común).
336
+ const commonNorm = spec.commonNorm ?? spec.common_norm ?? !spec.cumulative;
337
+ const cut = spec.cut ?? 3;
338
+ const adjust = spec.bwAdjust ?? spec.bw_adjust ?? 1;
339
+ // Grilla común
340
+ const bws = groups.map((gr) => scottBandwidth(gr.values.map(tr.f)) * adjust);
341
+ const ext = groups.map((gr) => extent(gr.values.map(tr.f)));
342
+ let lo = Math.min(...ext.map((e, i) => e[0] - cut * bws[i]));
343
+ let hi = Math.max(...ext.map((e, i) => e[1] + cut * bws[i]));
344
+ if (spec.clip) { lo = Math.max(lo, tr.f(spec.clip[0])); hi = Math.min(hi, tr.f(spec.clip[1])); }
345
+ if (spec.xDomain) { lo = tr.f(spec.xDomain[0]); hi = tr.f(spec.xDomain[1]); }
346
+ const G = 200;
347
+ let curves = groups.map((gr, i) => {
348
+ const share = commonNorm ? gr.values.length / N : 1;
349
+ return kdeEst(gr.values.map(tr.f), { bandwidth: bws[i], extent: [lo, hi], gridSize: G }).map(([t, d]) => [t, d * share]);
350
+ });
351
+ if (spec.cumulative) {
352
+ curves = curves.map((c) => { let acc = 0; return c.map(([t, d], i) => { if (i) acc += ((d + c[i - 1][1]) / 2) * (t - c[i - 1][0]); return [t, acc]; }); });
353
+ }
354
+ const base = curves.map((c) => c.map(() => 0));
355
+ if (multiple === 'stack' || multiple === 'fill') {
356
+ for (let i = 0; i < G; i++) {
357
+ let acc = 0;
358
+ const tot = curves.reduce((a, c) => a + c[i][1], 0) || 1;
359
+ for (let k = 0; k < K; k++) {
360
+ const v = multiple === 'fill' ? curves[k][i][1] / tot : curves[k][i][1];
361
+ base[k][i] = acc; acc += v; curves[k][i] = [curves[k][i][0], acc];
362
+ }
363
+ }
364
+ }
365
+ const yMax = extent(curves.flatMap((c) => c.map((p) => p[1])))[1];
366
+ const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
367
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
368
+ const colorOf = (k) => (hl && !hl.has(groups[k].key) ? theme.dim : K === 1 ? (spec.colors && spec.colors[groups[0].key]) || theme.categorical[0] : cmap.get(groups[k].key));
369
+ const legend = K > 1 && spec.legend !== false ? groups.map((gr, k) => ({ label: gr.key, color: colorOf(k), shape: spec.fill === false ? 'line' : 'square' })) : [];
370
+
371
+ const frame = createFrame(spec, theme, { height: 380 });
372
+ const area = frame.layout(legend);
373
+ const M = theme.marks || {};
374
+ const fx = makeFormat(spec.xFormat ?? spec.format, locale, all);
375
+ const fy = multiple === 'fill' || spec.cumulative ? makeFormat({ type: 'percent', scale: 100 }, locale) : makeFormat(spec.yFormat, locale, [yMax]);
376
+ const vAx = axisDomain([0, yMax], { domain: multiple === 'fill' ? [0, 1] : spec.yDomain, zero: true, count: Math.max(3, Math.round(area.h / 70)) });
377
+ const ticksF = tickFmt(fy, vAx);
378
+ const rugH = spec.rug ? 10 : 0;
379
+ // Con relleno al 100% el área cubre todo: etiquetas del eje y por fuera.
380
+ const insideY = multiple === 'fill' ? false : undefined;
381
+ const top = 18 + yAxisTop(theme, insideY);
382
+ const leftM = yAxisMargin(vAx.ticks, ticksF, theme, insideY);
383
+ const inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM - 4, h: area.h - top - 44 - rugH };
384
+ const yS = vAx.scale(inner.y + inner.h, inner.y);
385
+ const xDomVals = [tr.inv(lo), tr.inv(hi)];
386
+ const xAx = axisDomain(xDomVals, { type: isLog ? 'log' : undefined, domain: xDomVals, count: Math.max(3, Math.round(inner.w / 90)) });
387
+ const xS = xAx.scale(inner.x, inner.x + inner.w);
388
+ const P = ([t, d]) => `${xS(tr.inv(t)).toFixed(2)},${yS(d).toFixed(2)}`;
389
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [vAx.min, vAx.max]; }
390
+
391
+ const body = [];
392
+ body.push(yGrid(vAx.ticks, yS, inner, theme));
393
+ const stacked = multiple === 'stack' || multiple === 'fill';
394
+ const order = stacked ? [...groups.keys()].reverse() : [...groups.keys()].sort((a, b) => (hl ? (hl.has(groups[a].key) ? 1 : 0) - (hl.has(groups[b].key) ? 1 : 0) : 0));
395
+ for (const k of order) {
396
+ const c = curves[k];
397
+ const color = colorOf(k);
398
+ const top = 'M' + c.map(P).join('L');
399
+ const parts = [];
400
+ if (spec.fill !== false || stacked) {
401
+ const bottom = base[k].map((b, i) => [c[i][0], b]).reverse().map(P).join('L');
402
+ parts.push(el('path', { d: top + 'L' + bottom + 'Z', fill: color, fillOpacity: stacked ? 0.85 : (M.areaOpacity ?? 0.12) * 1.5, stroke: stacked ? theme.surface : 'none', strokeWidth: stacked ? 1.5 : 0 }));
403
+ }
404
+ if (!stacked) parts.push(el('path', { d: top, fill: 'none', stroke: color, strokeWidth: M.lineWidth ?? 2, strokeLinejoin: 'round', strokeLinecap: 'round' }));
405
+ const vs = [...groups[k].values].sort((a, b) => a - b);
406
+ body.push(g({ class: 'pa-m', 'data-tip': tip(groups[k].key || humanize(field), `n = ${formatNumber(vs.length, 0, locale)}`, `${en ? 'Median' : 'Mediana'}: ${fx(quantile(vs, 0.5))}`, `P25–P75: ${fx(quantile(vs, 0.25))} – ${fx(quantile(vs, 0.75))}`) }, parts));
407
+ }
408
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(yS(0)) + 0.5, y2: Math.round(yS(0)) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
409
+ if (spec.rug) {
410
+ const ry = inner.y + inner.h + 3;
411
+ groups.forEach((gr, k) => {
412
+ const d = gr.values.slice(0, 2000).map((v) => xS(v)).filter((px) => px >= inner.x && px <= inner.x + inner.w).map((px) => `M${px.toFixed(1)},${ry}v${rugH - 3}`).join('');
413
+ if (d) body.push(el('path', { d, stroke: colorOf(k), strokeWidth: 1, opacity: 0.45 }));
414
+ });
415
+ }
416
+ // Etiqueta en el pico de cada grupo (si no chocan); la leyenda queda igual.
417
+ if (K > 1 && !stacked && !spec.cumulative && spec.labels !== false) {
418
+ const boxes = [];
419
+ const labs = [];
420
+ for (let k = 0; k < K; k++) {
421
+ if (hl && !hl.has(groups[k].key)) continue;
422
+ const c = curves[k];
423
+ let pi = 0;
424
+ for (let i = 1; i < c.length; i++) if (c[i][1] > c[pi][1]) pi = i;
425
+ const px = xS(tr.inv(c[pi][0])), py = yS(c[pi][1]) - 8;
426
+ const w = measure(groups[k].key, 12, 600);
427
+ const box = [px - w / 2, py - 12, px + w / 2, py + 2];
428
+ if (box[0] < inner.x || box[2] > inner.x + inner.w || box[1] < area.y + 14) continue;
429
+ if (boxes.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
430
+ boxes.push(box);
431
+ labs.push(text(px, py, groups[k].key, { fill: theme.ink, fontSize: 12, fontWeight: 600, textAnchor: 'middle', ...halo(theme) }));
432
+ }
433
+ body.push(g({ class: 'pa-peaks' }, labs));
434
+ }
435
+ body.push(gridX(xAx.ticks, xS, { ...inner, h: inner.h + rugH }, theme, tickFmt(fx, xAx), { lines: false }));
436
+ // Los valores de densidad no dicen nada al lector: se ocultan salvo que se pidan.
437
+ const showY = spec.yTicks ?? (spec.cumulative || multiple === 'fill');
438
+ if (showY) body.push(yTickLabels(vAx.ticks, yS, inner, theme, ticksF, { inside: insideY }));
439
+ axisTitles(body, theme, area, { ...inner, h: inner.h + rugH }, { x: spec.xLabel ?? humanize(field), y: spec.yLabel === false ? null : spec.yLabel ?? (spec.cumulative ? (en ? 'Cumulative share' : 'Proporción acumulada') : multiple === 'fill' ? (en ? 'Share' : 'Proporción') : en ? 'Density' : 'Densidad') });
440
+
441
+ const what = humanize(field).toLowerCase();
442
+ const meds = groups.map((gr) => `${gr.key ? gr.key + ' ' : ''}${fx(median(gr.values))}`).join(', ');
443
+ const desc = en ? `Density plot of ${what} (${N} observations). Median: ${meds}.` : `Gráfico de densidad de ${what} (${formatNumber(N, 0, locale)} observaciones). Mediana: ${meds}.`;
444
+ return frame.finish(body, { desc, type: 'kde' });
445
+ }
446
+
447
+ // ── ecdfplot ───────────────────────────────────────────────────────────────
448
+
449
+ /** Distribución acumulada empírica. Opciones: x, hue, complementary, xScale 'log', xDomain. */
450
+ export function ecdf(spec, theme) {
451
+ const data = ensureData(spec);
452
+ const fields = fieldsOf(data);
453
+ const field = spec.x ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
454
+ requireField(data, field, 'x');
455
+ const hue = hueField(spec);
456
+ if (hue) requireField(data, hue, 'hue');
457
+ const en = EN(spec);
458
+ const locale = spec.locale || 'es';
459
+ const isLog = spec.xScale === 'log';
460
+ const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values })).filter((gr) => gr.values.length);
461
+ if (!groups.length) throw noNumbers(field, en, isLog);
462
+ const all = groups.flatMap((gr) => gr.values);
463
+ const K = groups.length;
464
+ const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
465
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
466
+ const colorOf = (k) => (hl && !hl.has(groups[k].key) ? theme.dim : K === 1 ? theme.categorical[0] : cmap.get(groups[k].key));
467
+ const legend = K > 1 && spec.legend !== false ? groups.map((gr, k) => ({ label: gr.key, color: colorOf(k), shape: 'line' })) : [];
468
+ const frame = createFrame(spec, theme, { height: 380 });
469
+ const area = frame.layout(legend);
470
+ const M = theme.marks || {};
471
+ const fx = makeFormat(spec.xFormat ?? spec.format, locale, all);
472
+ const fy = makeFormat({ type: 'percent', scale: 100 }, locale);
473
+ const vAx = axisDomain([0, 1], { domain: [0, 1], count: 4 });
474
+ const ticksF = (v) => fy(v);
475
+ const top = 18 + yAxisTop(theme);
476
+ const inner = { x: area.x + yAxisMargin(vAx.ticks, ticksF, theme), y: area.y + top, w: 0, h: area.h - top - 44 };
477
+ inner.w = area.x + area.w - inner.x - 4;
478
+ const yS = vAx.scale(inner.y + inner.h, inner.y);
479
+ const xAx = axisDomain(all, { type: isLog ? 'log' : undefined, domain: spec.xDomain, count: Math.max(3, Math.round(inner.w / 90)) });
480
+ const xS = xAx.scale(inner.x, inner.x + inner.w);
481
+ if (spec._domains && typeof spec._domains === 'object') { spec._domains.x = [xAx.min, xAx.max]; spec._domains.y = [0, 1]; }
482
+ const body = [yGrid(vAx.ticks, yS, inner, theme)];
483
+ // Mediana como referencia discreta.
484
+ const py50 = Math.round(yS(0.5)) + 0.5;
485
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: py50, y2: py50, stroke: theme.axis, strokeWidth: 1 }));
486
+ const order = [...groups.keys()].sort((a, b) => (hl ? (hl.has(groups[a].key) ? 1 : 0) - (hl.has(groups[b].key) ? 1 : 0) : 0));
487
+ for (const k of order) {
488
+ const pts = ecdfEst(groups[k].values);
489
+ const comp = !!spec.complementary;
490
+ const Y = (p) => (comp ? 1 - p : p);
491
+ let d = `M${xS(Math.max(xAx.min, pts[0][0])).toFixed(2)},${yS(Y(0)).toFixed(2)}`;
492
+ // Escalones a menos de medio píxel se funden en uno (50 mil valores → ~1 por columna).
493
+ let prev = 0, colX = NaN, colP0 = 0, colP1 = 0;
494
+ const flush = () => { if (Number.isFinite(colX)) { const px = colX.toFixed(2); d += `L${px},${yS(Y(colP0)).toFixed(2)}L${px},${yS(Y(colP1)).toFixed(2)}`; } };
495
+ for (const [x, p] of pts) {
496
+ const fx0 = xS(Math.min(xAx.max, Math.max(xAx.min, x)));
497
+ if (Number.isFinite(colX) && Math.abs(fx0 - colX) < 0.5) colP1 = p;
498
+ else { flush(); colX = fx0; colP0 = prev; colP1 = p; }
499
+ prev = p;
500
+ }
501
+ flush();
502
+ d += `L${xS(xAx.max).toFixed(2)},${yS(Y(1)).toFixed(2)}`;
503
+ const vs = [...groups[k].values].sort((a, b) => a - b);
504
+ body.push(g({ class: 'pa-m', 'data-tip': tip(groups[k].key || humanize(field), `P25: ${fx(quantile(vs, 0.25))}`, `${en ? 'Median' : 'Mediana'}: ${fx(quantile(vs, 0.5))}`, `P75: ${fx(quantile(vs, 0.75))}`, `P90: ${fx(quantile(vs, 0.9))}`) }, [
505
+ el('path', { d, fill: 'none', stroke: 'transparent', strokeWidth: 12 }),
506
+ el('path', { d, fill: 'none', stroke: colorOf(k), strokeWidth: hl && hl.has(groups[k].key) ? (M.lineWidth ?? 2) + 0.75 : M.lineWidth ?? 2, strokeLinejoin: 'round' }),
507
+ ]));
508
+ }
509
+ body.push(text(inner.x + inner.w, py50 - 5, en ? 'Median' : 'Mediana', { fill: theme.muted, fontSize: theme.type?.axis ?? 11.5, textAnchor: 'end', ...halo(theme) }));
510
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: inner.y + inner.h + 0.5, y2: inner.y + inner.h + 0.5, stroke: theme.axis }));
511
+ body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
512
+ body.push(yTickLabels(vAx.ticks, yS, inner, theme, ticksF));
513
+ axisTitles(body, theme, area, inner, { x: spec.xLabel ?? humanize(field), y: spec.yLabel === false ? null : spec.yLabel ?? (spec.complementary ? (en ? 'Share above' : 'Proporción sobre el valor') : en ? 'Cumulative share' : 'Proporción acumulada') });
514
+ const what = humanize(field).toLowerCase();
515
+ const meds = groups.map((gr) => `${gr.key ? gr.key + ' ' : ''}${fx(median(gr.values))}`).join(', ');
516
+ const desc = en ? `Empirical cumulative distribution of ${what}. Median: ${meds}.` : `Distribución acumulada empírica de ${what}. Mediana: ${meds}.`;
517
+ return frame.finish(body, { desc, type: 'ecdf' });
518
+ }
519
+
520
+ function emptyChart(spec, theme, en) {
521
+ const frame = createFrame(spec, theme, { height: 200 });
522
+ const area = frame.layout([]);
523
+ return frame.finish([text(area.x, area.y + 20, en ? 'No numeric values to plot.' : 'No hay valores numéricos para graficar.', { fill: theme.lede, fontSize: 13 })], { desc: en ? 'Empty chart.' : 'Gráfico vacío.', type: 'empty' });
524
+ }
525
+
526
+ function hashStr(str) {
527
+ let h = 0;
528
+ for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
529
+ return h;
530
+ }