plotagents 0.5.0 → 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 (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
@@ -3,7 +3,7 @@
3
3
  import { el, g, text, tip } from '../svg.js';
4
4
  import { measure, truncate } from '../text.js';
5
5
  import { makeFormat } from '../format.js';
6
- import { ensureData, requireField, num, fieldsOf, fieldKind } from '../data.js';
6
+ import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
7
7
  import { createFrame } from '../frame.js';
8
8
 
9
9
  export function sankey(spec, theme) {
@@ -16,24 +16,39 @@ export function sankey(spec, theme) {
16
16
  requireField(data, to, 'to');
17
17
  requireField(data, valueF, 'value');
18
18
  const locale = spec.locale || 'es';
19
+ const en = String(locale).startsWith('en');
19
20
 
20
- // Enlaces agregados
21
+ // Enlaces agregados (valores no finitos o ≤ 0 se ignoran).
21
22
  const lmap = new Map();
22
23
  for (const r of data) {
23
24
  const v = num(r[valueF]);
24
- if (!(v > 0)) continue;
25
+ if (!(v > 0) || !Number.isFinite(v)) continue;
25
26
  const s = String(r[from]), t = String(r[to]);
26
27
  if (s === t) continue;
27
28
  const k = s + '\u0000' + t;
28
29
  lmap.set(k, (lmap.get(k) || 0) + v);
29
30
  }
30
31
  const links = [...lmap.entries()].map(([k, v]) => { const [s, t] = k.split('\u0000'); return { s, t, v }; });
32
+ if (!links.length) {
33
+ throw en
34
+ ? new PlotError('The sankey has no positive flows between different nodes.', `Check \`${valueF}\`: it needs values > 0, and \`${from}\` must differ from \`${to}\` (self-loops are dropped).`)
35
+ : new PlotError('El sankey no tiene flujos positivos entre nodos distintos.', `Revisa \`${valueF}\`: necesita valores > 0, y \`${from}\` debe ser distinto de \`${to}\` (los bucles se omiten).`);
36
+ }
31
37
  const names = [...new Set(links.flatMap((l) => [l.s, l.t]))];
32
38
  const nodes = new Map(names.map((n) => [n, { name: n, in: [], out: [], depth: 0 }]));
33
39
  for (const l of links) { l.sn = nodes.get(l.s); l.tn = nodes.get(l.t); l.sn.out.push(l); l.tn.in.push(l); }
34
40
  for (const n of nodes.values()) n.value = Math.max(sum(n.in), sum(n.out));
35
41
 
36
- // Profundidad = camino más largo desde un origen (con tope por si hay ciclos).
42
+ // Un sankey fluye en una dirección: un ciclo (a → b → a) no tiene etapas.
43
+ const cycle = findCycle(nodes);
44
+ if (cycle) {
45
+ const path = cycle.join(' → ');
46
+ throw en
47
+ ? new PlotError(`The sankey has a cycle (${path}): flows must go one way, stage by stage.`, 'Name each stage apart ("Chile (origin)" → "Chile (destination)"), drop the return flows, or use `type: "chord"` for two-way exchanges.')
48
+ : new PlotError(`El sankey tiene un ciclo (${path}): los flujos deben ir en una sola dirección, etapa por etapa.`, 'Distingue las etapas ("Chile (origen)" → "Chile (destino)"), quita los flujos de vuelta o usa `type: "chord"` para intercambios en ambos sentidos.');
49
+ }
50
+
51
+ // Profundidad = camino más largo desde un origen.
37
52
  for (let it = 0; it < names.length; it++) {
38
53
  let changed = false;
39
54
  for (const l of links) if (l.tn.depth < l.sn.depth + 1) { l.tn.depth = l.sn.depth + 1; changed = true; }
@@ -124,10 +139,31 @@ export function sankey(spec, theme) {
124
139
  }
125
140
  body.push(g({ 'data-tip': `${n.name}\n${fmt(n.value)}` }, parts));
126
141
  }
127
- const en = String(locale).startsWith('en');
128
142
  const top = [...links].sort((a, b) => b.v - a.v).slice(0, 3).map((l) => `${l.s} → ${l.t} (${fmt(l.v)})`).join('; ');
129
143
  const desc = en ? `Sankey diagram with ${nodes.size} nodes and ${links.length} flows. Largest flows: ${top}.` : `Diagrama de Sankey con ${nodes.size} nodos y ${links.length} flujos. Mayores flujos: ${top}.`;
130
144
  return frame.finish(body, { desc, type: 'sankey' });
131
145
  }
132
146
 
133
147
  const sum = (ls) => ls.reduce((a, l) => a + l.v, 0);
148
+
149
+ /** Primer ciclo encontrado (lista de nombres, cerrada) o null. DFS iterativo. */
150
+ function findCycle(nodes) {
151
+ const state = new Map(); // 1 = en la pila, 2 = terminado
152
+ for (const start of nodes.values()) {
153
+ if (state.has(start)) continue;
154
+ const stack = [[start, 0]];
155
+ const trail = [start];
156
+ state.set(start, 1);
157
+ while (stack.length) {
158
+ const top = stack[stack.length - 1];
159
+ const [n, i] = top;
160
+ if (i < n.out.length) {
161
+ top[1]++;
162
+ const m = n.out[i].tn;
163
+ if (state.get(m) === 1) return [...trail.slice(trail.indexOf(m)), m].map((x) => x.name);
164
+ if (!state.has(m)) { state.set(m, 1); stack.push([m, 0]); trail.push(m); }
165
+ } else { state.set(n, 2); stack.pop(); trail.pop(); }
166
+ }
167
+ }
168
+ return null;
169
+ }
@@ -5,28 +5,47 @@ import { measure } from '../text.js';
5
5
  import { makeFormat } from '../format.js';
6
6
  import { niceDomain, niceStep, linear, log, logDomain } from '../scale.js';
7
7
  import { colorMap } from '../theme.js';
8
- import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize } from '../data.js';
9
- import { gridY, gridX, ticksWidth, refLinesY, refLinesX, yAxisMargin, yAxisTop, yGrid, yTickLabels, quietCorner } from '../axes.js';
10
- import { createFrame, SIZES } from '../frame.js';
8
+ import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
9
+ import { gridY, gridX, ticksWidth, refLinesY, refLinesX, yAxisMargin, yAxisTop, yGrid, yTickLabels, quietCorner, isInside } from '../axes.js';
10
+ import { createFrame, SIZES, hash } from '../frame.js';
11
+ import { extent } from '../stats.js';
12
+ import { ctx } from '../context.js';
13
+
14
+ /** Muestra pareja de ~n filas (inferir tipos de campo sin recorrer 50 mil filas). */
15
+ const sampleRows = (data, n = 200) => (data.length <= n ? data : Array.from({ length: n }, (_, i) => data[Math.floor((i * (data.length - 1)) / (n - 1))]));
11
16
 
12
17
  export function scatter(spec, theme) {
13
18
  const data = ensureData(spec);
14
19
  const fields = fieldsOf(data);
15
- const numeric = fields.filter((f) => fieldKind(data, f) === 'number' || fieldKind(data, f) === 'year');
20
+ const sample = sampleRows(data);
21
+ const kinds = new Map();
22
+ const kindOf = (f) => { if (!kinds.has(f)) kinds.set(f, fieldKind(sample, f)); return kinds.get(f); };
23
+ const numeric = spec.x && spec.y ? [] : fields.filter((f) => kindOf(f) === 'number' || kindOf(f) === 'year');
16
24
  const x = spec.x || numeric[0];
17
25
  const y = spec.y || numeric.find((f) => f !== x);
18
26
  const size = spec.size;
19
27
  const series = spec.series ?? spec.hue ?? spec.color;
20
- const labelF = spec.label ?? fields.find((f) => fieldKind(data, f) === 'category' && f !== series);
28
+ const labelF = spec.label ?? fields.find((f) => kindOf(f) === 'category' && f !== series);
21
29
  requireField(data, x, 'x');
22
30
  requireField(data, y, 'y');
23
31
  if (size) requireField(data, size, 'size');
24
32
  if (series) requireField(data, series, 'series');
25
33
  const locale = spec.locale || 'es';
34
+ const en = String(locale).startsWith('en');
35
+ const xLog = spec.xScale === 'log', yLog = spec.yScale === 'log';
26
36
 
37
+ // En escala log, los valores ≤ 0 no tienen posición: se omiten.
27
38
  const pts = data
28
39
  .map((r) => ({ r, x: num(r[x]), y: num(r[y]), s: size ? num(r[size]) : 1, k: series ? String(r[series]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
29
- .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
40
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!xLog || p.x > 0) && (!yLog || p.y > 0));
41
+ if (!pts.length) {
42
+ const which = !data.some((r) => Number.isFinite(num(r[x]))) ? x : y;
43
+ const logNote = xLog || yLog ? (en ? ' (on a log scale only values > 0 count)' : ' (en escala log solo cuentan valores > 0)') : '';
44
+ throw new PlotError(
45
+ en ? `No points to plot: \`${which}\` has no numeric values${logNote}.` : `No hay puntos para graficar: \`${which}\` no tiene valores numéricos${logNote}.`,
46
+ en ? `Use numeric fields for x and y, e.g. x: "gdp", y: "life_expectancy". Fields: ${fields.join(', ')}.` : `Usa campos numéricos en x e y, p. ej. x: "pib", y: "esperanza_vida". Campos: ${fields.join(', ')}.`
47
+ );
48
+ }
30
49
  const keys = series ? uniq(pts.map((p) => p.k)) : [''];
31
50
  const cmap = colorMap(keys, theme, spec.colors);
32
51
  const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
@@ -41,27 +60,43 @@ export function scatter(spec, theme) {
41
60
  const frame = createFrame(spec, theme, { height: 460 });
42
61
  const area = frame.layout(legend);
43
62
 
44
- const xLog = spec.xScale === 'log', yLog = spec.yScale === 'log';
45
- const ex = [Math.min(...pts.map((p) => p.x)), Math.max(...pts.map((p) => p.x))];
46
- const ey = [Math.min(...pts.map((p) => p.y)), Math.max(...pts.map((p) => p.y))];
63
+ const ex = extent(pts.map((p) => p.x));
64
+ const ey = extent(pts.map((p) => p.y));
47
65
  if (spec.xDomain) ex.splice(0, 2, ...spec.xDomain);
48
66
  if (spec.yDomain) ey.splice(0, 2, ...spec.yDomain);
49
67
  const dx = xLog ? logDomain(...ex) : spec.xDomain ? { min: ex[0], max: ex[1], step: niceStep(ex[1] - ex[0], Math.max(3, Math.round(area.w / 110))) } : niceDomain(ex[0], ex[1], Math.max(3, Math.round(area.w / 110)));
50
68
  const dy = yLog ? logDomain(...ey) : spec.yDomain ? { min: ey[0], max: ey[1], step: niceStep(ey[1] - ey[0], Math.max(3, Math.round(area.h / 70))) } : niceDomain(ey[0], ey[1], Math.max(3, Math.round(area.h / 70)));
51
69
  if (spec._domains) { spec._domains.x = [dx.min, dx.max]; spec._domains.y = [dy.min, dy.max]; }
52
- const ytf = yLog ? fy : fy.tick(dy.step);
53
- const xtf = xLog ? fx : fx.tick(dx.step);
54
70
  const yTicks = (yLog ? log(dy.min, dy.max, 0, 1) : linear(dy.min, dy.max, 0, 1)).ticks(dy.step);
71
+ const ytf = yLog ? fy : fy.tick(dy.step, yTicks);
72
+ const xtf = xLog ? fx : fx.tick(dx.step, linear(dx.min, dx.max, 0, 1).ticks(dx.step));
55
73
  const leftM = yAxisMargin(yTicks, ytf, theme);
56
74
  const top = area.y + 18 + yAxisTop(theme);
57
75
  const inner = { x: area.x + leftM, y: top, w: area.w - leftM - 8, h: area.y + area.h - top - 44 };
58
- const maxS = Math.max(...pts.map((p) => p.s));
76
+ const maxS = extent(pts.map((p) => p.s))[1];
59
77
  const rMax = spec.maxRadius ?? Math.min(30, Math.max(12, Math.sqrt((inner.w * inner.h) / Math.max(1, pts.length)) / 3));
60
78
  // Las burbujas no se salen del área: margen interno del radio máximo.
61
79
  const bp = size ? rMax * 0.75 : 6;
62
- const xS = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp, inner.x + inner.w - bp);
63
80
  const yS = (yLog ? log : linear)(dy.min, dy.max, inner.y + inner.h - bp, inner.y + bp);
64
81
  const rOf = (p) => (size ? Math.max(2.5, Math.sqrt(Math.max(0, p.s) / (maxS || 1)) * rMax) : spec.radius ?? (pts.length > 300 ? 3 : 5));
82
+ // Eje y por dentro: si un punto pegado a la izquierda choca con una etiqueta
83
+ // de marca, el rango x arranca pasado la etiqueta más ancha.
84
+ let xPad = 0;
85
+ if (isInside(theme, undefined) && !spec.xDomain) {
86
+ const axisSize = SIZES.axis;
87
+ const kk = 5 * Math.max(1, ctx.textScale * 0.8);
88
+ const yT = yS.ticks(dy.step);
89
+ const boxes = yT.map((t) => { const base = Math.round(yS(t)) + 0.5 - kk; return { w: ticksWidth([t], ytf, theme.type?.axis ?? axisSize), y0: base - (theme.type?.axis ?? axisSize) * 0.75, y1: base + 1 }; });
90
+ const xS0 = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp, inner.x + inner.w - bp);
91
+ const wMax = Math.max(0, ...boxes.map((b) => b.w));
92
+ for (const p of pts) {
93
+ const r = rOf(p), px = xS0(p.x);
94
+ if (px - r > inner.x + wMax + 1) continue;
95
+ const py = yS(p.y);
96
+ if (boxes.some((b) => px - r < inner.x + b.w + 1 && py + r > b.y0 && py - r < b.y1)) { xPad = Math.min(Math.max(0, wMax + 6 - bp + r), inner.w * 0.2); if (xPad >= inner.w * 0.2) break; }
97
+ }
98
+ }
99
+ const xS = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp + xPad, inner.x + inner.w - bp);
65
100
 
66
101
  const body = [];
67
102
  body.push(yGrid(yS.ticks(dy.step), yS, inner, theme, { baseline: NaN }));
@@ -71,6 +106,7 @@ export function scatter(spec, theme) {
71
106
  body.push(text(area.x, area.y + 10, spec.yLabel ?? humanize(y), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500 }));
72
107
  body.push(text(inner.x + inner.w, inner.y + inner.h + 40, spec.xLabel ?? humanize(x), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500, textAnchor: 'end' }));
73
108
 
109
+ const taken = []; // zonas ocupadas por rótulos (R²), para la clave de tamaños
74
110
  // Tendencia lineal (mínimos cuadrados)
75
111
  if (spec.trend) {
76
112
  const tx = pts.map((p) => (xLog ? Math.log10(p.x) : p.x)), ty = pts.map((p) => (yLog ? Math.log10(p.y) : p.y));
@@ -82,12 +118,16 @@ export function scatter(spec, theme) {
82
118
  const lx0 = xLog ? Math.log10(dx.min) : dx.min, lx1 = xLog ? Math.log10(dx.max) : dx.max;
83
119
  const P = (lx) => [xS(xLog ? 10 ** lx : lx), yS(yLog ? 10 ** (a + b * lx) : a + b * lx)];
84
120
  const [ax, ay] = P(lx0), [bx, by] = P(lx1);
85
- body.push(el('clipPath', { id: 'pa-clip-trend' }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
86
- body.push(el('line', { x1: ax, y1: ay, x2: bx, y2: by, stroke: theme.ink, strokeWidth: 1.5, opacity: 0.55, clipPath: 'url(#pa-clip-trend)' }));
121
+ // id único por gráfico: varios SVG en una página no comparten el recorte.
122
+ const clipId = 'pa-clip-trend-' + Math.abs(hash(String(spec.title) + x + y + inner.w + pts.length)).toString(36);
123
+ body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
124
+ body.push(el('line', { x1: ax, y1: ay, x2: bx, y2: by, stroke: theme.ink, strokeWidth: 1.5, opacity: 0.55, clipPath: `url(#${clipId})` }));
87
125
  const r2s = `R² = ${r2v.toFixed(2).replace('.', String(locale).startsWith('en') ? '.' : ',')}`;
88
126
  const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
89
127
  for (let i = 0; i <= 24; i++) occ.push([ax + ((bx - ax) * i) / 24, ay + ((by - ay) * i) / 24]);
90
128
  const qc = quietCorner(occ, inner, measure(r2s, 11.5) + 4, 16);
129
+ const r2w = measure(r2s, 11.5) + 4, r2x0 = qc.anchor === 'end' ? qc.x - r2w : qc.x;
130
+ for (let i = 0; i <= 8; i++) for (const dy of [-12, -6, 0]) taken.push([r2x0 + (r2w * i) / 8, qc.y + dy]);
91
131
  body.push(text(qc.x, qc.y, r2s, { fill: theme.lede, fontSize: 11.5, textAnchor: qc.anchor, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke' }));
92
132
  }
93
133
  body.push(refLinesY(spec.refLines || [], yS, inner, theme, fy));
@@ -95,13 +135,52 @@ export function scatter(spec, theme) {
95
135
 
96
136
  // Burbujas grandes debajo, chicas arriba; anillo del color de fondo.
97
137
  const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
98
- const circles = sorted.map((p) => {
99
- const lines = [p.name || '', `${humanize(x)}: ${fx(p.x)}`, `${humanize(y)}: ${fy(p.y)}`];
100
- if (size) lines.push(`${humanize(size)}: ${fs(p.s)}`);
101
- if (series) lines.splice(1, 0, p.k);
102
- return { class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r: rOf(p), fill: colorOf(p), fillOpacity: size ? 0.78 : 0.9, stroke: theme.surface, strokeWidth: size ? 1.5 : pts.length > 400 ? 1 : 2, 'data-tip': tip(...lines) };
138
+ // Tooltips: la capa densa guarda a lo más MAX_TIPS; no se arman los que se descartan.
139
+ const MAX_TIPS = 20000;
140
+ const hx = humanize(x), hy = humanize(y), hs = size ? humanize(size) : '';
141
+ const circles = sorted.map((p, i) => {
142
+ let t = '';
143
+ if (i < MAX_TIPS || sorted.length <= 1500) {
144
+ const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
145
+ if (size) lines.push(`${hs}: ${fs(p.s)}`);
146
+ if (series) lines.splice(1, 0, p.k);
147
+ t = tip(...lines);
148
+ }
149
+ return { class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r: rOf(p), fill: colorOf(p), fillOpacity: size ? 0.78 : 0.9, stroke: theme.surface, strokeWidth: size ? 1.5 : pts.length > 400 ? 1 : 2, 'data-tip': t };
103
150
  });
104
- body.push(dotLayer(circles));
151
+ body.push(dotLayer(circles, { maxTips: MAX_TIPS }));
152
+
153
+ // Clave de tamaños (burbujas): 2–3 círculos de referencia anidados en una esquina libre.
154
+ if (size && spec.sizeKey !== false && maxS > 0) {
155
+ const nice = (v) => { const e = 10 ** Math.floor(Math.log10(v)); const m = v / e; return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * e; };
156
+ const v1 = nice(maxS);
157
+ const vals = [v1, nice(v1 / 4), nice(v1 / 16)].filter((v, i, a) => a.indexOf(v) === i && Math.sqrt(v / maxS) * rMax >= 4);
158
+ if (vals.length >= 2) {
159
+ const radii = vals.map((v) => Math.sqrt(v / maxS) * rMax);
160
+ const R = radii[0];
161
+ const labs = vals.map((v) => fs(v));
162
+ const lw = Math.max(...labs.map((l) => measure(l, 10.5)));
163
+ const kw = 2 * R + 8 + lw, kh = 2 * R + 16;
164
+ const occ = sorted.map((p) => [xS(p.x), yS(p.y)]);
165
+ // Lo ya ocupado (R²) pesa como muchos puntos: la clave nunca lo tapa.
166
+ for (const q of taken) for (let i = 0; i < 50; i++) occ.push(q);
167
+ const qc = quietCorner(occ, inner, kw, kh);
168
+ const left = qc.anchor === 'end' ? qc.x - kw : qc.x;
169
+ const y0 = qc.y - kh + 3; // borde superior de la caja elegida
170
+ const cx = left + R, cy0 = y0 + kh - 1;
171
+ const parts = [text(left, y0 + 10, hs, { fill: theme.muted, fontSize: 10.5, fontWeight: 500 })];
172
+ let prevY = -Infinity;
173
+ radii.forEach((r, i) => {
174
+ const top = cy0 - 2 * r;
175
+ const ly = Math.max(top, prevY + 11); // rótulos separados aunque los círculos se acerquen
176
+ prevY = ly;
177
+ parts.push(el('circle', { cx, cy: cy0 - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
178
+ parts.push(el('path', { d: `M${cx.toFixed(1)},${top.toFixed(1)}H${(cx + R + 1).toFixed(1)}L${(cx + R + 4).toFixed(1)},${ly.toFixed(1)}`, fill: 'none', stroke: theme.muted, strokeWidth: 0.75, opacity: 0.6 }));
179
+ parts.push(text(cx + R + 6, ly + 3.5, labs[i], { fill: theme.muted, fontSize: 10.5 }));
180
+ });
181
+ body.push(g({ class: 'pa-sizekey' }, parts));
182
+ }
183
+ }
105
184
 
106
185
  body.push(yTickLabels(yS.ticks(dy.step), yS, inner, theme, ytf));
107
186
 
@@ -128,7 +207,6 @@ export function scatter(spec, theme) {
128
207
  }
129
208
  }
130
209
 
131
- const en = String(locale).startsWith('en');
132
210
  const desc = en
133
211
  ? `Scatter plot of ${humanize(y)} vs ${humanize(x)} with ${pts.length} points.`
134
212
  : `Gráfico de dispersión de ${humanize(y)} versus ${humanize(x)} con ${pts.length} puntos.`;
@@ -34,9 +34,10 @@ export function sunburst(spec, theme) {
34
34
  const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
35
35
  const dim = (n) => hl && !n.path.some((p) => hl.has(p));
36
36
 
37
- const top = focus.children;
38
- const legend = top.length > 1 && spec.legend !== false && !focus.path.length
39
- ? [...top.filter((c) => !c.folded).map((c) => ({ label: c.name, color: c.color, shape: 'square' })), ...(top.some((c) => c.folded) ? [{ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' }] : [])]
37
+ const top = root.children;
38
+ const active = focus.path[0];
39
+ const legend = top.length > 1 && spec.legend !== false
40
+ ? [...top.filter((c) => !c.folded).map((c) => ({ label: c.name, value: c.name, color: c.color, shape: 'square', active: active === undefined ? undefined : active === c.name })), ...(top.some((c) => c.folded) ? [{ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' }] : [])]
40
41
  : [];
41
42
 
42
43
  const frame = createFrame(spec, theme, { height: (spec.width || 720) < 560 ? Math.round((spec.width || 720) + 190) : Math.min(560, Math.max(380, (spec.width || 720) * 0.7)) });
@@ -126,7 +127,7 @@ export function sunburst(spec, theme) {
126
127
  measure(small, 11.5) <= r0 * 1.7 ? text(cx, cy + bigSize * 0.3 + 16, small, { fill: theme.lede, fontSize: 11.5, textAnchor: 'middle' }) : '',
127
128
  ]));
128
129
 
129
- const top3 = top.slice(0, 3).map((c) => `${c.name} ${share(c.value, total)}`).join(', ');
130
+ const top3 = focus.children.slice(0, 3).map((c) => `${c.name} ${share(c.value, total)}`).join(', ');
130
131
  const what = humanize(valueField);
131
132
  const desc = en
132
133
  ? `Sunburst of ${what} in ${fields.length} levels, total ${fmt(total)}. Largest groups: ${top3}.`
@@ -85,7 +85,8 @@ export function treemap(spec, theme) {
85
85
  if (byChange) (function walk(n) { if (!n.children.length) { const c = changeOf(n); if (c !== null) cMax = Math.max(cMax, Math.abs(c)); } n.children.forEach(walk); })(focus);
86
86
  cMax = spec.changeDomain ?? (Math.ceil(Math.min(1, cMax || 0.1) * 20) / 20);
87
87
  // Crecimiento en el tono frío, caída en el cálido; gris neutro sin cambio.
88
- const divRamp = [...theme.diverging].reverse();
88
+ // Con una colormap propia (cmap) se respeta su orientación: bajo → alto.
89
+ const divRamp = theme.cmap ? [...theme.diverging] : [...theme.diverging].reverse();
89
90
  const changeColor = (c) => (c === null ? theme.tint : ramp(divRamp, 0.5 + Math.max(-1, Math.min(1, c / cMax)) / 2));
90
91
 
91
92
  // Leyenda del primer nivel visible, con su participación: la leyenda también informa.
@@ -93,12 +94,14 @@ export function treemap(spec, theme) {
93
94
  let legend = [];
94
95
  if (byChange && spec.legend !== false) {
95
96
  const lab = (v) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${formatNumber(Math.abs(v) * 100, 0, locale)}%`;
96
- legend = [{ type: 'ramp', stops: [...theme.diverging].reverse(), min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
97
- } else if (levels > 1 && top.length > 1 && spec.legend !== false && !focus.path.length) {
98
- const shown = top.filter((c) => !c.folded);
99
- const sh0 = shareFmt(locale);
100
- legend = shown.map((c) => ({ label: `${c.name} ${sh0(c.value, focus.value)}`, color: c.color, shape: 'square' }));
101
- if (top.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
97
+ legend = [{ type: 'ramp', stops: divRamp, min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
98
+ } else if (height(root) > 1 && root.children.length > 1 && spec.legend !== false) {
99
+ // Leyenda del primer nivel, también al entrar a un grupo: clic = ver solo
100
+ // esa categoría en todo el plano; shift + clic = quitarla (en el navegador).
101
+ const tops = root.children;
102
+ const active = focus.path[0];
103
+ legend = tops.filter((c) => !c.folded).map((c) => ({ label: `${c.name} ${share(c.value, root.value)}`, value: c.name, color: c.color, shape: 'square', active: active === undefined ? undefined : active === c.name }));
104
+ if (tops.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
102
105
  }
103
106
 
104
107
  // En pantallas angostas el treemap crece hacia abajo: rectángulos legibles.
@@ -0,0 +1,163 @@
1
+ // Nube de palabras, hecha con cuidado.
2
+ //
3
+ // plot({ type: 'wordcloud', data, text: 'palabra', value: 'menciones' })
4
+ // plot({ type: 'wordcloud', text: 'respuestas abiertas de una encuesta…' })
5
+ //
6
+ // Decisiones de diseño (lo que suele salir mal en una nube):
7
+ // · Todas las palabras horizontales: girarlas obliga a torcer el cuello.
8
+ // · El tamaño de letra crece con la raíz de la frecuencia, para que el ÁREA
9
+ // de la palabra (no su altura) sea proporcional al conteo.
10
+ // · Colocación determinista en espiral desde el centro, la más frecuente
11
+ // primero; sin choques, con aire entre palabras. Mismo spec, misma nube.
12
+ // · Color con significado: por grupo (`series`) con leyenda, o un solo tono
13
+ // que se oscurece con la frecuencia. Nunca colores al azar.
14
+ // · Si una palabra no cabe, se achica toda la nube; las que igual no caben
15
+ // se informan en la descripción, nunca se cortan.
16
+ // · Con texto crudo, cuenta palabras quitando las vacías (es/en).
17
+ import { g, text, tip } from '../svg.js';
18
+ import { measure } from '../text.js';
19
+ import { makeFormat, formatNumber } from '../format.js';
20
+ import { colorMap, ramp } from '../theme.js';
21
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
22
+ import { createFrame } from '../frame.js';
23
+ import { ctx } from '../context.js';
24
+
25
+ const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
26
+
27
+ const STOP = new Set((
28
+ // Español
29
+ 'a al algo algún alguna algunas alguno algunos ante antes aquel aquella aquellas aquellos aquí así aun aún bajo bien cada casi como cómo con contra cual cuál cuales cuando cuándo de del desde donde dónde dos el él ella ellas ellos en entre era eran es esa esas ese eso esos esta está están estar este esto estos fue fueron ha había han hasta hay la las le les lo los más me mi mis mismo mucho muy nada ni no nos nosotros nuestra nuestro o os otra otras otro otros para pero poco por porque qué que quien quién se sea ser si sí sin sobre son su sus también tan tanto te tiene tienen todo todos tu tus un una uno unos usted ustedes ya yo estoy estamos fue sido ser hacer hace cosa cosas solo sólo vez veces etc pues entonces luego'
30
+ // English
31
+ + ' a about above after again all am an and any are as at be because been before being below between both but by can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its itself just me more most my no nor not now of off on once only or other our ours out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your yours also get got really much many one two'
32
+ ).split(/\s+/));
33
+
34
+ /** Texto crudo → [{ text, value }] por frecuencia. */
35
+ export function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
36
+ const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
37
+ const counts = new Map(), shown = new Map();
38
+ const docs = [].concat(input).filter((d) => typeof d === 'string');
39
+ for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
40
+ const raw = m[0].replace(/['’-]+$/, '');
41
+ const k = raw.toLowerCase();
42
+ if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
43
+ counts.set(k, (counts.get(k) || 0) + 1);
44
+ // Se muestra la forma más común (respeta "Biobío", "IA", "ChatGPT").
45
+ const f = shown.get(k) || new Map();
46
+ f.set(raw, (f.get(raw) || 0) + 1);
47
+ shown.set(k, f);
48
+ }
49
+ return [...counts].map(([k, v]) => ({ text: [...shown.get(k)].sort((a, b) => b[1] - a[1])[0][0], value: v }));
50
+ }
51
+
52
+ export function wordcloud(spec, theme) {
53
+ const locale = spec.locale || 'es';
54
+ const en = String(locale).startsWith('en');
55
+ // ── Palabras ────────────────────────────────────────────────────────────
56
+ let words, groupF = null, valueF = 'value';
57
+ // `text` es un campo de los datos, o el texto mismo a contar (string o lista de strings).
58
+ const isField = typeof spec.text === 'string' && Array.isArray(spec.data) && spec.data.length > 0 && fieldsOf(spec.data).includes(spec.text);
59
+ const rawText = !isField && (typeof spec.text === 'string' || (Array.isArray(spec.text) && spec.text.length > 0 && spec.text.every((t) => typeof t === 'string')));
60
+ if (rawText || (spec.textField && spec.data)) {
61
+ const docs = rawText ? spec.text : ensureData(spec).map((r) => r[spec.textField]);
62
+ words = countWords(docs, { locale, minLength: spec.minLength ?? 3, stopwords: spec.stopwords });
63
+ valueF = T(locale, 'menciones', 'mentions');
64
+ } else {
65
+ const data = ensureData(spec);
66
+ const fields = fieldsOf(data);
67
+ const textF = spec.text ?? spec.word ?? spec.label ?? spec.x ?? fields.find((f) => /^(word|palabra|term|término|termino|text|texto|tag|keyword)$/i.test(f)) ?? fields.find((f) => fieldKind(data, f) === 'category');
68
+ valueF = spec.value ?? spec.size ?? spec.y ?? fields.find((f) => f !== textF && fieldKind(data, f) === 'number');
69
+ groupF = spec.series ?? spec.hue ?? spec.group ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null);
70
+ requireField(data, textF, 'text');
71
+ if (valueF) requireField(data, valueF, 'value');
72
+ if (groupF) requireField(data, groupF, 'series');
73
+ const acc = new Map();
74
+ for (const r of data) {
75
+ const t = String(r[textF] ?? '').trim();
76
+ if (!t) continue;
77
+ const v = valueF ? num(r[valueF]) : 1;
78
+ if (!Number.isFinite(v) || v <= 0) continue;
79
+ const e = acc.get(t) || { text: t, value: 0, group: groupF ? String(r[groupF]) : null };
80
+ e.value += v;
81
+ acc.set(t, e);
82
+ }
83
+ words = [...acc.values()];
84
+ }
85
+ if (!words.length) throw new PlotError(T(locale, 'La nube no tiene palabras.', 'The word cloud has no words.'), T(locale, 'Pasa filas { palabra, valor } o `text: "…"` con el texto a contar.', 'Pass rows { word, value } or `text: "…"` with the text to count.'));
86
+ words.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text));
87
+ const total = words.reduce((a, w) => a + w.value, 0);
88
+ const top = spec.top ?? 80;
89
+ const dropped = Math.max(0, words.length - top);
90
+ words = words.slice(0, top);
91
+
92
+ // ── Marco y color ───────────────────────────────────────────────────────
93
+ const groups = groupF ? [...new Set(words.map((w) => w.group))] : [];
94
+ const cmap = groups.length ? colorMap(groups, theme, spec.colors) : null;
95
+ const legend = groups.map((gname) => ({ label: gname, color: cmap.get(gname), shape: 'square' }));
96
+ const frame = createFrame(spec, theme, { height: 460 });
97
+ const area = frame.layout(legend);
98
+ const vmax = words[0].value, vmin = words[words.length - 1].value;
99
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map((s) => String(s).toLowerCase()));
100
+ const colorOf = (w, t) => {
101
+ if (cmap) return cmap.get(w.group);
102
+ if (spec.colors && spec.colors[w.text]) return spec.colors[w.text];
103
+ // Un solo tono: más frecuente, más oscuro (en tema oscuro, más claro).
104
+ return ramp(theme.sequential, 0.5 + 0.5 * t);
105
+ };
106
+
107
+ // ── Colocación: espiral, de la más frecuente a la menos ─────────────────
108
+ const XS = ctx.textScale;
109
+ const minF = spec.minFont ?? 11;
110
+ const maxF0 = spec.maxFont ?? Math.max(26, Math.min(80, Math.sqrt(area.w * area.h) / 6.2));
111
+ const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
112
+ const pad = 3;
113
+ const tryLayout = (k) => {
114
+ const maxF = Math.max(minF + 4, maxF0 * k);
115
+ const placed = [], miss = [];
116
+ const cell = 40, grid = new Map();
117
+ const keyOf = (i, j) => i * 100003 + j;
118
+ const cellsOf = (b) => { const out = []; for (let i = Math.floor(b[0] / cell); i <= Math.floor(b[2] / cell); i++) for (let j = Math.floor(b[1] / cell); j <= Math.floor(b[3] / cell); j++) out.push(keyOf(i, j)); return out; };
119
+ const hit = (b) => { for (const c of cellsOf(b)) for (const o of grid.get(c) || []) if (!(b[2] <= o[0] || b[0] >= o[2] || b[3] <= o[1] || b[1] >= o[3])) return true; return false; };
120
+ const add = (b) => { for (const c of cellsOf(b)) { if (!grid.has(c)) grid.set(c, []); grid.get(c).push(b); } };
121
+ const ratio = area.w / Math.max(1, area.h);
122
+ for (const [idx, w] of words.entries()) {
123
+ const t = vmax === vmin ? 0.6 : Math.sqrt((w.value - vmin) / (vmax - vmin));
124
+ const size = Math.max(minF, minF + (maxF - minF) * t);
125
+ const weight = t > 0.55 ? 600 : t > 0.2 ? 500 : 400;
126
+ const bw = measure(w.text, size, weight), bh = size * XS * 1.02;
127
+ if (bw > area.w || bh > area.h) { miss.push(w); continue; }
128
+ let ok = null;
129
+ // Espiral de Arquímedes, achatada según la forma del panel.
130
+ for (let s = 0, r = 0; r < Math.hypot(area.w, area.h); s += 0.12) {
131
+ r = 1.6 * s;
132
+ const x = cx + r * Math.cos(s + idx * 0.7) * Math.sqrt(ratio), y = cy + r * Math.sin(s + idx * 0.7) / Math.sqrt(ratio);
133
+ const b = [x - bw / 2 - pad, y - bh / 2 - pad, x + bw / 2 + pad, y + bh / 2 + pad];
134
+ if (b[0] < area.x || b[2] > area.x + area.w || b[1] < area.y || b[3] > area.y + area.h) continue;
135
+ if (!hit(b)) { ok = { x, y, b }; break; }
136
+ }
137
+ if (!ok) { miss.push(w); continue; }
138
+ add(ok.b);
139
+ placed.push({ ...w, x: ok.x, y: ok.y, size, weight, t, bh });
140
+ }
141
+ return { placed, miss };
142
+ };
143
+ let L = tryLayout(1);
144
+ // Si no caben las importantes (el primer cuarto), se achica la nube entera.
145
+ for (let k = 0.88, n = 0; n < 7 && L.miss.some((w) => words.indexOf(w) < Math.max(5, words.length / 4)); k *= 0.88, n++) L = tryLayout(k);
146
+ const fmt = makeFormat(spec.format, locale, words.map((w) => w.value));
147
+ const body = L.placed.map((w) => {
148
+ const color = colorOf(w, w.t);
149
+ const dim = hl && !hl.has(w.text.toLowerCase());
150
+ const share = total ? ` · ${formatNumber((100 * w.value) / total, (100 * w.value) / total < 10 ? 1 : 0, locale)}%` : '';
151
+ return text(w.x, w.y + w.bh * 0.3, w.text, {
152
+ class: 'pa-m', fill: dim ? theme.dim : hl ? theme.categorical[1] : color, fontSize: w.size, fontWeight: w.weight, textAnchor: 'middle',
153
+ letterSpacing: w.size > 30 ? '-0.02em' : null, 'data-color': color, 'data-tip': tip(w.text, `${fmt(w.value)} ${valueF ? humanize(valueF).toLowerCase() : ''}${share}`.trim(), w.group),
154
+ });
155
+ });
156
+ // ── Descripción ─────────────────────────────────────────────────────────
157
+ const head = words.slice(0, 5).map((w) => `${w.text} (${fmt(w.value)})`).join(', ');
158
+ let desc = en ? `Word cloud of ${L.placed.length} words, sized by ${valueF ? humanize(valueF).toLowerCase() : 'frequency'}. Most frequent: ${head}.` : `Nube de ${L.placed.length} palabras, con tamaño según ${valueF ? humanize(valueF).toLowerCase() : 'frecuencia'}. Más frecuentes: ${head}.`;
159
+ const lost = L.miss.length + dropped;
160
+ if (lost) desc += en ? ` ${lost} less frequent words are not shown.` : ` No se muestran ${lost} palabras menos frecuentes.`;
161
+ return frame.finish([g({ class: 'pa-words' }, body)], { desc, type: 'wordcloud' });
162
+ }
163
+