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
package/src/axes.js ADDED
@@ -0,0 +1,144 @@
1
+ // Ejes recesivos: líneas finas (1px), sólidas, un tono fuera del fondo.
2
+ import { el, g, text } from './svg.js';
3
+ import { measure } from './text.js';
4
+ import { SIZES } from './frame.js';
5
+ import { ctx } from './context.js';
6
+
7
+ /** Líneas de grilla horizontales con etiquetas a la izquierda. */
8
+ export function gridY(ticks, y, area, theme, fmt, { labels = true, baseline = 0 } = {}) {
9
+ const out = [];
10
+ for (const t of ticks) {
11
+ const py = Math.round(y(t)) + 0.5;
12
+ const isBase = t === baseline;
13
+ out.push(el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: isBase ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
14
+ if (labels) out.push(text(area.x - 8, py + 4, fmt(t), { fill: theme.muted, fontSize: SIZES.axis, textAnchor: 'end' }));
15
+ }
16
+ return g({ class: 'pa-axis pa-axis-y' }, out);
17
+ }
18
+
19
+ /** Líneas de grilla verticales con etiquetas abajo. */
20
+ export function gridX(ticks, x, area, theme, fmt, { labels = true, baseline = 0, lines = true } = {}) {
21
+ const out = [];
22
+ const items = ticks.map((t) => ({ pos: Math.round(x(t)) + 0.5, label: fmt(t), t }));
23
+ for (const it of items) {
24
+ if (lines) out.push(el('line', { x1: it.pos, x2: it.pos, y1: area.y, y2: area.y + area.h, stroke: it.t === baseline ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
25
+ }
26
+ if (labels) out.push(xLabels(items, area, theme));
27
+ return g({ class: 'pa-axis pa-axis-x' }, out);
28
+ }
29
+
30
+ /** Etiquetas bajo el eje x, raleadas si chocan (nunca rotadas). */
31
+ export function xLabels(items, area, theme, { y, size = SIZES.axis, color } = {}) {
32
+ const ly = (y ?? area.y + area.h + 18) + (ctx.textScale - 1) * 10;
33
+ const widths = items.map((it) => measure(it.label, size));
34
+ let every = 1;
35
+ const fits = (k) => {
36
+ let last = -Infinity;
37
+ for (let i = 0; i < items.length; i += k) {
38
+ const left = items[i].pos - widths[i] / 2;
39
+ if (left < last + 10) return false;
40
+ last = items[i].pos + widths[i] / 2;
41
+ }
42
+ return true;
43
+ };
44
+ while (every < items.length && !fits(every)) every++;
45
+ const out = [];
46
+ items.forEach((it, i) => {
47
+ if (i % every) return;
48
+ let anchor = 'middle';
49
+ let px = it.pos;
50
+ const half = widths[i] / 2;
51
+ // Puede sobresalir del área hasta el margen disponible (0 en paneles).
52
+ const ov = Math.min(20, Math.max(0, area.x));
53
+ if (px - half < area.x - ov) { anchor = 'start'; px = Math.max(px - 4, area.x - ov); }
54
+ if (px + half > area.x + area.w + ov) { anchor = 'end'; px = Math.min(px + 4, area.x + area.w + ov); }
55
+ out.push(text(px, ly, it.label, { fill: color || theme.muted, fontSize: size, textAnchor: anchor }));
56
+ });
57
+ return g({}, out);
58
+ }
59
+
60
+ export const ticksWidth = (ticks, fmt, size = SIZES.axis) => Math.max(0, ...ticks.map((t) => measure(fmt(t), size)));
61
+
62
+ // ── Eje y "interior": la etiqueta va sobre su línea de grilla, dentro del
63
+ // área de trazado (estilo FT / Datawrapper). Ahorra el margen izquierdo.
64
+ // Uso: margen = yAxisMargin(...); dibuja yGrid(...) antes de las marcas y
65
+ // yTickLabels(...) después, para que queden legibles sobre los datos.
66
+
67
+ export const isInside = (theme, inside) => inside ?? theme.axes?.inside ?? true;
68
+
69
+ /** Margen izquierdo que necesita el eje y (0 si las etiquetas van dentro). */
70
+ export function yAxisMargin(ticks, fmt, theme, inside) {
71
+ return isInside(theme, inside) ? 0 : ticksWidth(ticks, fmt, theme.type?.axis ?? SIZES.axis) + 10;
72
+ }
73
+ /** Espacio superior para la etiqueta de la marca más alta. */
74
+ export const yAxisTop = (theme, inside) => (isInside(theme, inside) ? 14 * ctx.textScale : 6);
75
+
76
+ export function yGrid(ticks, y, area, theme, { baseline = 0 } = {}) {
77
+ return g({ class: 'pa-grid' }, ticks.map((t) => {
78
+ const py = Math.round(y(t)) + 0.5;
79
+ return el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: t === baseline ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' });
80
+ }));
81
+ }
82
+
83
+ export function yTickLabels(ticks, y, area, theme, fmt, { inside, unit } = {}) {
84
+ const size = theme.type?.axis ?? SIZES.axis;
85
+ const ins = isInside(theme, inside);
86
+ return g({ class: 'pa-axis pa-axis-y' }, ticks.map((t, i) => {
87
+ const py = Math.round(y(t)) + 0.5;
88
+ const label = fmt(t) + (unit && i === ticks.length - 1 ? ' ' + unit : '');
89
+ return ins
90
+ ? text(area.x, py - 5 * Math.max(1, ctx.textScale * 0.8), label, { fill: theme.muted, fontSize: size, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' })
91
+ : text(area.x - 8, py + 4, label, { fill: theme.muted, fontSize: size, textAnchor: 'end' });
92
+ }));
93
+ }
94
+
95
+ /** Líneas de referencia: metas, promedios, umbrales. Sólidas, nunca punteadas. */
96
+ export function refLinesY(refs = [], y, area, theme, fmt) {
97
+ const out = [];
98
+ for (const r of refs) {
99
+ if (r.y === undefined) continue;
100
+ const py = Math.round(y(r.y)) + 0.5;
101
+ if (py < area.y - 1 || py > area.y + area.h + 1) continue;
102
+ out.push(el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: r.color || theme.ink, strokeWidth: 1.25, opacity: 0.7 }));
103
+ const lab = r.label ? `${r.label} · ${fmt(r.y)}` : fmt(r.y);
104
+ const w = measure(lab, 11.5, 500);
105
+ out.push(el('rect', { x: area.x + area.w - w - 8, y: py - 17, width: w + 8, height: 15, rx: 3, fill: theme.surface, opacity: 0.9 }));
106
+ out.push(text(area.x + area.w - 4, py - 6, lab, { fill: theme.ink, fontSize: 11.5, fontWeight: 500, textAnchor: 'end' }));
107
+ }
108
+ return g({ class: 'pa-refs' }, out);
109
+ }
110
+
111
+ export function refLinesX(refs = [], x, area, theme, fmt) {
112
+ const out = [];
113
+ for (const r of refs) {
114
+ if (r.x === undefined || r.y !== undefined) continue;
115
+ const px = Math.round(x(r.x)) + 0.5;
116
+ out.push(el('line', { x1: px, x2: px, y1: area.y, y2: area.y + area.h, stroke: r.color || theme.ink, strokeWidth: 1.25, opacity: 0.7 }));
117
+ const lab = r.label ? `${r.label} · ${fmt(r.x)}` : fmt(r.x);
118
+ out.push(text(px + 5, area.y + 11, lab, { fill: theme.ink, fontSize: 11.5, fontWeight: 500 }));
119
+ }
120
+ return g({ class: 'pa-refs' }, out);
121
+ }
122
+
123
+ /**
124
+ * Esquina más despejada para una nota (R², n…): cuenta los puntos (y la recta
125
+ * de ajuste, si se pasa muestreada) que caen bajo cada esquina candidata.
126
+ * Devuelve { x, y, anchor } listo para un <text>.
127
+ */
128
+ export function quietCorner(points, inner, w, h = 14, pad = 8) {
129
+ const m = 10;
130
+ const cands = [
131
+ { k: 'tr', x0: inner.x + inner.w - pad - w, y0: inner.y + 2, tx: inner.x + inner.w - pad, ty: inner.y + 2 + h - 3, anchor: 'end' },
132
+ { k: 'br', x0: inner.x + inner.w - pad - w, y0: inner.y + inner.h - h - 4, tx: inner.x + inner.w - pad, ty: inner.y + inner.h - 7, anchor: 'end' },
133
+ // A la izquierda se deja libre la columna de etiquetas interiores del eje y.
134
+ { k: 'tl', x0: inner.x + pad + 34, y0: inner.y + 2, tx: inner.x + pad + 34, ty: inner.y + 2 + h - 3, anchor: 'start' },
135
+ { k: 'bl', x0: inner.x + pad + 34, y0: inner.y + inner.h - h - 4, tx: inner.x + pad + 34, ty: inner.y + inner.h - 7, anchor: 'start' },
136
+ ];
137
+ let best = cands[0], bestN = Infinity;
138
+ for (const c of cands) {
139
+ let n = 0;
140
+ for (const [px, py] of points) if (px >= c.x0 - m && px <= c.x0 + w + m && py >= c.y0 - m && py <= c.y0 + h + m) n++;
141
+ if (n < bestN) { bestN = n; best = c; }
142
+ }
143
+ return { x: best.tx, y: best.ty, anchor: best.anchor };
144
+ }
@@ -0,0 +1,339 @@
1
+ // Barras y columnas: simples, agrupadas, apiladas y apiladas al 100%.
2
+ // Orientación automática: columnas para pocas categorías cortas o tiempo,
3
+ // barras horizontales cuando las etiquetas son largas o hay muchas.
4
+ import { el, g, text, barPath, tip } from '../svg.js';
5
+ import { measure, truncate } from '../text.js';
6
+ import { makeFormat } from '../format.js';
7
+ import { niceDomain, niceStep, linear, band } from '../scale.js';
8
+ import { colorMap } from '../theme.js';
9
+ import { ensureData, inferXY, requireField, melt, uniq, aggregate, num, fieldKind, humanize } from '../data.js';
10
+ import { gridY, gridX, xLabels, ticksWidth, refLinesY, refLinesX } from '../axes.js';
11
+ import { mean, sd, meanCI } from '../stats.js';
12
+ import { createFrame, SIZES, hash } from '../frame.js';
13
+ import { parseDate } from '../format.js';
14
+ import { dashedMatcher, fromTest, hatcher, dashedAttrs, dashedDefaultLabel } from '../dashed.js';
15
+ import { ctx } from '../context.js';
16
+
17
+ export function prepareBar(spec) {
18
+ let data = ensureData(spec);
19
+ let series = spec.series ?? spec.hue ?? spec.color;
20
+ let y = spec.y;
21
+ if (Array.isArray(y)) {
22
+ if (y.length > 1) {
23
+ data = melt(data, spec.x, y, '__series', '__value', spec.labelsFor || {});
24
+ series = '__series';
25
+ y = '__value';
26
+ } else y = y[0];
27
+ }
28
+ const inf = inferXY({ ...spec, y }, data);
29
+ const x = inf.x;
30
+ const counting = (spec.aggregate ?? spec.estimator) === 'count';
31
+ y = y || (counting ? null : inf.y);
32
+ requireField(data, x, 'x');
33
+ if (!counting) requireField(data, y, 'y');
34
+ if (series) requireField(data, series, 'series');
35
+ const kind = fieldKind(data, x);
36
+ // Estimador: suma por defecto; con `ci`/`errorbar` → media ± intervalo (seaborn barplot).
37
+ const errorbar = spec.errorbar ?? (spec.ci === true || typeof spec.ci === 'number' ? 'ci' : null);
38
+ const how = spec.aggregate ?? spec.estimator ?? (errorbar ? 'mean' : 'sum');
39
+ const level = typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95;
40
+ const key = (c, s) => c + '\u0000' + s;
41
+ const buckets = new Map();
42
+ for (const r of data) {
43
+ const c = r[x] === null || r[x] === undefined ? '' : String(r[x]);
44
+ const s = series ? String(r[series]) : '';
45
+ const k = key(c, s);
46
+ if (!buckets.has(k)) buckets.set(k, []);
47
+ buckets.get(k).push(how === 'count' ? 1 : num(r[y]));
48
+ }
49
+ let cats = uniq(data.map((r) => (r[x] === null || r[x] === undefined ? '' : String(r[x]))));
50
+ const seriesKeys = series ? uniq(data.map((r) => String(r[series]))) : [''];
51
+ const val = (c, s) => {
52
+ const v = buckets.get(key(c, s));
53
+ return v ? aggregate(v, how === 'count' ? 'sum' : how) : 0;
54
+ };
55
+ const values = new Map(cats.map((c) => [c, seriesKeys.map((s) => val(c, s) || 0)]));
56
+ const errs = new Map();
57
+ if (errorbar && errorbar !== 'none') for (const c of cats) errs.set(c, seriesKeys.map((s) => {
58
+ const v = (buckets.get(key(c, s)) || []).filter(Number.isFinite);
59
+ if (v.length < 2) return null;
60
+ if (errorbar === 'sd') { const mm = mean(v), d = sd(v); return [mm - d, mm + d]; }
61
+ if (errorbar === 'se') { const mm = mean(v), d = sd(v) / Math.sqrt(v.length); return [mm - d, mm + d]; }
62
+ return meanCI(v, level);
63
+ }));
64
+ const total = (c) => values.get(c).reduce((a, b) => a + b, 0);
65
+ if (kind === 'year' || kind === 'number') cats.sort((a, b) => num(a) - num(b));
66
+ const nominal = kind === 'category';
67
+ const sort = spec.sort ?? (nominal ? 'desc' : 'none');
68
+ if (sort === 'desc') cats.sort((a, b) => total(b) - total(a));
69
+ else if (sort === 'asc') cats.sort((a, b) => total(a) - total(b));
70
+ else if (sort === 'alpha') cats.sort((a, b) => a.localeCompare(b, 'es'));
71
+
72
+ // Cola larga → "Otros" (nunca 40 barras ilegibles).
73
+ const top = spec.top ?? (cats.length > 40 ? 25 : null);
74
+ let folded = 0;
75
+ if (top && cats.length > top) {
76
+ const keep = cats.slice(0, top);
77
+ const rest = cats.slice(top);
78
+ folded = rest.length;
79
+ const otherName = String(spec.locale || 'es').startsWith('en') ? 'Other' : 'Otros';
80
+ values.set(otherName, seriesKeys.map((_, i) => rest.reduce((a, c) => a + values.get(c)[i], 0)));
81
+ cats = [...keep, otherName];
82
+ }
83
+ return { data, x, y, series, seriesKeys, cats, values, kind, nominal, folded, total, errs, errorbar };
84
+ }
85
+
86
+ export function bar(spec, theme) {
87
+ const m = prepareBar(spec);
88
+ const locale = spec.locale || 'es';
89
+ const { cats, values, seriesKeys } = m;
90
+ const multi = m.series && seriesKeys.length > 1;
91
+ const stack = multi ? spec.stack ?? false : false;
92
+ const pct = stack === 'percent' || stack === 'normalize';
93
+ const k = seriesKeys.length;
94
+
95
+ // Valores apilados
96
+ const segs = new Map();
97
+ for (const c of cats) {
98
+ const vs = values.get(c);
99
+ const tot = vs.reduce((a, b) => a + Math.abs(b), 0) || 1;
100
+ let pos = 0, neg = 0;
101
+ segs.set(c, vs.map((v) => {
102
+ const vv = pct ? v / tot : v;
103
+ if (vv >= 0) { const s = { from: pos, to: pos + vv, v, vv }; pos += vv; return s; }
104
+ const s = { from: neg, to: neg + vv, v, vv }; neg += vv; return s;
105
+ }));
106
+ }
107
+ const all = stack
108
+ ? cats.flatMap((c) => segs.get(c).map((s) => s.to))
109
+ : cats.flatMap((c) => values.get(c));
110
+ if (m.errs.size) for (const e of m.errs.values()) for (const r of e) if (r) all.push(...r);
111
+ let vmin = Math.min(0, ...all), vmax = Math.max(0, ...all);
112
+ if (pct) { vmin = Math.min(0, vmin); vmax = Math.max(vmax, 1); }
113
+ const fmt = pct ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale) : makeFormat(spec.format, locale, all);
114
+
115
+ // Orientación
116
+ const frame = createFrame(spec, theme, { height: 420 });
117
+ const labelSize = 12.5;
118
+ const maxCatW = Math.max(...cats.map((c) => measure(c, labelSize)));
119
+ let horizontal = spec.horizontal ?? spec.orientation === 'horizontal';
120
+ if (spec.horizontal === undefined && !spec.orientation) {
121
+ const slot = (frame.width - frame.pad * 2) / Math.max(1, cats.length);
122
+ horizontal = m.nominal && (cats.length > 12 || maxCatW > slot - 10);
123
+ }
124
+
125
+ // Color: identidad por serie; una sola serie → un solo color (o highlight).
126
+ const cmap = colorMap(seriesKeys, theme, spec.colors);
127
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
128
+ const colorFor = (c, si) => {
129
+ if (multi) return cmap.get(seriesKeys[si]);
130
+ if (spec.colors && spec.colors[c]) return spec.colors[c];
131
+ if (hl) return hl.has(c) ? theme.categorical[0] : theme.dim;
132
+ return c === 'Otros' || c === 'Other' ? theme.other : theme.categorical[0];
133
+ };
134
+ const legend = multi && spec.legend !== false ? seriesKeys.map((s) => ({ label: s, color: cmap.get(s), shape: 'square' })) : [];
135
+
136
+ // Barras punteadas (estimaciones, cifras provisorias): relleno rayado + borde discontinuo.
137
+ const H = hatcher(theme, 'pa-b' + Math.abs(hash(String(spec.title) + theme.mode + frame.width)).toString(36));
138
+ const dm = dashedMatcher(spec, m.data, m.x, m.series);
139
+ const xNum = (c) => (m.kind === 'date' ? parseDate(c) : num(c));
140
+ const from = m.nominal ? null : fromTest(spec, xNum);
141
+ const isDashed = (c, si) => !!((dm && dm(c, multi ? seriesKeys[si] : m.series ? seriesKeys[0] : undefined)) || (from && from(xNum(c))));
142
+ const anyDashed = (dm || from) && cats.some((c) => seriesKeys.some((_, i) => isDashed(c, i)));
143
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
144
+ const dl = (c, si) => (isDashed(c, si) ? dashedLabel : '');
145
+ const paint = (c, si) => (isDashed(c, si) ? dashedAttrs(H, colorFor(c, si)) : { fill: colorFor(c, si) });
146
+ if (anyDashed && spec.legend !== false) legend.push({ label: dashedLabel, key: 'dashed', color: theme.muted, shape: 'square', dashed: true, fill: H.fill(theme.muted), stroke: H.stroke(theme.muted) });
147
+
148
+ // Etiquetas de valor
149
+ const labelMode = spec.labels ?? 'auto';
150
+ const showLabels = labelMode === true || (labelMode === 'auto' && !m.errs.size && (!multi || stack) && cats.length <= (horizontal ? 40 : 16));
151
+ const labelOf = (c) => (stack ? (pct ? '' : fmt(m.total(c))) : fmt(values.get(c)[0]));
152
+ const valueLabelW = showLabels ? Math.max(...cats.map((c) => measure(labelOf(c), SIZES.label, 600))) : 0;
153
+ const hideAxis = spec.axis === false || (showLabels && !multi && !(spec.refLines || []).length && spec.axis !== true);
154
+ const maxBar = spec.barWidth || (horizontal ? theme.marks?.barMaxH ?? 22 : theme.marks?.barMax ?? 36);
155
+
156
+ let area = frame.layout(legend);
157
+ const body = [];
158
+ const refs = (spec.refLines || []).map((r) => ({ ...r, y: r.value ?? r.y }));
159
+
160
+ if (!horizontal) {
161
+ // ── Columnas
162
+ if (spec.height === undefined && frame.height < 360) frame.setHeight(360);
163
+ const ticksCount = Math.max(3, Math.round(area.h / 64));
164
+ const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], ticksCount) } : niceDomain(vmin, vmax, ticksCount);
165
+ const dom = hideAxis && !spec.yDomain ? { min: vmin, max: vmax || 1 } : nd;
166
+ if (spec._domains) spec._domains.y = [dom.min, dom.max];
167
+ const leftM = hideAxis ? 0 : ticksWidth(linear(nd.min, nd.max, 0, 1).ticks(nd.step), fmt.tick(nd.step)) + 10;
168
+ const top = showLabels ? 20 : 6;
169
+ const inner = { x: area.x + leftM, y: area.y + top * ctx.textScale, w: area.w - leftM, h: area.h - top * ctx.textScale - 26 * ctx.textScale };
170
+ const yS = linear(dom.min, dom.max, inner.y + inner.h, inner.y);
171
+ const xb = band(cats, inner.x, inner.x + inner.w, { inner: multi && !stack ? 0.2 : 0.3, outer: 0.1 });
172
+ if (!hideAxis) body.push(gridY(yS.ticks(nd.step), yS, inner, theme, fmt.tick(nd.step)));
173
+ const y0 = yS(0);
174
+ cats.forEach((c) => {
175
+ const bx = xb(c);
176
+ const bw = xb.bandwidth;
177
+ const vs = values.get(c);
178
+ const parts = [];
179
+ const hit = el('rect', { x: bx - xb.step * 0.12, y: inner.y, width: bw + xb.step * 0.24, height: inner.h, fill: 'transparent' });
180
+ if (stack) {
181
+ const w = Math.min(maxBar * 1.4, bw);
182
+ const x0 = bx + (bw - w) / 2;
183
+ const sg = segs.get(c);
184
+ const lastPos = sg.map((s, i) => (s.vv > 0 ? i : -1)).filter((i) => i >= 0).pop();
185
+ const lastNeg = sg.map((s, i) => (s.vv < 0 ? i : -1)).filter((i) => i >= 0).pop();
186
+ sg.forEach((s, i) => {
187
+ if (!s.vv) return;
188
+ const ya = yS(s.from), yb = yS(s.to);
189
+ let top = Math.min(ya, yb), h = Math.abs(yb - ya);
190
+ const gap = s.from !== 0 ? 2 : 0;
191
+ if (s.vv > 0) h -= gap; else { top += gap; h -= gap; }
192
+ const outer = s.vv > 0 ? i === lastPos : i === lastNeg;
193
+ const d = barPath(x0, top, w, Math.max(0, h), s.vv > 0 ? 'up' : 'down', outer ? 4 : 0);
194
+ parts.push(g({ class: 'pa-m', 'data-tip': tip(c, `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}`, dl(c, i)) }, [el('path', { d, ...paint(c, i) })]));
195
+ });
196
+ if (showLabels && !pct) {
197
+ const t = m.total(c);
198
+ const ty = t >= 0 ? yS(Math.max(0, ...sg.map((s) => s.to))) - 7 : yS(Math.min(0, ...sg.map((s) => s.to))) + 15;
199
+ parts.push(text(bx + bw / 2, ty, fmt(t), { fill: theme.ink, fontSize: SIZES.label, fontWeight: 600, textAnchor: 'middle' }));
200
+ }
201
+ body.push(g({ 'data-tip': tip(c, ...sg.map((s, i) => `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}` + (isDashed(c, i) && dm ? ` (${dashedLabel})` : '')), pct ? '' : `Total: ${fmt(m.total(c))}`, from && from(xNum(c)) ? dashedLabel : '') }, [hit, ...parts]));
202
+ } else {
203
+ const w = Math.min(maxBar, (bw - 2 * (k - 1)) / k);
204
+ const groupW = w * k + 2 * (k - 1);
205
+ const x0 = bx + (bw - groupW) / 2;
206
+ vs.forEach((v, i) => {
207
+ const bxI = x0 + i * (w + 2);
208
+ const yv = yS(v);
209
+ const top = Math.min(yv, y0), h = Math.abs(yv - y0);
210
+ const d = barPath(bxI, top, w, h, v >= 0 ? 'up' : 'down');
211
+ const er0 = m.errs.get(c)?.[i];
212
+ const label = (multi ? `${seriesKeys[i]}: ${fmt(v)}` : fmt(v)) + (er0 ? ` (IC ${fmt(er0[0])}–${fmt(er0[1])})` : '');
213
+ parts.push(g({ class: 'pa-m', 'data-tip': tip(c, label, dl(c, i)) }, [
214
+ el('rect', { x: bxI - 1, y: inner.y, width: w + 2, height: inner.h, fill: 'transparent' }),
215
+ el('path', { d, ...paint(c, i) }),
216
+ errBar(m.errs.get(c)?.[i], (e) => [bxI + w / 2, yS(e)], true, theme),
217
+ ]));
218
+ if (showLabels && !multi) {
219
+ const strong = !hl || hl.has(c);
220
+ parts.push(text(bxI + w / 2, v >= 0 ? yv - 7 : yv + 15, fmt(v), { fill: strong ? theme.ink : theme.muted, fontSize: SIZES.label, fontWeight: strong ? 600 : 400, textAnchor: 'middle' }));
221
+ }
222
+ });
223
+ body.push(g({}, [hit, ...parts]));
224
+ }
225
+ });
226
+ // Línea base
227
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(y0) + 0.5, y2: Math.round(y0) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
228
+ // Categorías
229
+ const items = cats.map((c) => ({ pos: xb.center(c), label: m.nominal ? truncate(c, Math.max(xb.step - 6, 40), 12) : c }));
230
+ body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18, size: 12, color: theme.lede }));
231
+ body.push(refLinesY(refs, yS, inner, theme, fmt));
232
+ } else {
233
+ // ── Barras horizontales
234
+ const step = Math.max(multi && !stack ? Math.min(maxBar, 16) * k + 2 * (k - 1) + 10 : maxBar + 9, 12.5 * ctx.textScale * 1.45);
235
+ const needH = cats.length * step + 30;
236
+ if (spec.height === undefined) {
237
+ frame.setHeight(area.y + needH + frame.chrome);
238
+ area = { ...area, h: frame.bottom - area.y };
239
+ }
240
+ const catW = Math.min(maxCatW, area.w * 0.38) + 12;
241
+ const rightM = showLabels ? valueLabelW + 10 : 8;
242
+ const hasNeg = vmin < 0;
243
+ const inner = { x: area.x + catW, y: area.y + 4, w: area.w - catW - rightM - (hasNeg && showLabels ? valueLabelW + 10 : 0), h: area.h - (hideAxis ? 8 : 28) };
244
+ if (hasNeg && showLabels) inner.x += valueLabelW + 10;
245
+ const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], Math.max(3, Math.round(inner.w / 110))) } : niceDomain(vmin, vmax, Math.max(3, Math.round(inner.w / 110)));
246
+ const dom = hideAxis && !spec.yDomain ? { min: vmin, max: vmax || 1 } : nd;
247
+ if (spec._domains) spec._domains.y = [dom.min, dom.max];
248
+ const xS = linear(dom.min, dom.max, inner.x, inner.x + inner.w);
249
+ const yb = band(cats, inner.y, inner.y + inner.h, { inner: 0.32, outer: 0.1 });
250
+ if (!hideAxis) body.push(gridX(xS.ticks(nd.step), xS, inner, theme, fmt.tick(nd.step)));
251
+ const x0 = xS(0);
252
+ cats.forEach((c) => {
253
+ const by = yb(c);
254
+ const bh = yb.bandwidth;
255
+ const vs = values.get(c);
256
+ const parts = [];
257
+ const strong = !hl || hl.has(c);
258
+ parts.push(el('rect', { x: area.x, y: by - yb.step * 0.16, width: area.w, height: yb.step, fill: 'transparent' }));
259
+ parts.push(text(inner.x - 10 - (hasNeg && showLabels ? valueLabelW + 10 : 0), by + bh / 2 + 4.5, truncate(c, catW - 12, labelSize), { fill: strong ? theme.ink : theme.muted, fontSize: labelSize, fontWeight: hl && strong ? 600 : 400, textAnchor: 'end' }));
260
+ if (stack) {
261
+ const h = Math.min(maxBar, bh);
262
+ const y0b = by + (bh - h) / 2;
263
+ const sg = segs.get(c);
264
+ const lastPos = sg.map((s, i) => (s.vv > 0 ? i : -1)).filter((i) => i >= 0).pop();
265
+ sg.forEach((s, i) => {
266
+ if (!s.vv) return;
267
+ const xa = xS(s.from), xb2 = xS(s.to);
268
+ let left = Math.min(xa, xb2), w = Math.abs(xb2 - xa);
269
+ if (s.from !== 0) { if (s.vv > 0) left += 2; w -= 2; }
270
+ const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || s.vv < 0 ? 4 : 0);
271
+ const fill = colorFor(c, i);
272
+ const lab = pct ? fmt(s.vv) : '';
273
+ const fits = lab && measure(lab, 11, 600) + 10 < w;
274
+ const hatched = isDashed(c, i);
275
+ parts.push(g({ class: 'pa-m', 'data-tip': tip(c, `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}`, dl(c, i)) }, [
276
+ el('path', { d, ...paint(c, i) }),
277
+ fits ? text(left + 6, y0b + h / 2 + 4, lab, { fill: hatched ? theme.ink : textColor(fill, theme), fontSize: 11, fontWeight: 600, ...(hatched ? { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke' } : {}) }) : '',
278
+ ]));
279
+ });
280
+ if (showLabels && !pct) parts.push(text(xS(Math.max(0, ...sg.map((s) => s.to))) + 7, by + bh / 2 + 4.5, fmt(m.total(c)), { fill: theme.ink, fontSize: SIZES.label, fontWeight: 600 }));
281
+ body.push(g({ 'data-tip': tip(c, ...sg.map((s, i) => `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}` + (isDashed(c, i) ? ` (${dashedLabel})` : ''))) }, parts));
282
+ } else {
283
+ const h = Math.min(multi ? 16 : maxBar, (bh - 2 * (k - 1)) / k);
284
+ const groupH = h * k + 2 * (k - 1);
285
+ const y0b = by + (bh - groupH) / 2;
286
+ vs.forEach((v, i) => {
287
+ const yy = y0b + i * (h + 2);
288
+ const xv = xS(v);
289
+ const d = barPath(Math.min(xv, x0), yy, Math.abs(xv - x0), h, v >= 0 ? 'right' : 'left');
290
+ const er = m.errs.get(c)?.[i];
291
+ const erTxt = er ? ` (IC ${fmt(er[0])}–${fmt(er[1])})` : '';
292
+ parts.push(g({ class: 'pa-m', 'data-tip': tip(c, (multi ? `${seriesKeys[i]}: ` : '') + fmt(v) + erTxt, dl(c, i)) }, [el('path', { d, ...paint(c, i) }), errBar(er, (e) => [xS(e), yy + h / 2], false, theme)]));
293
+ if (showLabels && !multi) {
294
+ parts.push(text(v >= 0 ? xv + 7 : xv - 7, yy + h / 2 + 4.5, fmt(v), { fill: strong ? theme.ink : theme.muted, fontSize: SIZES.label, fontWeight: strong ? 600 : 400, textAnchor: v >= 0 ? 'start' : 'end' }));
295
+ }
296
+ });
297
+ body.push(g({}, parts));
298
+ }
299
+ });
300
+ body.push(el('line', { x1: Math.round(x0) + 0.5, x2: Math.round(x0) + 0.5, y1: inner.y, y2: inner.y + inner.h, stroke: theme.axis, strokeWidth: 1 }));
301
+ body.push(refLinesX(refs.map((r) => ({ ...r, x: r.y, y: undefined })), xS, inner, theme, fmt));
302
+ }
303
+
304
+ const desc = describeBar(m, fmt, locale);
305
+ return frame.finish(body, { desc, type: 'bar', defs: H.defs() });
306
+ }
307
+
308
+ /** Barra de error fina con remates cortos (seaborn). */
309
+ function errBar(e, P, vertical, theme) {
310
+ if (!e) return '';
311
+ const [ax, ay] = P(e[0]), [bx, by] = P(e[1]);
312
+ const c = 5;
313
+ const d = vertical
314
+ ? `M${ax},${ay}V${by}M${ax - c},${ay}H${ax + c}M${bx - c},${by}H${bx + c}`
315
+ : `M${ax},${ay}H${bx}M${ax},${ay - c}V${ay + c}M${bx},${by - c}V${by + c}`;
316
+ return el('path', { d, stroke: theme.ink, strokeWidth: 1.5, fill: 'none', strokeLinecap: 'round', opacity: 0.85 });
317
+ }
318
+
319
+ function textColor(fill, theme) {
320
+ // Blanco sobre colores oscuros; tinta sobre ámbar/cielo/oliva.
321
+ const [r, gg, b] = fill.replace('#', '').match(/../g).map((h) => parseInt(h, 16));
322
+ const l = 0.299 * r + 0.587 * gg + 0.114 * b;
323
+ return l > 150 ? (theme.mode === 'dark' ? '#0B0F2B' : theme.ink) : '#FFFFFF';
324
+ }
325
+
326
+ export function describeBar(m, fmt, locale = 'es') {
327
+ const en = String(locale).startsWith('en');
328
+ const totals = m.cats.map((c) => [c, m.total(c)]);
329
+ if (!totals.length) return '';
330
+ const sorted = [...totals].sort((a, b) => b[1] - a[1]);
331
+ const sum = totals.reduce((a, [, v]) => a + v, 0);
332
+ const [hiC, hiV] = sorted[0];
333
+ const [loC, loV] = sorted[sorted.length - 1];
334
+ const share = sum > 0 ? ` (${fmt.type === 'percent' ? '' : Math.round((hiV / sum) * 100) + '%'})`.replace(' ()', '') : '';
335
+ const what = humanize(m.y === '__value' ? '' : m.y) || (en ? 'Value' : 'Valor');
336
+ return en
337
+ ? `Bar chart of ${what} across ${m.cats.length} categories. Highest: ${hiC} with ${fmt(hiV)}${share}. Lowest: ${loC} with ${fmt(loV)}.`
338
+ : `Gráfico de barras de ${what.toLowerCase()} en ${m.cats.length} categorías. Mayor: ${hiC} con ${fmt(hiV)}${share}. Menor: ${loC} con ${fmt(loV)}.`;
339
+ }