plotagents 0.5.0 → 0.8.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 +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -4,7 +4,7 @@ import { el, g, text, tip, esc, barPath, dotLayer } from '../svg.js';
4
4
  import { measure } from '../text.js';
5
5
  import { makeFormat, formatNumber } from '../format.js';
6
6
  import { colorMap, readable } from '../theme.js';
7
- import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize } from '../data.js';
7
+ import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize, PlotError } from '../data.js';
8
8
  import { yAxisMargin, yAxisTop, yGrid, yTickLabels, gridX, quietCorner } from '../axes.js';
9
9
  import { createFrame } from '../frame.js';
10
10
  import { polyfit, lowess, histogram, kde as kdeEst } from '../stats.js';
@@ -31,6 +31,12 @@ function readPoints(spec, data, { needY = true } = {}) {
31
31
  const pts = data
32
32
  .map((r) => ({ x: num(r[x]), y: num(r[y]), k: hue ? String(r[hue]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
33
33
  .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!xLog || p.x > 0) && (!yLog || p.y > 0));
34
+ if (!pts.length) {
35
+ const log = xLog || yLog;
36
+ throw EN(spec)
37
+ ? new PlotError(`No row has numeric values in both \`${x}\` and \`${y}\`${log ? ' (> 0 for a log scale)' : ''}.`, `Check that \`x\` and \`y\` are measures with numbers${log ? ', or drop the log scale' : ''}; to compare a measure across categories use \`type: "box"\`.`)
38
+ : new PlotError(`Ninguna fila tiene valores numéricos en \`${x}\` y \`${y}\`${log ? ' (> 0 para escala log)' : ''}.`, `Revisa que \`x\` e \`y\` sean medidas con números${log ? ', o quita la escala log' : ''}; para comparar una medida entre categorías usa \`type: "box"\`.`);
39
+ }
34
40
  const keys = hue ? uniq(pts.map((p) => p.k)) : [''];
35
41
  return { x, y, hue, labelF, pts, keys, xLog, yLog };
36
42
  }
@@ -50,7 +56,7 @@ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
50
56
  const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
51
57
  return { line: pts, band: null, r2: null, n: gp.length };
52
58
  }
53
- const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci ?? 95) / 100 });
59
+ const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95 });
54
60
  const line = grid.map((v) => [v, fit.predict(fx(v))]);
55
61
  const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
56
62
  return { line, band, r2: fit.r2, n: fit.n, fit };
@@ -117,7 +123,7 @@ export function regplot(spec, theme) {
117
123
  });
118
124
  if (spec.scatter !== false) {
119
125
  const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
120
- const dots = order.map((p) => ({
126
+ const dots = order.filter((p) => visible(p, xS, yS, inner)).map((p) => ({
121
127
  class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: K === 1 ? (M.dotOpacity ?? 0.85) * 0.75 : M.dotOpacity ?? 0.85,
122
128
  stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
123
129
  'data-tip': tip(p.name || p.k || '', `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : ''),
@@ -345,7 +351,7 @@ export function jointplot(spec, theme) {
345
351
  body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
346
352
  });
347
353
  }
348
- body.push(g({}, dotLayer(pts.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
354
+ body.push(g({}, dotLayer(pts.filter((p) => visible(p, xS, yS, inner)).map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
349
355
  body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
350
356
  body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
351
357
  body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
@@ -357,6 +363,12 @@ export function jointplot(spec, theme) {
357
363
  return frame.finish(body, { desc, type: 'jointplot' });
358
364
  }
359
365
 
366
+ /** Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área. */
367
+ function visible(p, xS, yS, inner) {
368
+ const cx = xS(p.x), cy = yS(p.y);
369
+ return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1;
370
+ }
371
+
360
372
  function hashStr(s) {
361
373
  let h = 0;
362
374
  for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
@@ -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; }
@@ -105,7 +120,7 @@ export function sankey(spec, theme) {
105
120
  const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
106
121
  const d = `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}L${x1},${l.y1 + w}C${xm},${l.y1 + w} ${xm},${l.y0 + w} ${x0},${l.y0 + w}Z`;
107
122
  const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
108
- linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% de ${l.s}` }));
123
+ linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% ${en ? 'of' : 'de'} ${l.s}` }));
109
124
  }
110
125
  body.push(g({ class: 'pa-links' }, linkG));
111
126
  for (const [ci, c] of cols.entries()) for (const n of c) {
@@ -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,54 @@ 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
+ // Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área.
142
+ const vis = (p) => { const cx = xS(p.x), cy = yS(p.y); return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1; };
143
+ const circles = sorted.filter(vis).map((p, i) => {
144
+ let t = '';
145
+ if (i < MAX_TIPS || sorted.length <= 1500) {
146
+ const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
147
+ if (size) lines.push(`${hs}: ${fs(p.s)}`);
148
+ if (series) lines.splice(1, 0, p.k);
149
+ t = tip(...lines);
150
+ }
151
+ 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
152
  });
104
- body.push(dotLayer(circles));
153
+ body.push(dotLayer(circles, { maxTips: MAX_TIPS }));
154
+
155
+ // Clave de tamaños (burbujas): 2–3 círculos de referencia anidados en una esquina libre.
156
+ if (size && spec.sizeKey !== false && maxS > 0) {
157
+ const nice = (v) => { const e = 10 ** Math.floor(Math.log10(v)); const m = v / e; return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * e; };
158
+ const v1 = nice(maxS);
159
+ const vals = [v1, nice(v1 / 4), nice(v1 / 16)].filter((v, i, a) => a.indexOf(v) === i && Math.sqrt(v / maxS) * rMax >= 4);
160
+ if (vals.length >= 2) {
161
+ const radii = vals.map((v) => Math.sqrt(v / maxS) * rMax);
162
+ const R = radii[0];
163
+ const labs = vals.map((v) => fs(v));
164
+ const lw = Math.max(...labs.map((l) => measure(l, 10.5)));
165
+ const kw = 2 * R + 8 + lw, kh = 2 * R + 16;
166
+ const occ = sorted.map((p) => [xS(p.x), yS(p.y)]);
167
+ // Lo ya ocupado (R²) pesa como muchos puntos: la clave nunca lo tapa.
168
+ for (const q of taken) for (let i = 0; i < 50; i++) occ.push(q);
169
+ const qc = quietCorner(occ, inner, kw, kh);
170
+ const left = qc.anchor === 'end' ? qc.x - kw : qc.x;
171
+ const y0 = qc.y - kh + 3; // borde superior de la caja elegida
172
+ const cx = left + R, cy0 = y0 + kh - 1;
173
+ const parts = [text(left, y0 + 10, hs, { fill: theme.muted, fontSize: 10.5, fontWeight: 500 })];
174
+ let prevY = -Infinity;
175
+ radii.forEach((r, i) => {
176
+ const top = cy0 - 2 * r;
177
+ const ly = Math.max(top, prevY + 11); // rótulos separados aunque los círculos se acerquen
178
+ prevY = ly;
179
+ parts.push(el('circle', { cx, cy: cy0 - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
180
+ 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 }));
181
+ parts.push(text(cx + R + 6, ly + 3.5, labs[i], { fill: theme.muted, fontSize: 10.5 }));
182
+ });
183
+ body.push(g({ class: 'pa-sizekey' }, parts));
184
+ }
185
+ }
105
186
 
106
187
  body.push(yTickLabels(yS.ticks(dy.step), yS, inner, theme, ytf));
107
188
 
@@ -116,7 +197,7 @@ export function scatter(spec, theme) {
116
197
  }
117
198
  const placed = [];
118
199
  for (const p of chosen) {
119
- if (!p.name) continue;
200
+ if (!p.name || !vis(p)) continue;
120
201
  const w = measure(p.name, 12, 500);
121
202
  const r = rOf(p);
122
203
  let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
@@ -128,7 +209,6 @@ export function scatter(spec, theme) {
128
209
  }
129
210
  }
130
211
 
131
- const en = String(locale).startsWith('en');
132
212
  const desc = en
133
213
  ? `Scatter plot of ${humanize(y)} vs ${humanize(x)} with ${pts.length} points.`
134
214
  : `Gráfico de dispersión de ${humanize(y)} versus ${humanize(x)} con ${pts.length} puntos.`;
@@ -7,7 +7,7 @@ import { makeFormat } from '../format.js';
7
7
  import { textOn } from '../theme.js';
8
8
  import { humanize } from '../data.js';
9
9
  import { createFrame } from '../frame.js';
10
- import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
10
+ import { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
11
11
 
12
12
  const TAU = Math.PI * 2;
13
13
  const f = (n) => n.toFixed(2);
@@ -24,7 +24,7 @@ export function sunburst(spec, theme) {
24
24
  const locale = spec.locale || 'es';
25
25
  const en = String(locale).startsWith('en');
26
26
  const { root, fields, valueField } = buildHierarchy(spec);
27
- const focus = findNode(root, spec.focus);
27
+ const focus = focusNode(root, spec.focus);
28
28
  const levels = Math.max(1, height(focus));
29
29
  assignColors(root, theme, spec); // colores estables al navegar
30
30
  const vals = [];
@@ -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}.`