plotagents 0.0.0-stage → 0.5.0

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