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,495 @@
1
+ // Gráficos categóricos al estilo seaborn: caja (box), violín (violin),
2
+ // puntos con dispersión (strip), enjambre (swarm) y estimación con
3
+ // intervalo (point). Categorías en un eje, valores numéricos en el otro;
4
+ // `hue` (alias de `series`) separa grupos por color.
5
+ import { el, g, text, tip, dotLayer } from '../svg.js';
6
+ import { measure, truncate } from '../text.js';
7
+ import { makeFormat } from '../format.js';
8
+ import { niceDomain, niceStep, linear, log, logDomain, band } from '../scale.js';
9
+ import { colorMap, readable } from '../theme.js';
10
+ import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
11
+ import { gridX, xLabels, yAxisMargin, yAxisTop, yGrid, yTickLabels, refLinesY, refLinesX } from '../axes.js';
12
+ import { createFrame } from '../frame.js';
13
+ import { ctx } from '../context.js';
14
+ import { fiveNumber, kde, scottBandwidth, meanCI, mean, median, sd, sum, finite, quantile } from '../stats.js';
15
+
16
+ const T = (en, es, locale) => (String(locale || 'es').startsWith('en') ? en : es);
17
+
18
+ /** Pseudoaleatorio determinista en [0,1) a partir de un entero. */
19
+ function rand(i) {
20
+ let x = (i + 1) * 2654435761;
21
+ x ^= x >>> 16; x = Math.imul(x, 2246822507); x ^= x >>> 13; x = Math.imul(x, 3266489909); x ^= x >>> 16;
22
+ return (x >>> 0) / 4294967296;
23
+ }
24
+
25
+ // ── Preparación común ──────────────────────────────────────────────────────
26
+
27
+ function prepare(spec) {
28
+ const data = ensureData(spec);
29
+ const fields = fieldsOf(data);
30
+ const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
31
+ const isNum = (f) => kinds[f] === 'number';
32
+ const hue = spec.hue ?? spec.series ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : undefined);
33
+ let cat, val, swapped = false;
34
+ const { x, y } = spec;
35
+ if (x && y) {
36
+ if (isNum(x) && !isNum(y)) { cat = y; val = x; swapped = true; }
37
+ else { cat = x; val = y; }
38
+ } else if (y && !x) { val = y; cat = isNum(y) ? null : null; }
39
+ else if (x && !y) { if (isNum(x)) { val = x; swapped = true; } else { cat = x; val = fields.find((f) => isNum(f) && f !== hue); } }
40
+ else {
41
+ val = fields.find((f) => isNum(f) && f !== hue);
42
+ cat = fields.find((f) => !isNum(f) && f !== hue && f !== val) ?? null;
43
+ }
44
+ requireField(data, val, 'y (valor)');
45
+ if (cat) requireField(data, cat, 'x (categoría)');
46
+ if (hue) requireField(data, hue, 'hue');
47
+ const catOf = (r) => (cat ? String(r[cat] ?? '') : '');
48
+ const cats = Array.isArray(spec.order) ? spec.order.map(String) : uniq(data.map(catOf));
49
+ const hues = hue ? (Array.isArray(spec.hueOrder) ? spec.hueOrder.map(String) : uniq(data.map((r) => String(r[hue])))) : [''];
50
+ const groups = new Map();
51
+ for (const r of data) {
52
+ const v = num(r[val]);
53
+ if (!Number.isFinite(v)) continue;
54
+ const k = catOf(r) + '\u0000' + (hue ? String(r[hue]) : '');
55
+ if (!groups.has(k)) groups.set(k, []);
56
+ groups.get(k).push(v);
57
+ }
58
+ const get = (c, h) => groups.get(c + '\u0000' + h) || [];
59
+ const all = [...groups.values()].flat();
60
+ // Sin un solo número no hay eje de valores que dibujar.
61
+ if (!all.length) {
62
+ const en = String(spec.locale || 'es').startsWith('en');
63
+ throw en
64
+ ? new PlotError(`No numeric values in \`${val}\`.`, `\`${val}\` must hold numbers (empty, null and non-finite values are skipped). To count rows per category, use \`type: "countplot"\`.`)
65
+ : new PlotError(`No hay valores numéricos en \`${val}\`.`, `\`${val}\` debe tener números (se omiten vacíos, nulos y valores no finitos). Para contar filas por categoría usa \`type: "countplot"\`.`);
66
+ }
67
+ return { data, cat, val, hue, cats, hues, get, all, swapped };
68
+ }
69
+
70
+ /**
71
+ * Ejes, escalas, colores y leyenda compartidos por los cinco gráficos.
72
+ * `extent` = [min, max] de los valores a mostrar (incluye bigotes, IC, etc.).
73
+ */
74
+ function setup(spec, theme, m, extent, { type, zero = false, dodge = true, rowStep } = {}) {
75
+ const locale = spec.locale || 'es';
76
+ const vertical = !(spec.horizontal ?? (spec.orient === 'h' ? true : spec.orient === 'v' ? false : undefined) ?? autoHorizontal(spec, m));
77
+ const valueScale = (vertical ? spec.yScale : spec.xScale) ?? spec.valueScale ?? spec.scale;
78
+ const isLog = valueScale === 'log' && extent[0] > 0;
79
+ const fmt = makeFormat(spec.format, locale, m.all);
80
+ const multiHue = m.hues.length > 1;
81
+ const cmap = colorMap(m.hues, theme, spec.colors);
82
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
83
+ const colorOf = (c, h) => {
84
+ if (hl && !hl.has(c) && !hl.has(h)) return theme.dim;
85
+ if (multiHue) return cmap.get(h);
86
+ if (spec.colors && spec.colors[c]) return spec.colors[c];
87
+ return theme.categorical[0];
88
+ };
89
+ const legend = multiHue ? m.hues.map((h) => ({ label: h, color: hl && !hl.has(h) ? theme.dim : cmap.get(h), shape: type === 'point' ? 'line' : 'square' })) : [];
90
+
91
+ const frame = createFrame(spec, theme, { height: 420 });
92
+ let area = frame.layout(spec.legend === false ? [] : legend);
93
+ // Dominio del eje de valores: `yDomain` manda siempre (también en horizontal, para facetas).
94
+ const dom = spec.yDomain ?? (vertical ? undefined : spec.xDomain) ?? spec.valueDomain;
95
+ let lo = extent[0], hi = extent[1];
96
+ if (zero && !isLog) { lo = Math.min(0, lo); hi = Math.max(0, hi); }
97
+ const axisSize = theme.type?.axis ?? 11.5;
98
+ const yTitle = vertical ? spec.yLabel ?? humanize(m.val) : spec.yLabel;
99
+ const xTitle = vertical ? spec.xLabel : spec.xLabel ?? humanize(m.val);
100
+ const body = { pre: [], marks: [], post: [] };
101
+ let valS, nd, ticks, tickFmt, inner, catS;
102
+
103
+ if (vertical) {
104
+ const top = yAxisTop(theme) + (yTitle ? 18 : 0);
105
+ const bottom = (m.cat ? 24 : 6) + (xTitle ? 18 : 0);
106
+ const h0 = area.h - top - bottom;
107
+ if (isLog) { nd = dom ? { min: dom[0], max: dom[1] } : logDomain(lo, hi); ticks = log(nd.min, nd.max, 0, 1).ticks(); tickFmt = fmt; }
108
+ else { const cnt = Math.max(4, Math.round(h0 / 56)); nd = dom ? { min: dom[0], max: dom[1], step: niceStep(dom[1] - dom[0], cnt) } : niceDomain(lo, hi, cnt); ticks = linear(nd.min, nd.max, 0, 1).ticks(nd.step).filter((t) => t >= nd.min - 1e-9 && t <= nd.max + 1e-9); tickFmt = fmt.tick(nd.step); }
109
+ const leftM = yAxisMargin(ticks, tickFmt, theme);
110
+ inner = { x: area.x + leftM, y: area.y + top, w: area.w - leftM, h: h0 };
111
+ valS = (isLog ? log : linear)(nd.min, nd.max, inner.y + inner.h, inner.y);
112
+ catS = band(m.cats, inner.x, inner.x + inner.w, { inner: 0.28, outer: 0.12 });
113
+ body.pre.push(yGrid(ticks, valS, inner, theme, { baseline: zero ? 0 : NaN }));
114
+ body.post.push(yTickLabels(ticks, valS, inner, theme, tickFmt));
115
+ if (yTitle) body.post.push(text(area.x, area.y + 10, yTitle, { fill: theme.lede, fontSize: axisSize, fontWeight: 500 }));
116
+ if (m.cat) {
117
+ const items = m.cats.map((c) => ({ pos: catS.center(c), label: truncate(c, Math.max(40, catS.step - 6), axisSize + 0.5) }));
118
+ body.post.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18, size: axisSize + 0.5, color: theme.lede }));
119
+ }
120
+ body.pre.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, strokeWidth: 1 }));
121
+ if (xTitle) body.post.push(text(inner.x + inner.w, inner.y + inner.h + 22 + 18 * ctx.textScale, xTitle, { fill: theme.lede, fontSize: axisSize, fontWeight: 500, textAnchor: 'end' }));
122
+ } else {
123
+ const labelW = m.cat ? Math.min(Math.max(...m.cats.map((c) => measure(c, axisSize + 0.5))), area.w * (area.w < 500 ? 0.42 : 0.32)) + 12 : 0;
124
+ const step = rowStep ?? (multiHue && dodge ? Math.max(30, 22 * m.hues.length + 12) : 46);
125
+ if (spec.height === undefined) {
126
+ frame.setHeight(area.y + m.cats.length * step + 30 + (xTitle ? 20 : 0) + frame.chrome);
127
+ area = { ...area, h: frame.bottom - area.y };
128
+ }
129
+ inner = { x: area.x + labelW, y: area.y + 4, w: area.w - labelW - 10, h: area.h - 30 - (xTitle ? 20 : 0) };
130
+ if (isLog) { nd = dom ? { min: dom[0], max: dom[1] } : logDomain(lo, hi); ticks = log(nd.min, nd.max, 0, 1).ticks(); tickFmt = fmt; }
131
+ else { const cnt = Math.max(4, Math.round(inner.w / 100)); nd = dom ? { min: dom[0], max: dom[1], step: niceStep(dom[1] - dom[0], cnt) } : niceDomain(lo, hi, cnt); ticks = linear(nd.min, nd.max, 0, 1).ticks(nd.step).filter((t) => t >= nd.min - 1e-9 && t <= nd.max + 1e-9); tickFmt = fmt.tick(nd.step); }
132
+ valS = (isLog ? log : linear)(nd.min, nd.max, inner.x, inner.x + inner.w);
133
+ catS = band(m.cats, inner.y, inner.y + inner.h, { inner: 0.28, outer: 0.08 });
134
+ body.pre.push(gridX(ticks, valS, inner, theme, tickFmt, { baseline: zero ? 0 : NaN }));
135
+ if (m.cat) for (const c of m.cats) {
136
+ const strong = !hl || hl.has(c);
137
+ body.post.push(text(inner.x - 10, catS.center(c) + 4.5, truncate(c, labelW - 12, axisSize + 0.5), { fill: strong ? theme.ink : theme.muted, fontSize: axisSize + 0.5, textAnchor: 'end' }));
138
+ }
139
+ if (xTitle) body.post.push(text(inner.x + inner.w, inner.y + inner.h + 22 + 18 * ctx.textScale, xTitle, { fill: theme.lede, fontSize: axisSize, fontWeight: 500, textAnchor: 'end' }));
140
+ }
141
+
142
+ if (spec._domains && typeof spec._domains === 'object') spec._domains.y = [nd.min, nd.max];
143
+ // Líneas de referencia en el eje de valores (metas, umbrales).
144
+ const refs = [].concat(spec.refLines ?? []).filter((r) => r && typeof r === 'object').map((r) => ({ ...r, y: r.value ?? r.y ?? r.x }));
145
+ if (refs.length) body.post.push(vertical ? refLinesY(refs, valS, inner, theme, fmt) : refLinesX(refs.map((r) => ({ ...r, x: r.y, y: undefined })), valS, inner, theme, fmt));
146
+
147
+ // Posición en el eje de categorías: centro de cada grupo (con "dodge" por hue).
148
+ const k = multiHue && dodge ? m.hues.length : 1;
149
+ const slot = catS.bandwidth / k;
150
+ const center = (c, hi2) => catS(c) + (k > 1 ? slot * (hi2 + 0.5) : catS.bandwidth / 2);
151
+ const P = (cpos, v) => (vertical ? [cpos, valS(v)] : [valS(v), cpos]);
152
+ return { frame, vertical, valS, catS, slot, center, P, fmt, colorOf, body, inner, locale, hl, multiHue };
153
+ }
154
+
155
+ function autoHorizontal(spec, m) {
156
+ if (m.swapped) return true;
157
+ if (!m.cat) return false;
158
+ const w = (spec.width || 720) - 48;
159
+ const slot = w / Math.max(1, m.cats.length);
160
+ const longest = Math.max(...m.cats.map((c) => measure(c, 12)));
161
+ return m.cats.length > 10 || longest > slot - 10;
162
+ }
163
+
164
+ const finish = (s, desc, type) => s.frame.finish([...s.body.pre, ...s.body.marks, ...s.body.post], { desc, type });
165
+ const extentOf = (vs) => [Math.min(...vs), Math.max(...vs)];
166
+
167
+ /** Segmento (línea) en coordenadas de categoría/valor. */
168
+ function seg(s, cpos0, v0, cpos1, v1, attrs) {
169
+ const [x1, y1] = s.P(cpos0, v0), [x2, y2] = s.P(cpos1, v1);
170
+ return el('line', { x1, y1, x2, y2, ...attrs });
171
+ }
172
+
173
+ /** Rectángulo entre dos valores, centrado en cpos con ancho w (eje de categorías). */
174
+ function rectBetween(s, cpos, w, v0, v1, attrs) {
175
+ const a = s.valS(v0), b = s.valS(v1);
176
+ return s.vertical
177
+ ? el('rect', { x: cpos - w / 2, y: Math.min(a, b), width: w, height: Math.max(0.5, Math.abs(a - b)), ...attrs })
178
+ : el('rect', { x: Math.min(a, b), y: cpos - w / 2, width: Math.max(0.5, Math.abs(a - b)), height: w, ...attrs });
179
+ }
180
+
181
+ function strips(s, theme, vals, cpos, width, color, seed, r) {
182
+ return vals.map((v, i) => {
183
+ const off = (rand(seed * 7919 + i) - 0.5) * width;
184
+ const [x, y] = s.P(cpos + off, v);
185
+ return el('circle', { cx: x, cy: y, r, fill: color, fillOpacity: 0.55, stroke: 'none' });
186
+ });
187
+ }
188
+
189
+ const fmtTipLabel = (c, h) => [c, h].filter(Boolean).join(' · ');
190
+
191
+ // ── Caja ───────────────────────────────────────────────────────────────────
192
+
193
+ export function box(spec, theme) {
194
+ const m = prepare(spec);
195
+ const stats = new Map();
196
+ const ext = [];
197
+ for (const c of m.cats) for (const h of m.hues) {
198
+ const vs = m.get(c, h);
199
+ if (!vs.length) continue;
200
+ const f = fiveNumber(vs);
201
+ stats.set(c + '\u0000' + h, f);
202
+ ext.push(f.min, f.max);
203
+ }
204
+ const s = setup(spec, theme, m, extentOf(ext), { type: 'box' });
205
+ const lw = theme.marks?.lineWidth ?? 2;
206
+ m.cats.forEach((c) => m.hues.forEach((h, hi2) => {
207
+ const f = stats.get(c + '\u0000' + h);
208
+ if (!f) return;
209
+ const color = s.colorOf(c, h);
210
+ const cpos = s.center(c, hi2);
211
+ const w = Math.min(s.slot * 0.72, 64);
212
+ const parts = [];
213
+ parts.push(seg(s, cpos, f.whiskerLo, cpos, f.q1, { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' }));
214
+ parts.push(seg(s, cpos, f.q3, cpos, f.whiskerHi, { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' }));
215
+ for (const v of [f.whiskerLo, f.whiskerHi]) parts.push(seg(s, cpos - w * 0.22, v, cpos + w * 0.22, v, { stroke: color, strokeWidth: 1.5, strokeLinecap: 'round' }));
216
+ parts.push(rectBetween(s, cpos, w, f.q1, f.q3, { rx: 3, fill: color, fillOpacity: 0.16, stroke: color, strokeWidth: 1.5 }));
217
+ if (spec.points) parts.push(...strips(s, theme, m.get(c, h), cpos, w * 0.6, color, hi2 * 131 + m.cats.indexOf(c), 2.4));
218
+ parts.push(seg(s, cpos - w / 2 + 0.75, f.median, cpos + w / 2 - 0.75, f.median, { stroke: color, strokeWidth: Math.max(2.5, lw), strokeLinecap: 'butt' }));
219
+ if (spec.showMeans || spec.mean) { const [x, y] = s.P(cpos, f.mean); parts.push(el('path', { d: `M${x},${y - 4}L${x + 4},${y}L${x},${y + 4}L${x - 4},${y}Z`, fill: theme.surface, stroke: color, strokeWidth: 1.5 })); }
220
+ if (!spec.points) for (const v of f.outliers) { const [x, y] = s.P(cpos, v); parts.push(el('circle', { cx: x, cy: y, r: 2.75, fill: 'none', stroke: color, strokeWidth: 1.25 })); }
221
+ s.body.marks.push(g({ class: 'pa-m', 'data-tip': tip(fmtTipLabel(c, h), `n = ${f.n}`, `${T('Median', 'Mediana', s.locale)}: ${s.fmt(f.median)}`, `Q1–Q3: ${s.fmt(f.q1)} – ${s.fmt(f.q3)}`, `${T('Whiskers', 'Bigotes', s.locale)}: ${s.fmt(f.whiskerLo)} – ${s.fmt(f.whiskerHi)}`, f.outliers.length ? `${T('Outliers', 'Atípicos', s.locale)}: ${f.outliers.length}` : '') }, parts));
222
+ }));
223
+ return finish(s, describeGroups(m, (vs) => median(vs), s.fmt, s.locale, T('Box plot', 'Diagrama de caja', s.locale), T('median', 'mediana', s.locale)), 'box');
224
+ }
225
+
226
+ // ── Violín ─────────────────────────────────────────────────────────────────
227
+
228
+ export function violin(spec, theme) {
229
+ const m = prepare(spec);
230
+ const split = spec.split && m.hues.length === 2;
231
+ const cut = spec.cut ?? 0;
232
+ const dens = new Map();
233
+ const ext = [];
234
+ // En escala log, la densidad se estima sobre log10(v) para que la forma sea fiel.
235
+ const logV = (spec.yScale ?? spec.xScale ?? spec.valueScale ?? spec.scale) === 'log' && m.all.every((v) => v > 0);
236
+ const tf = logV ? Math.log10 : (v) => v, inv = logV ? (v) => 10 ** v : (v) => v;
237
+ for (const c of m.cats) for (const h of m.hues) {
238
+ const vs = m.get(c, h);
239
+ if (!vs.length) continue;
240
+ // Un solo valor (o todos iguales) no tiene densidad: se dibuja como una marca.
241
+ if (vs.length < 2 || Math.min(...vs) === Math.max(...vs)) {
242
+ dens.set(c + '\u0000' + h, { single: true, f: fiveNumber(vs), n: vs.length, max: 0 });
243
+ ext.push(vs[0]);
244
+ continue;
245
+ }
246
+ const tv = vs.map(tf);
247
+ const bw = (spec.bandwidth ?? scottBandwidth(tv)) * (spec.bwAdjust ?? 1);
248
+ const mn = Math.min(...tv), mx = Math.max(...tv);
249
+ const d = kde(tv, { bandwidth: bw, extent: [mn - cut * bw, mx + cut * bw], gridSize: 96 }).map(([v, dd]) => [inv(v), dd]);
250
+ dens.set(c + '\u0000' + h, { d, f: fiveNumber(vs), n: vs.length, max: Math.max(...d.map((p) => p[1])) });
251
+ ext.push(inv(mn - cut * bw), inv(mx + cut * bw));
252
+ }
253
+ const s = setup(spec, theme, m, extentOf(ext), { type: 'violin', dodge: !split });
254
+ const scaleMode = ['width', 'count', 'area'].includes(spec.scale) ? spec.scale : spec.densityNorm ?? 'width';
255
+ const gMax = Math.max(...[...dens.values()].filter((x) => !x.single).map((x) => x.max), 0);
256
+ const nMax = Math.max(...[...dens.values()].filter((x) => !x.single).map((x) => x.n), 1);
257
+ const inner = spec.inner ?? 'box';
258
+ const op = Math.min(0.5, (theme.marks?.bandOpacity ?? 0.16) * 2.2);
259
+ m.cats.forEach((c) => m.hues.forEach((h, hi2) => {
260
+ const D = dens.get(c + '\u0000' + h);
261
+ if (!D) return;
262
+ const color = s.colorOf(c, h);
263
+ const cpos = split ? s.catS.center(c) : s.center(c, hi2);
264
+ const half = (split ? s.catS.bandwidth : s.slot) * 0.46;
265
+ const norm = scaleMode === 'width' ? D.max : scaleMode === 'count' ? gMax * (nMax / D.n) : gMax;
266
+ const wOf = (d) => (d / (norm || 1)) * half;
267
+ const side = split ? (hi2 === 0 ? -1 : 1) : 0;
268
+ if (D.single) {
269
+ // Sin forma que estimar: una raya a lo ancho del violín y un punto en el valor.
270
+ const v = D.f.median, w = Math.min(half, 18);
271
+ const [x, y] = s.P(cpos, v);
272
+ const parts = [
273
+ seg(s, cpos - (side === 1 ? 0 : w), v, cpos + (side === -1 ? 0 : w), v, { stroke: color, strokeWidth: 2, strokeLinecap: 'round' }),
274
+ el('circle', { cx: x, cy: y, r: 3.5, fill: color, stroke: theme.surface, strokeWidth: 1.5 }),
275
+ ];
276
+ s.body.marks.push(g({ class: 'pa-m', 'data-tip': tip(fmtTipLabel(c, h), `n = ${D.n}`, `${T('Value', 'Valor', s.locale)}: ${s.fmt(v)}`) }, parts));
277
+ return;
278
+ }
279
+ const right = D.d.map(([v, d]) => s.P(cpos + (side === -1 ? 0 : wOf(d)), v));
280
+ const left = D.d.map(([v, d]) => s.P(cpos - (side === 1 ? 0 : wOf(d)), v)).reverse();
281
+ const pts = side === -1 ? [...D.d.map(([v]) => s.P(cpos, v)), ...left] : side === 1 ? [...right, ...D.d.map(([v]) => s.P(cpos, v)).reverse()] : [...right, ...left];
282
+ const path = 'M' + pts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join('L') + 'Z';
283
+ const parts = [el('path', { d: path, fill: color, fillOpacity: op, stroke: color, strokeWidth: 1.25, strokeLinejoin: 'round' })];
284
+ const f = D.f;
285
+ const ic = cpos + (side === 0 ? 0 : side * Math.min(half * 0.22, 7));
286
+ if (inner === 'box') {
287
+ parts.push(seg(s, ic, f.whiskerLo, ic, f.whiskerHi, { stroke: theme.ink, strokeWidth: 1.25, strokeOpacity: 0.7 }));
288
+ parts.push(rectBetween(s, ic, 5, f.q1, f.q3, { rx: 2, fill: theme.ink, fillOpacity: 0.85 }));
289
+ const [x, y] = s.P(ic, f.median);
290
+ parts.push(el('circle', { cx: x, cy: y, r: 2.6, fill: theme.surface }));
291
+ } else if (inner === 'quartiles') {
292
+ for (const [q, wq] of [[f.q1, 1], [f.median, 2], [f.q3, 1]]) {
293
+ const dq = D.d.reduce((a, p) => (Math.abs(p[0] - q) < Math.abs(a[0] - q) ? p : a))[1];
294
+ const w = wOf(dq);
295
+ parts.push(seg(s, cpos - (side === 1 ? 0 : w), q, cpos + (side === -1 ? 0 : w), q, { stroke: color, strokeWidth: wq + 0.25 }));
296
+ }
297
+ } else if (inner === 'points' || inner === 'stick') {
298
+ for (const v of m.get(c, h)) parts.push(seg(s, cpos - 3, v, cpos + 3, v, { stroke: theme.ink, strokeOpacity: 0.55, strokeWidth: 1 }));
299
+ }
300
+ s.body.marks.push(g({ class: 'pa-m', 'data-tip': tip(fmtTipLabel(c, h), `n = ${D.n}`, `${T('Median', 'Mediana', s.locale)}: ${s.fmt(f.median)}`, `Q1–Q3: ${s.fmt(f.q1)} – ${s.fmt(f.q3)}`, `${T('Range', 'Rango', s.locale)}: ${s.fmt(f.min)} – ${s.fmt(f.max)}`) }, parts));
301
+ }));
302
+ return finish(s, describeGroups(m, (vs) => median(vs), s.fmt, s.locale, T('Violin plot', 'Gráfico de violín', s.locale), T('median', 'mediana', s.locale)), 'violin');
303
+ }
304
+
305
+ // ── Puntos con dispersión (strip) ──────────────────────────────────────────
306
+
307
+ export function strip(spec, theme) {
308
+ const m = prepare(spec);
309
+ const s = setup(spec, theme, m, extentOf(m.all), { type: 'strip', dodge: spec.dodge ?? true });
310
+ const n = m.all.length;
311
+ const r = spec.radius ?? (n > 600 ? 2 : n > 200 ? 2.75 : (theme.marks?.pointRadius ?? 4) - 0.5);
312
+ const jitter = spec.jitter ?? 0.6;
313
+ m.cats.forEach((c, ci) => m.hues.forEach((h, hi2) => {
314
+ const vs = m.get(c, h);
315
+ if (!vs.length) return;
316
+ const color = s.colorOf(c, h);
317
+ const cpos = s.center(c, hi2);
318
+ const w = s.slot * (jitter === true ? 0.6 : jitter || 0);
319
+ const parts = vs.map((v, i) => {
320
+ const [x, y] = s.P(cpos + (rand(ci * 7919 + hi2 * 104729 + i) - 0.5) * w, v);
321
+ return { class: 'pa-m', cx: x, cy: y, r, fill: color, fillOpacity: theme.marks?.dotOpacity ?? 0.85, stroke: theme.surface, strokeWidth: n > 300 ? 0.75 : 1.25, 'data-tip': tip(fmtTipLabel(c, h), s.fmt(v)) };
322
+ });
323
+ parts.splice(0, parts.length, dotLayer(parts));
324
+ if (spec.mean || spec.showMeans) {
325
+ const mu = mean(vs);
326
+ parts.push(seg(s, cpos - Math.min(s.slot * 0.42, 34), mu, cpos + Math.min(s.slot * 0.42, 34), mu, { stroke: theme.ink, strokeWidth: 2.5, strokeLinecap: 'round' }));
327
+ }
328
+ s.body.marks.push(g({}, parts));
329
+ }));
330
+ return finish(s, describeGroups(m, mean, s.fmt, s.locale, T('Strip plot', 'Gráfico de puntos por categoría', s.locale), T('mean', 'promedio', s.locale)), 'strip');
331
+ }
332
+
333
+ // ── Enjambre (swarm / beeswarm) ────────────────────────────────────────────
334
+
335
+ /**
336
+ * Coloca puntos sin traslape: cada punto busca el desplazamiento lateral más
337
+ * chico que no choque con los ya ubicados. Devuelve offsets en px.
338
+ */
339
+ export function beeswarm(positions, r) {
340
+ const order = positions.map((p, i) => [p, i]).sort((a, b) => a[0] - b[0]);
341
+ const placed = [];
342
+ const out = new Array(positions.length).fill(0);
343
+ const d = 2 * r + 0.4;
344
+ for (const [p, i] of order) {
345
+ const near = placed.filter((q) => Math.abs(q.p - p) < d);
346
+ const cands = [0];
347
+ for (const q of near) {
348
+ const dx = Math.sqrt(Math.max(0, d * d - (q.p - p) ** 2));
349
+ cands.push(q.o + dx, q.o - dx);
350
+ }
351
+ cands.sort((a, b) => Math.abs(a) - Math.abs(b) || a - b);
352
+ let best = 0;
353
+ for (const o of cands) {
354
+ if (near.every((q) => (q.p - p) ** 2 + (q.o - o) ** 2 >= d * d - 1e-6)) { best = o; break; }
355
+ }
356
+ placed.push({ p, o: best });
357
+ out[i] = best;
358
+ }
359
+ return out;
360
+ }
361
+
362
+ export function swarm(spec, theme) {
363
+ const m = prepare(spec);
364
+ const s = setup(spec, theme, m, extentOf(m.all), { type: 'swarm', dodge: spec.dodge ?? false });
365
+ const groupsOf = (c) => (s.multiHue && (spec.dodge ?? false) ? m.hues.map((h, hi2) => [[h], hi2]) : [[m.hues, 0]]);
366
+ // Radio automático: se achica hasta que el enjambre más ancho quepa.
367
+ let r = spec.radius ?? (theme.marks?.pointRadius ?? 4);
368
+ const layouts = () => m.cats.map((c) => groupsOf(c).map(([hs, hi2]) => {
369
+ const items = hs.flatMap((h) => m.get(c, h).map((v) => ({ v, h })));
370
+ const offs = beeswarm(items.map((it) => s.valS(it.v)), r);
371
+ return { c, hi2, items, offs, width: Math.max(0, ...offs.map(Math.abs)) * 2 + 2 * r };
372
+ }));
373
+ let L = layouts();
374
+ const room = (spec.dodge ? s.slot : s.catS.bandwidth) * 0.98;
375
+ for (let it = 0; it < 12 && Math.max(...L.flat().map((x) => x.width)) > room && r > 1.4; it++) { r *= 0.85; L = layouts(); }
376
+ for (const grp of L.flat()) {
377
+ const cpos = spec.dodge ? s.center(grp.c, grp.hi2) : s.catS.center(grp.c);
378
+ const parts = grp.items.map((it, i) => {
379
+ const o = Math.max(-room / 2 + r, Math.min(room / 2 - r, grp.offs[i]));
380
+ const [x, y] = s.vertical ? [cpos + o, s.valS(it.v)] : [s.valS(it.v), cpos + o];
381
+ const color = s.colorOf(grp.c, it.h);
382
+ return { class: 'pa-m', cx: x, cy: y, r, fill: color, fillOpacity: theme.marks?.dotOpacity ?? 0.85, stroke: theme.surface, strokeWidth: r > 2.5 ? 1 : 0.5, 'data-tip': tip(fmtTipLabel(grp.c, it.h), s.fmt(it.v)) };
383
+ });
384
+ parts.splice(0, parts.length, dotLayer(parts));
385
+ s.body.marks.push(g({}, parts));
386
+ }
387
+ return finish(s, describeGroups(m, median, s.fmt, s.locale, T('Swarm plot', 'Gráfico de enjambre', s.locale), T('median', 'mediana', s.locale)), 'swarm');
388
+ }
389
+
390
+ // ── Estimación con intervalo (point) ───────────────────────────────────────
391
+
392
+ function estimate(vs, est, eb, level = 0.95) {
393
+ const v = finite(vs);
394
+ if (!v.length) return null;
395
+ const e = est === 'median' ? median(v) : est === 'sum' ? sum(v) : mean(v);
396
+ let lo = e, hi = e;
397
+ if (eb === 'ci' && est !== 'sum') [lo, hi] = est === 'median' ? medianCI(v, level) : meanCI(v, level);
398
+ else if (eb === 'sd') { lo = e - sd(v); hi = e + sd(v); }
399
+ else if (eb === 'se') { const se = sd(v) / Math.sqrt(v.length); lo = e - se; hi = e + se; }
400
+ else if (eb === 'pi') { const st = [...v].sort((a, b) => a - b); lo = quantile(st, 0.025); hi = quantile(st, 0.975); }
401
+ return { e, lo, hi, n: v.length };
402
+ }
403
+
404
+ function medianCI(v, level) {
405
+ // IC de la mediana por orden de estadísticos (aproximación normal binomial).
406
+ const st = [...v].sort((a, b) => a - b);
407
+ const n = st.length;
408
+ const z = level >= 0.99 ? 2.576 : level >= 0.95 ? 1.96 : 1.645;
409
+ const k = Math.max(0, Math.floor(n / 2 - (z * Math.sqrt(n)) / 2));
410
+ return [st[k], st[Math.min(n - 1, n - 1 - k)]];
411
+ }
412
+
413
+ export function point(spec, theme) {
414
+ const m = prepare(spec);
415
+ const est = spec.estimator ?? 'mean';
416
+ const eb = spec.errorbar === undefined ? 'ci' : spec.errorbar;
417
+ const res = new Map();
418
+ const ext = [];
419
+ for (const c of m.cats) for (const h of m.hues) {
420
+ const r = estimate(m.get(c, h), est, eb, spec.level ?? 0.95);
421
+ if (!r) continue;
422
+ res.set(c + '\u0000' + h, r);
423
+ ext.push(r.lo, r.hi);
424
+ }
425
+ const multi = m.hues.length > 1;
426
+ const showEnd = multi && (spec.labels ?? true) && m.hues.length <= 4;
427
+ // Reserva espacio a la derecha para las etiquetas al final.
428
+ const endW = showEnd ? Math.min(140, Math.max(...m.hues.map((h) => measure(h, 12, 500)))) + 16 : 0;
429
+ const s = setup({ ...spec, width: spec.width }, theme, m, extentOf(ext), { type: 'point', dodge: false, zero: est === 'sum' });
430
+ const vertical = s.vertical;
431
+ if (vertical && showEnd) {
432
+ // Recalcula el eje de categorías dejando espacio a la derecha.
433
+ s.catS = band(m.cats, s.inner.x, s.inner.x + s.inner.w - endW, { inner: 0.28, outer: 0.12 });
434
+ s.body.post = s.body.post.filter((p) => !p.startsWith('<g><text') || !m.cat);
435
+ const axisSize = theme.type?.axis ?? 11.5;
436
+ if (m.cat) s.body.post.push(xLabels(m.cats.map((c) => ({ pos: s.catS.center(c), label: truncate(c, Math.max(40, s.catS.step - 6), axisSize + 0.5) })), s.inner, theme, { y: s.inner.y + s.inner.h + 18, size: axisSize + 0.5, color: theme.lede }));
437
+ }
438
+ const dodgeW = multi && (spec.dodge ?? true) ? Math.min(s.catS.bandwidth * 0.36, 26) : 0;
439
+ const cposOf = (c, hi2) => s.catS.center(c) + (multi ? (hi2 - (m.hues.length - 1) / 2) * (dodgeW / Math.max(1, m.hues.length - 1)) : 0);
440
+ const lw = theme.marks?.lineWidth ?? 2;
441
+ const ends = [];
442
+ m.hues.forEach((h, hi2) => {
443
+ const color = s.colorOf(null, h);
444
+ const pts = m.cats.map((c) => [c, res.get(c + '\u0000' + h)]).filter(([, r]) => r);
445
+ const parts = [];
446
+ if (spec.join !== false && pts.length > 1) {
447
+ const d = pts.map(([c, r], i) => { const [x, y] = s.P(cposOf(c, hi2), r.e); return (i ? 'L' : 'M') + x.toFixed(2) + ',' + y.toFixed(2); }).join('');
448
+ parts.push(el('path', { d, fill: 'none', stroke: color, strokeWidth: lw, strokeLinejoin: 'round', strokeLinecap: 'round', strokeOpacity: 0.9 }));
449
+ }
450
+ for (const [c, r] of pts) {
451
+ const cp = cposOf(c, hi2);
452
+ const bits = [];
453
+ if (eb) bits.push(seg(s, cp, r.lo, cp, r.hi, { stroke: color, strokeWidth: 2, strokeLinecap: 'round' }));
454
+ const [x, y] = s.P(cp, r.e);
455
+ bits.push(el('circle', { cx: x, cy: y, r: (theme.marks?.pointRadius ?? 4) + 0.5, fill: color, stroke: theme.surface, strokeWidth: theme.marks?.ring ?? 2 }));
456
+ const ebName = eb === 'ci' ? `IC ${Math.round((spec.level ?? 0.95) * 100)}%` : eb === 'sd' ? T('± SD', '± DE', s.locale) : eb === 'se' ? T('± SE', '± EE', s.locale) : eb === 'pi' ? T('95% range', 'rango 95%', s.locale) : '';
457
+ const estName = est === 'median' ? T('Median', 'Mediana', s.locale) : est === 'sum' ? T('Sum', 'Suma', s.locale) : T('Mean', 'Promedio', s.locale);
458
+ parts.push(g({ class: 'pa-m', 'data-tip': tip(fmtTipLabel(c, h), `${estName}: ${s.fmt(r.e)}`, eb ? `${ebName}: ${s.fmt(r.lo)} – ${s.fmt(r.hi)}` : '', `n = ${r.n}`) }, bits));
459
+ }
460
+ if (showEnd && vertical && pts.length) {
461
+ const [c, r] = pts[pts.length - 1];
462
+ const [x, y] = s.P(cposOf(c, hi2), r.e);
463
+ ends.push({ x: s.catS.center(c) + dodgeW / 2 + 12, y, y0: y, xd: x, label: h, color: s.colorOf(c, h) });
464
+ }
465
+ s.body.marks.push(g({ 'data-series': h }, parts));
466
+ });
467
+ if (ends.length) {
468
+ ends.sort((a, b) => a.y - b.y);
469
+ for (let it = 0; it < 30; it++) for (let i = 1; i < ends.length; i++) { const d = ends[i].y - ends[i - 1].y; if (d < 15) { const sh = (15 - d) / 2; ends[i].y += sh; ends[i - 1].y -= sh; } }
470
+ for (const e of ends) {
471
+ if (Math.abs(e.y - e.y0) > 3 || e.x - e.xd > 14) s.body.post.push(el('path', { d: `M${(e.xd + 7).toFixed(1)},${e.y0.toFixed(1)}L${(e.x - 8).toFixed(1)},${e.y0.toFixed(1)}L${(e.x - 3).toFixed(1)},${e.y.toFixed(1)}`, fill: 'none', stroke: theme.muted, strokeWidth: 1, strokeOpacity: 0.7 }));
472
+ s.body.post.push(text(e.x, e.y + 4, truncate(e.label, endW - 12, 12, 500), { fill: readable(e.color, theme), fontSize: 12, fontWeight: 600 }));
473
+ }
474
+ }
475
+ const estW = est === 'median' ? T('median', 'mediana', s.locale) : est === 'sum' ? T('sum', 'suma', s.locale) : T('mean', 'promedio', s.locale);
476
+ return finish(s, describeGroups(m, (vs) => estimate(vs, est, false).e, s.fmt, s.locale, T('Point plot', 'Gráfico de estimación con intervalo', s.locale), estW), 'point');
477
+ }
478
+
479
+ // ── Descripción ────────────────────────────────────────────────────────────
480
+
481
+ function describeGroups(m, stat, fmt, locale, kind, statName) {
482
+ const en = String(locale).startsWith('en');
483
+ const rows = [];
484
+ for (const c of m.cats) for (const h of m.hues) {
485
+ const vs = m.get(c, h);
486
+ if (vs.length) rows.push([fmtTipLabel(c, h) || humanize(m.val), stat(vs), vs.length]);
487
+ }
488
+ if (!rows.length) return kind + '.';
489
+ rows.sort((a, b) => b[1] - a[1]);
490
+ const hi = rows[0], lo = rows[rows.length - 1];
491
+ const what = humanize(m.val).toLowerCase();
492
+ return en
493
+ ? `${kind} of ${what} across ${rows.length} groups (${m.all.length} observations). Highest ${statName}: ${hi[0]} (${fmt(hi[1])}). Lowest: ${lo[0]} (${fmt(lo[1])}).`
494
+ : `${kind} de ${what} en ${rows.length} grupos (${m.all.length} observaciones). Mayor ${statName}: ${hi[0]} (${fmt(hi[1])}). Menor: ${lo[0]} (${fmt(lo[1])}).`;
495
+ }