plotagents 0.7.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 (53) hide show
  1. package/AGENTS.md +5 -1
  2. package/README.md +18 -1
  3. package/bin/plotagents.js +3 -1
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +1857 -110
  6. package/docs/charts.md +86 -1
  7. package/docs/es/agents.md +102 -0
  8. package/docs/es/api.md +172 -0
  9. package/docs/es/charts.md +436 -0
  10. package/docs/es/colorscales.md +46 -0
  11. package/docs/es/dashboards.md +79 -0
  12. package/docs/es/getting-started.md +142 -0
  13. package/docs/es/rendering.md +47 -0
  14. package/docs/es/themes.md +116 -0
  15. package/index.d.ts +92 -8
  16. package/package.json +3 -2
  17. package/schema.json +359 -9
  18. package/skills/plotagents/SKILL.md +105 -0
  19. package/src/charts/bar.js +13 -5
  20. package/src/charts/chord.js +3 -1
  21. package/src/charts/choropleth.js +3 -1
  22. package/src/charts/corr.js +512 -0
  23. package/src/charts/dist.js +2 -1
  24. package/src/charts/donut.js +3 -1
  25. package/src/charts/kpi.js +2 -2
  26. package/src/charts/line.js +19 -7
  27. package/src/charts/network.js +13 -8
  28. package/src/charts/pointmap.js +9 -6
  29. package/src/charts/regplot.js +9 -3
  30. package/src/charts/sankey.js +1 -1
  31. package/src/charts/scatter.js +4 -2
  32. package/src/charts/sunburst.js +2 -2
  33. package/src/charts/table.js +882 -0
  34. package/src/charts/treemap.js +2 -2
  35. package/src/charts/wordcloud.js +17 -3
  36. package/src/colorscales.js +12 -1
  37. package/src/dashboard.js +21 -8
  38. package/src/data.js +2 -0
  39. package/src/facet.js +4 -3
  40. package/src/format.js +36 -13
  41. package/src/frame.js +8 -5
  42. package/src/geo/projection.js +4 -2
  43. package/src/hierarchy.js +6 -0
  44. package/src/i18n.js +107 -0
  45. package/src/index.js +17 -4
  46. package/src/infer.js +9 -1
  47. package/src/lint.js +66 -4
  48. package/src/runtime.js +94 -6
  49. package/src/scale.js +3 -2
  50. package/src/schema.js +54 -11
  51. package/src/stats.js +5 -3
  52. package/src/svg.js +4 -1
  53. package/src/text.js +5 -0
@@ -10,7 +10,7 @@ import { formatNumber } from '../format.js';
10
10
  import { humanize } from '../data.js';
11
11
  import { createFrame } from '../frame.js';
12
12
  import { ctx } from '../context.js';
13
- import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
13
+ import { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
14
14
 
15
15
  /** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
16
16
  export function squarify(items, x, y, w, h) {
@@ -57,7 +57,7 @@ export function treemap(spec, theme) {
57
57
  const locale = spec.locale || 'es';
58
58
  const en = String(locale).startsWith('en');
59
59
  const { root, fields, valueField } = buildHierarchy(spec);
60
- const focus = findNode(root, spec.focus);
60
+ const focus = focusNode(root, spec.focus);
61
61
  const levels = height(focus);
62
62
  // El color sigue a la entidad: se asigna sobre el árbol completo, así al
63
63
  // entrar a un grupo sus elementos conservan la familia de color del padre.
@@ -31,15 +31,26 @@ const STOP = new Set((
31
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
32
  ).split(/\s+/));
33
33
 
34
+ // Palabras vacías extra por idioma (se suman a es/en solo si el locale las pide).
35
+ const STOP_BY = {
36
+ pt: 'não uma uns umas dos das nas nos pelo pela pelos pelas com para por que mas como mais muito já também são foi era tem têm ele ela eles elas isso isto esse essa este esta aos àquele seu sua seus suas meu minha nós você vocês ser estar ter há então quando onde',
37
+ fr: 'les des une dans pour que qui est sont mais comme plus par sur pas avec ces cette cet aux ils elles nous vous ont été être avoir fait très aussi donc ainsi sans sous entre leur leurs son ses mes tes notre votre',
38
+ de: 'der die das den dem des ein eine einer einem einen und oder aber nicht mit von zu für auf ist sind war waren wird werden hat haben auch als wie bei nach aus über sich es sie wir ihr ich du er an im am zum zur dass noch nur sehr',
39
+ };
40
+ // Compara sin tildes: "tambien" y "también" son la misma palabra vacía.
41
+ const plain = (w) => w.normalize('NFD').replace(/\p{M}/gu, '');
42
+ const STOP_PLAIN = new Set([...STOP].map(plain));
43
+
34
44
  /** Texto crudo → [{ text, value }] por frecuencia. */
35
45
  export function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
36
46
  const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
47
+ for (const w of (STOP_BY[String(locale || '').slice(0, 2)] || '').split(' ')) if (w) extra.add(w);
37
48
  const counts = new Map(), shown = new Map();
38
49
  const docs = [].concat(input).filter((d) => typeof d === 'string');
39
50
  for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
40
51
  const raw = m[0].replace(/['’-]+$/, '');
41
52
  const k = raw.toLowerCase();
42
- if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
53
+ if (k.length < minLength || STOP.has(k) || STOP_PLAIN.has(plain(k)) || extra.has(k) || /^\d+$/.test(k)) continue;
43
54
  counts.set(k, (counts.get(k) || 0) + 1);
44
55
  // Se muestra la forma más común (respeta "Biobío", "IA", "ChatGPT").
45
56
  const f = shown.get(k) || new Map();
@@ -76,9 +87,12 @@ export function wordcloud(spec, theme) {
76
87
  if (!t) continue;
77
88
  const v = valueF ? num(r[valueF]) : 1;
78
89
  if (!Number.isFinite(v) || v <= 0) continue;
79
- const e = acc.get(t) || { text: t, value: 0, group: groupF ? String(r[groupF]) : null };
90
+ // Con `series`, la misma palabra en dos grupos son dos entradas (no se funde con el primero).
91
+ const gk = groupF ? String(r[groupF]) : null;
92
+ const key = gk === null ? t : gk + '\u0000' + t;
93
+ const e = acc.get(key) || { text: t, value: 0, group: gk };
80
94
  e.value += v;
81
- acc.set(t, e);
95
+ acc.set(key, e);
82
96
  }
83
97
  words = [...acc.values()];
84
98
  }
@@ -141,6 +141,17 @@ const LOWER = new Map();
141
141
  for (const k of [...Object.keys(COLORMAPS), ...Object.keys(QUALITATIVE)]) LOWER.set(k.toLowerCase(), k);
142
142
  const unpack = (s) => s.match(/.{6}/g).map((h) => '#' + h);
143
143
  const isHex = (c) => typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim());
144
+ // Nombres CSS comunes en listas de colores (["white", "steelblue"]): antes se
145
+ // descartaban en silencio y la escala caía a la por defecto.
146
+ const NAMED = {
147
+ black: '#000000', white: '#ffffff', gray: '#808080', grey: '#808080', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', darkgray: '#a9a9a9', darkgrey: '#a9a9a9', silver: '#c0c0c0',
148
+ red: '#ff0000', darkred: '#8b0000', crimson: '#dc143c', tomato: '#ff6347', salmon: '#fa8072', pink: '#ffc0cb', maroon: '#800000',
149
+ orange: '#ffa500', darkorange: '#ff8c00', gold: '#ffd700', yellow: '#ffff00', khaki: '#f0e68c', beige: '#f5f5dc', ivory: '#fffff0', wheat: '#f5deb3', brown: '#a52a2a',
150
+ green: '#008000', darkgreen: '#006400', lime: '#00ff00', limegreen: '#32cd32', olive: '#808000', seagreen: '#2e8b57', forestgreen: '#228b22', teal: '#008080',
151
+ cyan: '#00ffff', aqua: '#00ffff', turquoise: '#40e0d0', lightblue: '#add8e6', skyblue: '#87ceeb', steelblue: '#4682b4', blue: '#0000ff', darkblue: '#00008b', navy: '#000080', royalblue: '#4169e1', cornflowerblue: '#6495ed', dodgerblue: '#1e90ff',
152
+ purple: '#800080', indigo: '#4b0082', violet: '#ee82ee', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6', lavender: '#e6e6fa', plum: '#dda0dd',
153
+ };
154
+ const toHex = (c) => (isHex(c) ? c.trim() : typeof c === 'string' ? NAMED[c.trim().toLowerCase()] : undefined);
144
155
 
145
156
  /** Nombres disponibles, por tipo. */
146
157
  export function colorScaleNames() {
@@ -161,7 +172,7 @@ export function colorScaleNames() {
161
172
  export function resolveColorScale(v) {
162
173
  if (!v) return null;
163
174
  if (Array.isArray(v)) {
164
- const stops = v.filter(isHex);
175
+ const stops = v.map(toHex).filter(Boolean);
165
176
  return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
166
177
  }
167
178
  if (typeof v === 'object') {
package/src/dashboard.js CHANGED
@@ -30,12 +30,19 @@ let core = null;
30
30
  /** index.js registra plot/resolve/lint aquí (evita una importación circular). */
31
31
  export function _bindDashboard(fns) { core = fns; }
32
32
 
33
+ /** Spec tolerante: charts/kpis/filters siempre listas de objetos (los nulos y valores sueltos se descartan). */
34
+ function tidy(spec) {
35
+ const list = (v) => (v == null || v === false ? [] : [].concat(v)).filter((x) => x != null);
36
+ return { ...spec, charts: list(spec.charts).filter((c) => typeof c === 'object'), kpis: list(spec.kpis), filters: list(spec.filters).filter((f) => typeof f === 'string' || (typeof f === 'object' && typeof f.field === 'string')) };
37
+ }
38
+ const hasField = (r, f) => r !== null && typeof r === 'object' && f in r;
39
+
33
40
  const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
34
41
 
35
42
  // Ancho (de 12) según el tipo: lo que se lee de izquierda a derecha, ancho.
36
43
  const WIDE = new Set(['line', 'area', 'gantt', 'heatmap', 'sankey', 'flowchart', 'network', 'jointplot']);
37
44
  const NARROW = new Set(['donut', 'pie', 'radar', 'kpi', 'hist', 'kde', 'ecdf', 'count']);
38
- const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart']);
45
+ const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart', 'table']);
39
46
 
40
47
  function spanFor(s) {
41
48
  if (s.size === 'full' || s.span === 12) return 12;
@@ -131,6 +138,7 @@ const colorField = (s) => s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0
131
138
  export function planDashboard(spec = {}) {
132
139
  if (!core) throw new PlotError('plotagents no está inicializado.');
133
140
  if (!spec || typeof spec !== 'object') throw new PlotError('El dashboard debe ser un objeto.', 'dashboard({ title, kpis, charts: [...] })');
141
+ spec = tidy(spec);
134
142
  const locale = spec.locale || 'es';
135
143
  const shared = spec.data ? ensureData(spec) : null;
136
144
  const base = {};
@@ -191,7 +199,7 @@ export function planDashboard(spec = {}) {
191
199
  const filters = [].concat(spec.filters || []).map((f) => {
192
200
  const o = typeof f === 'string' ? { field: f } : { ...f };
193
201
  const pool = [...(shared || []), ...panels.flatMap((p) => (Array.isArray(p.spec.data) && p.spec.data !== shared ? p.spec.data : []))];
194
- const vals = uniq(pool.map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
202
+ const vals = uniq(pool.filter((r) => hasField(r, o.field)).map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
195
203
  const kind = pool.length ? fieldKind(pool, o.field) : 'category';
196
204
  vals.sort((a, b) => (kind === 'date' ? parseDate(a) - parseDate(b) : kind === 'year' || kind === 'number' ? num(a) - num(b) : 0));
197
205
  return { field: o.field, label: o.label ?? o.field.charAt(0).toUpperCase() + o.field.slice(1), values: vals.map(String), control: o.control ?? (vals.length <= 6 ? 'buttons' : 'select'), value: o.value ?? o.default ?? null, all: o.all ?? T(locale, 'Todos', 'All') };
@@ -205,6 +213,7 @@ export function planDashboard(spec = {}) {
205
213
 
206
214
  // ── Revisión (lint) ────────────────────────────────────────────────────────
207
215
  export function lintDashboard(spec = {}) {
216
+ spec = tidy(spec && typeof spec === 'object' ? spec : {});
208
217
  const locale = spec.locale || 'es';
209
218
  const L = (es, en) => T(locale, es, en);
210
219
  const errors = [], warnings = [], suggestions = [];
@@ -226,7 +235,7 @@ export function lintDashboard(spec = {}) {
226
235
  if (untitled) suggestions.push({ code: 'dash-chart-titles', message: L(`${untitled} gráfico(s) sin título.`, `${untitled} chart(s) without a title.`), fix: L('Cada panel necesita su hallazgo como título.', 'Every panel needs its finding as the title.') });
227
236
  for (const f of [].concat(spec.filters || [])) {
228
237
  const field = typeof f === 'string' ? f : f.field;
229
- const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => r && field in r));
238
+ const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => hasField(r, field)));
230
239
  if (!has) warnings.push({ code: 'dash-filter-field', message: L(`El filtro "${field}" no existe en los datos de ningún gráfico.`, `The filter "${field}" is not in any chart's data.`), fix: L('Usa un campo de `data`.', 'Use a field of `data`.') });
231
240
  }
232
241
  // Revisión de cada gráfico, con su número y título.
@@ -291,7 +300,9 @@ export function kpiMarkup(plan, rows) {
291
300
  if (k.trendBy && !k.trend && rows && rows.length) {
292
301
  const by = new Map();
293
302
  for (const r of cmp ? inPeriod(cmp.current) : rows) { const key = r[k.trendBy]; if (!by.has(key)) by.set(key, []); by.get(key).push(r); }
294
- const ks = [...by.keys()].sort((x, y) => (String(x) < String(y) ? -1 : 1));
303
+ // Periodos numéricos (1…12) se ordenan como números; el resto, como texto (fechas ISO).
304
+ const allNum = [...by.keys()].every((x) => Number.isFinite(num(x)));
305
+ const ks = [...by.keys()].sort((x, y) => (allNum ? num(x) - num(y) : String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0));
295
306
  k = { ...k, trend: ks.map((x) => computeKpi(k, by.get(x))) };
296
307
  }
297
308
  const fmt = makeFormat(k.format, locale, [v]);
@@ -344,7 +355,7 @@ export function filterSpec(spec, plan, active, locale) {
344
355
  const v = active[i];
345
356
  if (v === null || v === undefined || v === '') return;
346
357
  if (s.time === f.field) { s = { ...s, timeValue: v }; return; }
347
- if (Array.isArray(s.data) && s.data.some((r) => r && f.field in r)) { s = { ...s, data: s.data.filter((r) => String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
358
+ if (Array.isArray(s.data) && s.data.some((r) => hasField(r, f.field))) { s = { ...s, data: s.data.filter((r) => hasField(r, f.field) && String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
348
359
  });
349
360
  if (tags.length) s = { ...s, subtitle: [s.subtitle, tags.join(' · ')].filter(Boolean).join(' · ') };
350
361
  return s;
@@ -368,7 +379,7 @@ export function mountDashboard(root, plan) {
368
379
  }
369
380
  // KPIs calculados: se recalculan con los filtros.
370
381
  if (plan.data && plan.kpis.some((k) => k.value === undefined)) {
371
- const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !(f.field in r) || String(r[f.field]) === String(active[i])));
382
+ const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !hasField(r, f.field) || String(r[f.field]) === String(active[i])));
372
383
  const slot = root.querySelector('.pa-kslot');
373
384
  if (slot) slot.innerHTML = kpiMarkup(plan, rows);
374
385
  }
@@ -430,9 +441,11 @@ ${bundle ? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>
430
441
 
431
442
  /** Nunca lanza: { ok, html, plan, errors, warnings, suggestions } (para agentes). */
432
443
  export function safeDashboard(spec) {
433
- const rep = lintDashboard(spec || {});
444
+ let rep;
445
+ try { rep = lintDashboard(spec || {}); }
446
+ catch (e) { return { ok: false, html: null, plan: null, errors: [{ code: 'render', message: String(e.message).split('\n')[0], fix: e.hint || 'Revisa que `charts` sea una lista de specs y `kpis` una lista de objetos.' }], warnings: [], suggestions: [] }; }
434
447
  if (!rep.ok) return { ...rep, html: null, plan: null };
435
448
  try { const html = dashboard(spec); return { ...rep, ok: true, html, plan: planDashboard(spec) }; }
436
- catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint }] }; }
449
+ catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa `charts`, `kpis` y `data` del dashboard.' }] }; }
437
450
  }
438
451
 
package/src/data.js CHANGED
@@ -15,6 +15,8 @@ export function num(v) {
15
15
  if (typeof v === 'number') return v;
16
16
  if (v === null || v === undefined || v === '' || typeof v === 'boolean') return NaN;
17
17
  let s = String(v).trim().replace(/[\s$€£%]|US\$|CLP|UF/g, '').replace('−', '-');
18
+ // Solo símbolos ("%", "$", " ") no es el 0; y nada de "0x10" ni "Infinity".
19
+ if (!/^[+-]?(\d[\d.,]*|[.,]\d[\d.,]*)(e[+-]?\d+)?$/i.test(s)) return NaN;
18
20
  const hasC = s.includes(','), hasD = s.includes('.');
19
21
  if (hasC && hasD) s = s.lastIndexOf(',') > s.lastIndexOf('.') ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
20
22
  else if (hasC) s = /,\d{3}(?!\d)/.test(s) && !/,\d{1,2}$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.');
package/src/facet.js CHANGED
@@ -32,9 +32,10 @@ export function facet(spec, theme, renderOne) {
32
32
  const frame = createFrame({ ...spec, height: 200 }, theme, { width: 720 });
33
33
  const area = frame.layout(legend);
34
34
  const gap = spec.panelGap ?? 14;
35
- // Columnas pedidas, pero nunca paneles de menos de ~150 px (en móvil se apilan).
36
- const fit = Math.max(1, Math.floor((area.w + gap) / 150));
37
- const want = spec.columns ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
35
+ // Columnas pedidas, pero nunca paneles más angostos que el mínimo de un gráfico
36
+ // (240 px, ver createFrame): más angostos se montarían unos sobre otros.
37
+ const fit = Math.max(1, Math.floor((area.w + gap) / 240));
38
+ const want = (typeof spec.columns === 'number' ? spec.columns : undefined) ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
38
39
  const cols = Math.max(1, Math.min(keys.length, want, fit));
39
40
  const pw = (area.w - gap * (cols - 1)) / cols;
40
41
  const ph = spec.panelHeight ?? Math.round(Math.min(300, Math.max(160, pw * 0.66)));
package/src/format.js CHANGED
@@ -4,19 +4,29 @@
4
4
  const SEP = {
5
5
  es: { group: '.', dec: ',' },
6
6
  en: { group: ',', dec: '.' },
7
+ pt: { group: '.', dec: ',' },
8
+ de: { group: '.', dec: ',' },
9
+ fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
7
10
  };
8
11
 
9
12
  const COMPACT = {
10
13
  es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
11
14
  en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
15
+ pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
16
+ fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
17
+ de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
12
18
  };
13
19
 
14
20
  export const MONTHS = {
15
21
  es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
16
22
  en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
23
+ pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
24
+ fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
25
+ de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
17
26
  };
18
27
 
19
- const loc = (l) => (l && String(l).toLowerCase().startsWith('en') ? 'en' : 'es');
28
+ /** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
29
+ const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
20
30
 
21
31
  /** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
22
32
  export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
@@ -24,7 +34,8 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
24
34
  if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
25
35
  const { group, dec } = SEP[loc(locale)];
26
36
  const neg = v < 0;
27
- let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))).split('.');
37
+ const fixed = Math.abs(v) >= 1e21 ? BigInt(Math.abs(v)).toString() : Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))); // toFixed pasa a "1e+21"
38
+ let [int, frac = ''] = fixed.split('.');
28
39
  int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
29
40
  if (trim) frac = frac.replace(/0+$/, '');
30
41
  const out = frac ? int + dec + frac : int;
@@ -44,10 +55,16 @@ export function smartDecimals(v) {
44
55
  export function formatCompact(v, locale = 'es', decimals) {
45
56
  const l = loc(locale);
46
57
  const a = Math.abs(v);
47
- for (const [n, suf] of COMPACT[l]) {
58
+ const tbl = COMPACT[l];
59
+ for (let i = 0; i < tbl.length; i++) {
60
+ let [n, suf] = tbl[i];
48
61
  if (a >= n) {
49
- const s = v / n;
50
- const d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
62
+ let s = v / n;
63
+ let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
64
+ // 999.999 → "1.000 mil" no: al redondear llega a 1000, sube a la unidad siguiente (1 M).
65
+ if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
66
+ [n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
67
+ }
51
68
  return formatNumber(s, d, l, decimals === undefined) + suf;
52
69
  }
53
70
  }
@@ -109,7 +126,7 @@ export function makeFormat(fmt, locale = 'es', values = []) {
109
126
  return f;
110
127
  }
111
128
 
112
- function currencyPrefix(cur, l) {
129
+ export function currencyPrefix(cur, l) {
113
130
  if (!cur) return '$';
114
131
  const c = String(cur).toUpperCase();
115
132
  return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
@@ -121,8 +138,8 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
121
138
  const decFor = (s) => {
122
139
  const a = Math.abs(s) || 1;
123
140
  let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
124
- while (d < 6 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
125
- return Math.min(6, d);
141
+ while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
142
+ return Math.min(12, d);
126
143
  };
127
144
  let core;
128
145
  if (type === 'year') core = (v) => String(Math.round(v));
@@ -141,9 +158,12 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
141
158
  ? (v) => formatNumber(v, decFor(step), l)
142
159
  : (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
143
160
  } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
161
+ // Una marca entera se escribe sin ",0" (600, no 600,0); las fraccionarias
162
+ // conservan sus decimales (0 · 0,5 · 1 · 1,5).
163
+ const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
144
164
  return (v) => {
145
165
  if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
146
- const s = core(v);
166
+ const s = core(v).replace(zeroFrac, '');
147
167
  if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
148
168
  return pre + s + suf;
149
169
  };
@@ -163,7 +183,8 @@ export function parseDate(v) {
163
183
  if (v instanceof Date) return v.getTime();
164
184
  if (typeof v === 'string') {
165
185
  const m = ISO.exec(v.trim());
166
- if (m) {
186
+ // Rangos válidos: "2020-21" (temporada) o "2024-13" no son fechas.
187
+ if (m && +m[2] >= 1 && +m[2] <= 12 && +(m[3] || 1) >= 1 && +(m[3] || 1) <= new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate() && +(m[4] || 0) < 24 && +(m[5] || 0) < 60 && +(m[6] || 0) < 60) {
167
188
  const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
168
189
  let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
169
190
  const z = m[8];
@@ -178,7 +199,7 @@ export function parseDate(v) {
178
199
  return NaN;
179
200
  }
180
201
 
181
- export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
202
+ export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
182
203
 
183
204
  /**
184
205
  * Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
@@ -196,8 +217,10 @@ export function formatDate(ms, unit, locale = 'es', withYear = true) {
196
217
  return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
197
218
  }
198
219
  if (unit === 'year') return String(y);
199
- if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
220
+ if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
200
221
  if (unit === 'month') return withYear ? `${m} ${y}` : m;
201
222
  const day = d.getUTCDate();
202
- return l === 'en' ? `${m} ${day}${withYear ? ', ' + y : ''}` : `${day} ${m}${withYear ? ' ' + y : ''}`;
223
+ if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
224
+ if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
225
+ return `${day} ${m}${withYear ? ' ' + y : ''}`;
203
226
  }
package/src/frame.js CHANGED
@@ -4,6 +4,7 @@ import { el, esc, g, text, r2 } from './svg.js';
4
4
  import { measure, wrap, truncate } from './text.js';
5
5
  import { FONT, FONT_CSS } from './theme.js';
6
6
  import { ctx } from './context.js';
7
+ import { words } from './i18n.js';
7
8
 
8
9
  export const SIZES = {
9
10
  title: 22,
@@ -49,7 +50,7 @@ export function createFrame(spec, theme, defaults = {}) {
49
50
  if (subLines.length) y += subLines.length * slh + 2;
50
51
  if (titleLines.length || subLines.length) y += 10;
51
52
 
52
- const srcText = spec.source ? (/^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source) : '';
53
+ const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
53
54
  // Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
54
55
  const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
55
56
  const srcLH = Math.round(T.source * X * 1.35);
@@ -97,7 +98,7 @@ export function createFrame(spec, theme, defaults = {}) {
97
98
  },
98
99
  /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
99
100
  setHeight(h) { height = h; },
100
- finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
101
+ finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
101
102
  if (legendBottom) {
102
103
  const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
103
104
  header.push(svg);
@@ -108,7 +109,8 @@ export function createFrame(spec, theme, defaults = {}) {
108
109
  const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
109
110
  const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}@media (prefers-reduced-motion:reduce){.pa-m{transition:none}}.pa-chart:focus-visible{outline:2px solid ${theme.categorical[0]};outline-offset:2px}`
110
111
  + (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
111
- + (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '');
112
+ + (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '')
113
+ + css;
112
114
  return el(
113
115
  'svg',
114
116
  {
@@ -116,8 +118,9 @@ export function createFrame(spec, theme, defaults = {}) {
116
118
  viewBox: `0 0 ${width} ${r2(height)}`,
117
119
  width,
118
120
  height: r2(height),
119
- role: 'img',
120
- 'aria-labelledby': `${id}-t ${id}-d`,
121
+ role,
122
+ 'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
123
+ 'aria-describedby': role === 'img' ? null : `${id}-d`,
121
124
  fontFamily: spec.fontFamily || theme.font?.family || FONT,
122
125
  class: 'pa-chart',
123
126
  'data-pa-type': type,
@@ -95,8 +95,10 @@ export const projections = {
95
95
  */
96
96
  export function makeProjection(p) {
97
97
  if (!p) return null;
98
- if (typeof p === 'string') { const f = projections[p] || projections[p.toLowerCase()]; return f ? f() : null; }
99
- if (typeof p === 'object') { const f = projections[p.type || p.name]; return f ? f(p) : null; }
98
+ // Sin distinguir mayúsculas ni guiones: "equalearth", "Equal-Earth" y "equalEarth" son lo mismo.
99
+ const find = (n) => { const k = String(n ?? '').toLowerCase().replace(/[-_\s]/g, ''); const hit = Object.keys(projections).find((x) => x.toLowerCase().replace(/[-_\s]/g, '') === k); return hit ? projections[hit] : null; };
100
+ if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
101
+ if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
100
102
  return null;
101
103
  }
102
104
 
package/src/hierarchy.js CHANGED
@@ -66,6 +66,12 @@ export function findNode(root, focus = []) {
66
66
  return node;
67
67
  }
68
68
 
69
+ /** Nodo de foco para dibujar: una hoja no se puede "abrir" (quedaría vacío), se usa su padre. */
70
+ export function focusNode(root, focus = []) {
71
+ const n = findNode(root, focus);
72
+ return !n.children.length && n.parent ? n.parent : n;
73
+ }
74
+
69
75
  export const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
70
76
  export const descendants = (n) => [n, ...n.children.flatMap(descendants)];
71
77
 
package/src/i18n.js ADDED
@@ -0,0 +1,107 @@
1
+ // Idioma: reconocimiento a partir del texto del spec y las pocas palabras que
2
+ // la biblioteca escribe por su cuenta (fuente, total, buscar…). Lo demás
3
+ // (paginación, flechas, ✓) se dibuja con símbolos que no dependen del idioma.
4
+ //
5
+ // detectLocale(['Ventas por región, 2025', 'Biobío']) → 'es'
6
+ // detectLocale(['Sales grew 12% in the South']) → 'en'
7
+ // locale: 'auto' en cualquier spec (y por defecto en `table`) lo usa.
8
+
9
+ export const LANGS = ['es', 'en', 'pt', 'fr', 'de'];
10
+
11
+ const WORDS = {
12
+ es: { source: 'Fuente: ', total: 'Total', search: 'Buscar', none: 'Sin resultados', clear: 'Borrar', page: 'Página', prev: 'Anterior', next: 'Siguiente', sort: 'Ordenar por', yes: 'Sí', no: 'No', rows: 'filas', table: 'Tabla', of: 'de' },
13
+ en: { source: 'Source: ', total: 'Total', search: 'Search', none: 'No results', clear: 'Clear', page: 'Page', prev: 'Previous', next: 'Next', sort: 'Sort by', yes: 'Yes', no: 'No', rows: 'rows', table: 'Table', of: 'of' },
14
+ pt: { source: 'Fonte: ', total: 'Total', search: 'Buscar', none: 'Sem resultados', clear: 'Limpar', page: 'Página', prev: 'Anterior', next: 'Próxima', sort: 'Ordenar por', yes: 'Sim', no: 'Não', rows: 'linhas', table: 'Tabela', of: 'de' },
15
+ fr: { source: 'Source : ', total: 'Total', search: 'Rechercher', none: 'Aucun résultat', clear: 'Effacer', page: 'Page', prev: 'Précédente', next: 'Suivante', sort: 'Trier par', yes: 'Oui', no: 'Non', rows: 'lignes', table: 'Tableau', of: 'sur' },
16
+ de: { source: 'Quelle: ', total: 'Gesamt', search: 'Suchen', none: 'Keine Treffer', clear: 'Löschen', page: 'Seite', prev: 'Zurück', next: 'Weiter', sort: 'Sortieren nach', yes: 'Ja', no: 'Nein', rows: 'Zeilen', table: 'Tabelle', of: 'von' },
17
+ };
18
+
19
+ /** Código de idioma soportado ('es', 'en', 'pt', 'fr', 'de') a partir de cualquier etiqueta ('pt-BR', 'en_US'…). */
20
+ export function langOf(locale) {
21
+ const k = String(locale || 'es').toLowerCase().slice(0, 2);
22
+ return LANGS.includes(k) ? k : 'es';
23
+ }
24
+
25
+ /** Palabras de interfaz en el idioma del gráfico. */
26
+ export const words = (locale) => WORDS[langOf(locale)];
27
+
28
+ // Palabras funcionales frecuentes de cada idioma. Una palabra compartida
29
+ // (de, la, que…) reparte su peso entre los idiomas que la usan.
30
+ const STOP = {
31
+ es: 'de la el los las y en por con para del que una un se al más es son sus su según entre como año años sin hasta desde ventas región regiones total mil millones porcentaje hogares personas este esta sobre cada ha han fue lidera mayor menor tasa crecimiento meta comuna comunas mes meses semana proyecto estado avance',
32
+ en: 'the of and in by for with to is are from on at than more its as per was were this that which share sales region regions total thousand million percent households people over each has have had leads largest smallest rate growth target month months week project status progress revenue year years',
33
+ pt: 'de da do das dos e em por com para que uma um ao mais é são sua seu entre como ano anos não até desde pelo pela no na nos nas vendas região total mil milhões taxa crescimento meta mês meses projeto estado cada foi tem',
34
+ fr: 'de la le les et en par pour avec du des que une un au aux plus est sont sa son ses entre comme année années sans jusqu depuis sur dans ventes région total mille millions taux croissance objectif mois semaine projet état chaque été',
35
+ de: 'der die das und in von mit für zu ist sind den dem des ein eine einer im am mehr als nach bei auf aus nicht jahr jahre umsatz region gesamt tausend millionen anteil wachstum ziel monat monate woche projekt status jede wurde hat',
36
+ };
37
+ const INDEX = new Map();
38
+ for (const [lang, list] of Object.entries(STOP)) for (const w of list.split(' ')) { if (!INDEX.has(w)) INDEX.set(w, []); INDEX.get(w).push(lang); }
39
+
40
+ // Letras que delatan un idioma (se suman a las palabras).
41
+ const CHARS = [
42
+ [/[ñ¿¡]/g, { es: 3 }],
43
+ [/[ãõ]/g, { pt: 3 }],
44
+ [/ç/g, { pt: 1.5, fr: 1.5 }],
45
+ [/[èêëàâùûœî]/g, { fr: 2 }],
46
+ [/[äöüß]/g, { de: 3 }],
47
+ [/[áíóú]/g, { es: 1, pt: 1 }],
48
+ [/é/g, { es: 0.5, pt: 0.5, fr: 0.8 }],
49
+ ];
50
+
51
+ /**
52
+ * Idioma más probable de unos textos (títulos, encabezados, categorías), o
53
+ * null si no hay señal suficiente. Determinista y sin diccionarios grandes.
54
+ */
55
+ export function detectLocale(texts) {
56
+ const s = [].concat(texts || []).filter((t) => typeof t === 'string' && t).join(' \n ').toLowerCase().slice(0, 6000);
57
+ if (!s) return null;
58
+ const score = { es: 0, en: 0, pt: 0, fr: 0, de: 0 };
59
+ for (const tok of s.split(/[^\p{L}]+/u)) {
60
+ const langs = tok && INDEX.get(tok);
61
+ if (langs) for (const l of langs) score[l] += 1 / langs.length;
62
+ }
63
+ for (const [re, w] of CHARS) {
64
+ const n = (s.match(re) || []).length;
65
+ if (n) for (const [l, v] of Object.entries(w)) score[l] += Math.min(6, n) * v;
66
+ }
67
+ const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
68
+ const [[best, a], [, b]] = ranked;
69
+ if (a < 1 || a < b * 1.25 + 0.25) return null;
70
+ return best;
71
+ }
72
+
73
+ /** Idioma del documento en el navegador (`<html lang>` o el del sistema), o null. */
74
+ export function browserLocale() {
75
+ // Solo en un navegador: Node 21+ también define `navigator`, y el SVG del servidor no debe depender de él.
76
+ if (typeof document === 'undefined') return null;
77
+ try {
78
+ const l = (document.documentElement && document.documentElement.lang) || (typeof navigator !== 'undefined' && navigator.language) || '';
79
+ const k = String(l).toLowerCase().slice(0, 2);
80
+ return LANGS.includes(k) ? k : null;
81
+ } catch { return null; }
82
+ }
83
+
84
+ /**
85
+ * Textos de un spec que delatan su idioma: título, bajada, fuente, nombres de
86
+ * columna y algunas categorías.
87
+ */
88
+ export function specTexts(spec, data) {
89
+ const out = [spec.title, spec.subtitle, spec.source, spec.dashedLabel];
90
+ for (const c of [].concat(Array.isArray(spec.columns) ? spec.columns : [])) {
91
+ if (typeof c === 'string') out.push(c);
92
+ else if (c && typeof c === 'object') out.push(c.label, c.field);
93
+ }
94
+ if (Array.isArray(data)) {
95
+ const rows = data.slice(0, 40);
96
+ const keys = new Set();
97
+ for (const r of rows) if (r && typeof r === 'object') for (const [k, v] of Object.entries(r)) { keys.add(k); if (typeof v === 'string' && v.length > 2 && !/^[\d\s.,%$€£-]+$/.test(v)) out.push(v); }
98
+ // Nombres de campo con guiones bajos o camelCase: "ventas_region" → "ventas region".
99
+ for (const k of keys) out.push(String(k).replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'));
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Resuelve `locale: "auto"`: idioma del texto, luego el del navegador, luego español. */
105
+ export function autoLocale(spec, data) {
106
+ return detectLocale(specTexts(spec, data)) || browserLocale() || 'es';
107
+ }
package/src/index.js CHANGED
@@ -31,8 +31,11 @@ import './geo/chile.js';
31
31
  import './geo/world.js';
32
32
  import './geo/usa.js';
33
33
  import { heatmap } from './charts/heatmap.js';
34
+ import { corr, pairplot, correlations } from './charts/corr.js';
34
35
  import { sankey } from './charts/sankey.js';
35
36
  import { kpi } from './charts/kpi.js';
37
+ import { table } from './charts/table.js';
38
+ import { autoLocale, detectLocale } from './i18n.js';
36
39
  import { ensureData, PlotError, fieldKind, uniq, num } from './data.js';
37
40
  import { facet } from './facet.js';
38
41
  import { parseDate } from './format.js';
@@ -59,8 +62,11 @@ const RENDERERS = {
59
62
  donut: (s, t) => donut(s, t),
60
63
  pie: (s, t) => pie(s, t),
61
64
  heatmap: (s, t) => heatmap(s, t),
65
+ corr: (s, t) => corr(s, t),
66
+ pairplot: (s, t) => pairplot(s, t),
62
67
  sankey: (s, t) => sankey(s, t),
63
68
  kpi: (s, t) => kpi(s, t),
69
+ table: (s, t) => table(s, t),
64
70
  hist: (s, t) => hist(s, t),
65
71
  kde: (s, t) => kde(s, t),
66
72
  ecdf: (s, t) => ecdf(s, t),
@@ -93,7 +99,7 @@ export function resolve(spec = {}) {
93
99
  // refLines / annotations: un objeto suelto vale como lista de uno.
94
100
  for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
95
101
  if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
96
- let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
102
+ let type = s.type && s.type !== 'auto' ? String(s.type).trim().toLowerCase() : null;
97
103
  if (type) {
98
104
  const [t, extra] = normalizeType(type);
99
105
  type = t;
@@ -105,6 +111,10 @@ export function resolve(spec = {}) {
105
111
  }
106
112
  if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
107
113
  s.type = type;
114
+ // Idioma: `locale: "auto"` (y las tablas sin `locale`) lo reconocen del texto del spec.
115
+ if (s.locale === 'auto' || (s.locale === undefined && type === 'table')) {
116
+ s.locale = autoLocale(s, Array.isArray(s.data) ? s.data : []);
117
+ }
108
118
  // Colormap divergente (RdBu, coolwarm…): centrada en 0 si los datos lo cruzan; si no, en el medio.
109
119
  const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
110
120
  if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
@@ -260,7 +270,7 @@ export function safePlot(spec) {
260
270
  // Se dibujó (con valores de respaldo), pero los errores de lint igual se devuelven: ok = sin errores.
261
271
  return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
262
272
  } catch (e) {
263
- const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
273
+ const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa los campos del spec contra el schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }];
264
274
  return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
265
275
  }
266
276
  }
@@ -299,7 +309,7 @@ export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 }
299
309
  ${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
300
310
  <style>
301
311
  :root{color-scheme:${dark ? 'dark' : 'light'}}
302
- body{margin:0;background:${bg};font-family:${esc(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
312
+ body{margin:0;background:${cssText(bg)};font-family:${cssText(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
303
313
  main{max-width:${columns > 1 ? 1500 : 820}px;margin:0 auto;padding:24px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,${columns > 1 ? 560 : 320}px),1fr));gap:24px}
304
314
  figure.pa{margin:0;min-width:0}
305
315
  figure.pa svg{width:100%;height:auto;display:block}
@@ -311,6 +321,9 @@ ${boot}
311
321
  </body></html>`;
312
322
  }
313
323
 
324
+ /** Texto dentro de <style>: las entidades HTML no se decodifican ahí (esc() rompería las comillas), así que se quitan los caracteres que cierran la regla o la etiqueta. */
325
+ const cssText = (v) => String(v ?? '').replace(/[<>{};\\&]/g, '');
326
+
314
327
  /** Código de la biblioteca en un archivo (para incrustar en HTML). */
315
328
  export function bundleSource(file = 'plotagents.js') {
316
329
  try {
@@ -327,7 +340,7 @@ _bindDashboard({ plot, resolve, lint, bundleSource, resolveDefaults: withDefault
327
340
  _setProbe((s) => plot(s));
328
341
  export { render };
329
342
 
330
- export { countWords, dashboardSchema };
343
+ export { countWords, dashboardSchema, detectLocale, correlations };
331
344
  export { resolveColorScale, colorScaleNames, interpolateColor, COLORMAPS, QUALITATIVE };
332
345
  export { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows };
333
346
  export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo, toFeatureCollection, loadGeo };