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
package/src/infer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Elige el tipo de gráfico cuando el agente no lo indica (type: "auto").
2
2
  import { fieldsOf, fieldKind } from './data.js';
3
3
 
4
- export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap'];
4
+ export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap', 'table', 'corr', 'pairplot'];
5
5
 
6
6
  // Nombres de seaborn, d3plus y otros: el agente puede usar el que conozca.
7
7
  export const ALIASES = {
@@ -16,6 +16,11 @@ export const ALIASES = {
16
16
  map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
17
17
  words: 'wordcloud', 'word-cloud': 'wordcloud', wordclouds: 'wordcloud', tagcloud: 'wordcloud', 'tag-cloud': 'wordcloud', nube: 'wordcloud', 'nube-de-palabras': 'wordcloud', cloud: 'wordcloud',
18
18
  timeline: 'gantt', roadmap: 'gantt', schedule: 'gantt', cronograma: 'gantt', 'carta-gantt': 'gantt', ganttchart: 'gantt', 'gantt-chart': 'gantt', planning: 'gantt',
19
+ tabla: 'table', tables: 'table', datatable: 'table', 'data-table': 'table', grid: 'table', tableau: 'table', tabelle: 'table', tabela: 'table', spreadsheet: 'table', planilla: 'table',
20
+ correlation: 'corr', correlations: 'corr', corrplot: 'corr', corrmatrix: 'corr', 'corr-matrix': 'corr', 'correlation-matrix': 'corr', correlogram: 'corr', correlograma: 'corr',
21
+ correlacion: 'corr', 'correlación': 'corr', 'matriz-correlacion': 'corr', 'matriz-de-correlacion': 'corr', 'matriz-de-correlación': 'corr',
22
+ clustermap: ['corr', { cluster: true }], 'cluster-map': ['corr', { cluster: true }],
23
+ pairs: 'pairplot', pairgrid: 'pairplot', splom: 'pairplot', scattermatrix: 'pairplot', 'scatter-matrix': 'pairplot', 'scatterplot-matrix': 'pairplot', 'matriz-de-dispersion': 'pairplot', 'matriz-de-dispersión': 'pairplot',
19
24
  tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
20
25
  };
21
26
 
@@ -29,6 +34,7 @@ export function normalizeType(t) {
29
34
 
30
35
  export function inferType(spec, data = []) {
31
36
  if (spec.items) return 'kpi';
37
+ if (Array.isArray(spec.columns) || spec.pageSize !== undefined) return 'table';
32
38
  if (spec.geo) return 'choropleth';
33
39
  if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
34
40
  { const f = fieldsOf(data); if (f.some((k) => /^(lat|latitude|latitud)$/i.test(k)) && f.some((k) => /^(lon|lng|long|longitude|longitud)$/i.test(k))) return 'pointmap'; }
@@ -39,6 +45,8 @@ export function inferType(spec, data = []) {
39
45
  if (spec.matrix) return 'chord';
40
46
  if (spec.from || spec.to) return 'sankey';
41
47
  if (spec.path || spec.group) return 'treemap';
48
+ if (spec.triangle !== undefined || spec.cluster !== undefined || spec.method !== undefined) return 'corr';
49
+ if (spec.diag !== undefined || spec.corner !== undefined || spec.upper !== undefined) return 'pairplot';
42
50
  if (spec.size) return 'bubble';
43
51
  const fields = fieldsOf(data);
44
52
  if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
package/src/lint.js CHANGED
@@ -8,14 +8,27 @@ import { themeFor, themes, palettes, ramps } from './theme.js';
8
8
  import { resolveColorScale, colorScaleNames, RAINBOW } from './colorscales.js';
9
9
  import { schema } from './schema.js';
10
10
  import { parseDate } from './format.js';
11
+ import { autoLocale } from './i18n.js';
12
+ import { COLUMN_TYPES, columnType } from './charts/table.js';
11
13
 
12
14
  /** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
13
15
  let RENDER_PROBE = null;
14
16
  export const _setProbe = (fn) => { RENDER_PROBE = fn; };
15
17
 
18
+ /** Nunca lanza: si la revisión misma falla con un spec raro, lo dice como aviso y sigue. */
16
19
  export function lint(spec = {}) {
20
+ try { return lintSpec(spec); }
21
+ catch (e) {
22
+ const type = spec && typeof spec === 'object' && typeof spec.type === 'string' ? spec.type : 'auto';
23
+ return { ok: true, type, errors: [], warnings: [{ code: 'lint-failed', message: `No se pudo revisar el spec por completo: ${String(e && e.message).split('\n')[0]}`, fix: 'Revisa que cada opción tenga el tipo del schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }], suggestions: [] };
24
+ }
25
+ }
26
+
27
+ function lintSpec(spec = {}) {
17
28
  if (!spec || typeof spec !== 'object') spec = {};
18
- const en = String(spec.locale || '').toLowerCase().startsWith('en');
29
+ // Idioma de los mensajes: el del gráfico (con `locale: "auto"`, y en tablas sin locale, el reconocido del texto).
30
+ const autoLang = spec.locale === 'auto' || (spec.locale === undefined && /^(table|tabla|tables|datatable|data-table|grid)$/i.test(String(spec.type || '')));
31
+ const en = (autoLang ? autoLocale(spec, Array.isArray(spec.data) ? spec.data : []) : String(spec.locale || '')).toLowerCase().startsWith('en');
19
32
  /** Mensajes nuevos: se escriben directo en ambos idiomas. */
20
33
  const L = (es, english) => (en ? english : es);
21
34
  const errors = [];
@@ -28,7 +41,7 @@ export function lint(spec = {}) {
28
41
  let data = [];
29
42
  const rawWords = !spec.data && (typeof spec.text === 'string' || Array.isArray(spec.text));
30
43
  try { data = spec.type === 'kpi' || spec.items || rawWords ? [] : ensureData(spec); } catch (e) { err('data', e.message.split('\n')[0], 'Pasa `data` como arreglo de filas: [{ categoria: "A", valor: 10 }]'); }
31
- const type = spec.type && spec.type !== 'auto' ? normalizeType(spec.type)[0] : inferType(spec, data);
44
+ const type = spec.type && spec.type !== 'auto' ? normalizeType(String(spec.type).trim())[0] : inferType(spec, data);
32
45
  if (!KNOWN_TYPES.has(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${[...KNOWN_TYPES].join(', ')}`);
33
46
  const fields = fieldsOf(data);
34
47
  const check = (f, role) => {
@@ -41,7 +54,7 @@ export function lint(spec = {}) {
41
54
  } else if (type !== 'kpi' && !rawWords && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
42
55
 
43
56
  if (!spec.title) sug('title', 'Falta el título.', 'Escribe el hallazgo como una frase completa: "Biobío concentra el 32% de las ventas", no "Ventas por región".');
44
- else if (!/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
57
+ else if (typeof spec.title === 'string' && !/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
45
58
  if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
46
59
 
47
60
  const series = spec.series ?? spec.color;
@@ -112,6 +125,26 @@ export function lint(spec = {}) {
112
125
  for (const f of [spec.x, spec.y]) if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: la regresión necesita dos medidas.`, 'Usa `type: "box"` para comparar una medida entre categorías.');
113
126
  if (data.length < 8) warn('few-obs', `Solo ${data.length} puntos: la recta y su intervalo son poco confiables.`, 'Muestra los puntos con `type: "scatter"`.');
114
127
  }
128
+ if ((type === 'corr' || type === 'pairplot') && data.length) {
129
+ const longForm = type === 'corr' && typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
130
+ if (!longForm) {
131
+ const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
132
+ if (spec.vars !== undefined && !Array.isArray(spec.vars)) err('corr-vars', L('`vars` debe ser una lista de campos.', '`vars` must be a list of fields.'), 'vars: ["precio", "superficie", "antiguedad"]');
133
+ if (Array.isArray(spec.vars)) check(spec.vars, 'vars');
134
+ const hueF = spec.hue ?? spec.series;
135
+ const nums = (Array.isArray(list) ? list : fields.filter((f) => f !== hueF && f !== spec.label && fieldKind(data, f) === 'number')).filter((f) => fields.includes(f));
136
+ if (Array.isArray(list)) for (const f of list) if (fields.includes(f) && fieldKind(data, f) !== 'number') err('corr-numeric', L(`"${f}" no es numérico: una correlación necesita medidas.`, `"${f}" is not numeric: a correlation needs measures.`), L('Quítalo de `vars`; para comparar una medida entre categorías usa `type: "box"`.', 'Drop it from `vars`; to compare a measure across categories use `type: "box"`.'));
137
+ if (nums.length < 2) err('corr-vars', L(`Hay ${nums.length} campo(s) numérico(s): se necesitan al menos dos.`, `There are ${nums.length} numeric field(s): at least two are needed.`), L('Entrega filas con dos o más medidas, o indícalas en `vars: [...]`.', 'Pass rows with two or more measures, or list them in `vars: [...]`.'));
138
+ const constant = nums.filter((f) => { const vs = data.map((r) => num(r[f])).filter(Number.isFinite); return vs.length > 1 && vs.every((v) => v === vs[0]); });
139
+ if (constant.length) warn('corr-constant', L(`Sin variación: ${constant.join(', ')}. Su correlación no está definida (celda vacía).`, `No variation: ${constant.join(', ')}. Its correlation is undefined (empty cell).`), L('Quítalos de `vars`.', 'Drop them from `vars`.'));
140
+ if (data.length < 10) warn('corr-few-rows', L(`Solo ${data.length} filas: una correlación con tan pocos casos es inestable.`, `Only ${data.length} rows: a correlation from so few cases is unstable.`), L('Muestra los puntos con `type: "scatter"`, o agrega `alpha: 0.05` para apagar lo no significativo.', 'Show the points with `type: "scatter"`, or add `alpha: 0.05` to grey out what is not significant.'));
141
+ if (type === 'corr' && nums.length > 12 && !spec.cluster) sug('corr-many', L(`${nums.length} variables: el orden de los datos esconde los grupos que se mueven juntos.`, `${nums.length} variables: data order hides the groups that move together.`), L('Agrega `cluster: true` (o usa `type: "clustermap"`).', 'Add `cluster: true` (or use `type: "clustermap"`).'));
142
+ if (type === 'corr' && nums.length > 30) warn('corr-too-many', L(`${nums.length} variables: las celdas quedan demasiado chicas para leerse.`, `${nums.length} variables: cells get too small to read.`), L('Elige las que importan con `vars: [...]`.', 'Pick the ones that matter with `vars: [...]`.'));
143
+ if (type === 'pairplot' && !Array.isArray(list) && nums.length > 6) warn('pairplot-many', L(`${nums.length} campos numéricos: se dibujan solo los 6 primeros (${nums.slice(0, 6).join(', ')}).`, `${nums.length} numeric fields: only the first 6 are drawn (${nums.slice(0, 6).join(', ')}).`), L('Elige con `vars: [...]`, o usa `type: "corr"` para verlos todos.', 'Choose with `vars: [...]`, or use `type: "corr"` to see them all.'));
144
+ if (type === 'pairplot' && Array.isArray(list) && list.length > 7) warn('pairplot-many', L(`${list.length} variables son ${list.length * list.length} paneles diminutos.`, `${list.length} variables make ${list.length * list.length} tiny panels.`), L('Deja 3–6 en `vars`, o usa `type: "corr"`.', 'Keep 3–6 in `vars`, or use `type: "corr"`.'));
145
+ if (type === 'pairplot' && data.length > 3000) sug('pairplot-dense', L(`${data.length} filas: los puntos se tapan en cada panel.`, `${data.length} rows: points hide each other in every panel.`), L('Usa `type: "corr"`, o `radius: 1` con una muestra.', 'Use `type: "corr"`, or `radius: 1` with a sample.'));
146
+ }
147
+ }
115
148
  if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
116
149
  const counts = new Map();
117
150
  for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
@@ -165,6 +198,29 @@ export function lint(spec = {}) {
165
198
  if (data.length > 25 && spec.collapsed === undefined) sug('gantt-collapse', L(`${data.length} tareas: plegar las fases terminadas acorta la carta.`, `${data.length} tasks: collapsing finished phases shortens the chart.`), L(`Agrega \`collapsed: ["${phases[0]}"]\` (o \`true\` para ver solo el resumen).`, `Add \`collapsed: ["${phases[0]}"]\` (or \`true\` for the summary only).`));
166
199
  }
167
200
  }
201
+ if (type === 'table' && data.length) {
202
+ const cols = Array.isArray(spec.columns) ? spec.columns : [];
203
+ for (const c of cols) {
204
+ const f = typeof c === 'string' ? c : c && typeof c === 'object' ? c.field ?? c.key ?? c.name : undefined;
205
+ if (c && typeof c === 'object' && c.type !== undefined && !columnType(c.type)) {
206
+ const near = didYouMean(String(c.type), COLUMN_TYPES);
207
+ err('table-column-type', L(`Tipo de columna "${c.type}" desconocido${f ? ` (columna "${f}")` : ''}.`, `Unknown column type "${c.type}"${f ? ` (column "${f}")` : ''}.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Tipos: ${COLUMN_TYPES.join(', ')}.`, `Types: ${COLUMN_TYPES.join(', ')}.`));
208
+ }
209
+ if (f === undefined && !(c && typeof c === 'object' && columnType(c.type) === 'rank')) err('table-column', L('Una columna no dice qué campo muestra.', 'A column does not say which field it shows.'), L('Usa `columns: ["region", { field: "ventas", type: "bar" }]`.', 'Use `columns: ["region", { field: "sales", type: "bar" }]`.'));
210
+ else if (f !== undefined) check(f, 'columns');
211
+ if (c && typeof c === 'object' && typeof c.target === 'string') check(c.target, 'columns.target');
212
+ }
213
+ const by = spec.sortBy ?? spec.orderBy;
214
+ if (by !== undefined && !fields.includes(by) && !cols.some((c) => c && typeof c === 'object' && String(c.label) === String(by)) && !['#', 'rank'].includes(String(by))) {
215
+ const near = didYouMean(String(by), fields);
216
+ err('table-sort', L(`\`sortBy: "${by}"\` no es una columna.`, `\`sortBy: "${by}"\` is not a column.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Campos: ${fields.join(', ')}.`, `Fields: ${fields.join(', ')}.`));
217
+ }
218
+ if (spec.pageSize !== undefined && spec.pageSize !== false && !(Number.isFinite(+spec.pageSize) && +spec.pageSize >= 1)) err('table-page-size', L('`pageSize` debe ser un entero positivo (o false).', '`pageSize` must be a positive integer (or false).'), 'pageSize: 10');
219
+ const nCols = cols.length || fields.length;
220
+ if (nCols > 8) warn('table-wide', L(`${nCols} columnas: en pantallas angostas la tabla pasa a tarjetas largas y en escritorio se aprieta.`, `${nCols} columns: on narrow screens the table turns into long cards, and it gets cramped on desktop.`), L('Deja las 4–7 columnas que responden la pregunta del título con `columns: [...]`.', 'Keep the 4–7 columns that answer the title\'s question with `columns: [...]`.'));
221
+ if (data.length > 15 && spec.pageSize === false) sug('table-long', L(`${data.length} filas sin paginar hacen una imagen muy alta.`, `${data.length} unpaginated rows make a very tall image.`), L('Quita `pageSize: false` (páginas de 10), o usa `top: 15` con un orden.', 'Drop `pageSize: false` (pages of 10), or use `top: 15` with a sort.'));
222
+ if (data.length > 0 && data.length <= 3 && nCols <= 2) sug('table-tiny', L('Una tabla de pocas cifras dice más como indicadores.', 'A table with so few numbers works better as indicators.'), L('Usa `type: "kpi"` con `items`.', 'Use `type: "kpi"` with `items`.'));
223
+ }
168
224
  if (type === 'kpi' && ![].concat(spec.items || spec.data || []).length) err('items', 'El KPI necesita `items`.', 'items: [{ label: "Ventas", value: 1250, delta: 0.12, period: "vs 2025" }]');
169
225
 
170
226
  checkOptions(spec, { type, data, fields, err, warn, sug, L });
@@ -254,6 +310,7 @@ const EXTRA_OPTIONS = `
254
310
  tileOpacity tileStyle tiles tilesKey todayColor truncate unit valueDomain valueFormat valueScale weekends word
255
311
  xFormat yFormat yTicks zoom curveness exclude colorScale filters kpis charts updated primary layout
256
312
  after area good minLength sizeKey x2
313
+ orderBy perPage paginate stacked totalLabel
257
314
  id name description notes meta alt $schema comment
258
315
  `.trim().split(/\s+/);
259
316
 
@@ -341,6 +398,11 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
341
398
  if (pos !== undefined) enumCheck('legend', String(pos).toLowerCase(), LEGEND_POS, 'true, false, "top", "bottom", "left", "right" o { position }');
342
399
  }
343
400
  if (type === 'network' && spec.layout !== undefined) enumCheck('layout', String(spec.layout).toLowerCase(), NETWORK_LAYOUTS, '"force", "circular", "radial", "concentric", "clusters", "tree", "grid"');
401
+ if (type === 'corr' && spec.method !== undefined) enumCheck('method', String(spec.method).toLowerCase(), ['pearson', 'spearman']);
402
+ if (type === 'corr' && typeof spec.triangle === 'string') enumCheck('triangle', spec.triangle, ['lower', 'full']);
403
+ if (type === 'corr' && spec.alpha !== undefined && !(+spec.alpha > 0 && +spec.alpha < 1)) err('corr-alpha', L('`alpha` es un nivel de significancia entre 0 y 1.', '`alpha` is a significance level between 0 and 1.'), 'alpha: 0.05');
404
+ if (type === 'pairplot' && spec.diag !== undefined) enumCheck('diag', spec.diag, ['hist', 'kde']);
405
+ if (type === 'pairplot' && typeof spec.upper === 'string') enumCheck('upper', spec.upper, ['corr', 'scatter']);
344
406
  if (type === 'pointmap') {
345
407
  if (typeof spec.tiles === 'string') enumCheck('tiles', spec.tiles.toLowerCase(), TILES, '"osm", "esri-gray", "esri-dark", "carto-light", "carto-dark", "mapbox-light", "mapbox-dark", { url, attribution } o false');
346
408
  if (spec.tileStyle !== undefined) enumCheck('tileStyle', spec.tileStyle, ['muted', 'original']);
@@ -361,7 +423,7 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
361
423
  const base = String(nameOf).replace(/_r$/i, '');
362
424
  const near = didYouMean(base, all);
363
425
  err('colorscale-unknown', L(`La escala de color "${nameOf}" no existe.`, `The color scale "${nameOf}" does not exist.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Usa un nombre de matplotlib ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` invierte) o una lista de colores hex.', 'Use a matplotlib name ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` reverses) or a list of hex colors.'));
364
- } else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores hex (["#FFF", "#123"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two hex colors (["#FFF", "#123"]) or { name, reverse, mid }.'));
426
+ } else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores (["#FFF", "steelblue"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two colors (["#FFF", "steelblue"]) or { name, reverse, mid }.'));
365
427
  } else if (cs.kind === 'qualitative') {
366
428
  err('colorscale-qualitative', L(`"${cs.name}" es una paleta cualitativa: no sirve para una escala continua.`, `"${cs.name}" is a qualitative palette: it cannot be a continuous scale.`), L(`Usa \`palette: "${cs.name}"\` para categorías, o una escala como "viridis" o "Blues".`, `Use \`palette: "${cs.name}"\` for categories, or a scale such as "viridis" or "Blues".`));
367
429
  } else if (RAINBOW.has(cs.name.replace(/_r$/, ''))) {
package/src/runtime.js CHANGED
@@ -7,6 +7,7 @@ import { ensureData, fieldKind, uniq, num, PlotError } from './data.js';
7
7
  import { parseDate, formatDate } from './format.js';
8
8
  import { dateUnit } from './scale.js';
9
9
  import { isGeoUrl } from './geo/registry.js';
10
+ import { words } from './i18n.js';
10
11
 
11
12
  let plotFn = null;
12
13
  let resolveFn = null;
@@ -56,7 +57,12 @@ function ensureAssets() {
56
57
  .pa-live .pa-chev{opacity:1!important}
57
58
  .pa-chart .pa-gh:hover .pa-hit{fill:var(--pa-tint,#ECEEF8);fill-opacity:.5}
58
59
  .pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
59
- @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}}`;
60
+ .pa-search{position:absolute;z-index:2;box-sizing:border-box;margin:0;font:inherit;font-size:13px;color:var(--pa-ink);background-color:var(--pa-surface);background-repeat:no-repeat;background-position:10px center;border:1px solid var(--pa-line);border-radius:8px;padding:0 10px 0 32px;outline:none;transition:border-color .15s,box-shadow .15s;-webkit-appearance:none;appearance:none}
61
+ .pa-search::placeholder{color:var(--pa-muted);opacity:1}
62
+ .pa-search:hover{border-color:var(--pa-muted)}
63
+ .pa-search:focus-visible,.pa-search:focus{border-color:var(--pa-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--pa-accent) 22%,transparent)}
64
+ .pa-chart .pa-th[data-sort]:focus-visible,.pa-chart .pa-pg:focus-visible{outline:2px solid var(--pa-accent);outline-offset:-2px}
65
+ @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}.pa-search{transition:none}}`;
60
66
  document.head.appendChild(st);
61
67
  }
62
68
 
@@ -117,6 +123,39 @@ export function render(target, spec, opts = {}) {
117
123
  let timer = null, ro = null, raf = 0;
118
124
 
119
125
  const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
126
+ // Tabla: caja de búsqueda (sobre 20 filas, o `search: true`), pegada a la franja que la tabla reserva.
127
+ let search = null, searchTimer = 0;
128
+ if (state.type === 'table' && state.search !== false) {
129
+ let n = 0;
130
+ try { n = ensureData(state).length; } catch { n = 0; }
131
+ if (state.search === true || n > 20) {
132
+ const th = themeFor(state);
133
+ const W = words(state.locale);
134
+ search = document.createElement('input');
135
+ search.type = 'search'; search.className = 'pa-search';
136
+ search.placeholder = W.search + '…'; search.setAttribute('aria-label', W.search);
137
+ search.autocomplete = 'off'; search.spellcheck = false;
138
+ if (state.query) search.value = state.query;
139
+ const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.25" fill="none" stroke="${th.muted}" stroke-width="1.5"/><path d="M9.2 9.2l3.3 3.3" stroke="${th.muted}" stroke-width="1.5" stroke-linecap="round"/></svg>`;
140
+ search.style.cssText = `--pa-accent:${th.categorical[0]};--pa-surface:${th.surface};--pa-ink:${th.ink};--pa-muted:${th.muted};--pa-line:${th.axis};font-family:${(th.font?.family || 'system-ui').replace(/"/g, "'")};background-image:url("data:image/svg+xml,${encodeURIComponent(icon)}")`;
141
+ search.addEventListener('input', () => {
142
+ clearTimeout(searchTimer);
143
+ searchTimer = setTimeout(() => { state = { ...state, query: search.value.trim() || undefined, page: 1 }; draw(); }, 120);
144
+ });
145
+ search.addEventListener('keydown', (e) => { if (e.key === 'Escape' && search.value) { e.stopPropagation(); search.value = ''; state = { ...state, query: undefined, page: 1 }; draw(); } });
146
+ if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
147
+ host.appendChild(search);
148
+ }
149
+ }
150
+ const placeSearch = () => {
151
+ if (!search) return;
152
+ const slot = chart.querySelector('.pa-search-slot');
153
+ if (!slot) { search.hidden = true; return; }
154
+ search.hidden = false;
155
+ const r = slot.getBoundingClientRect(), h = host.getBoundingClientRect();
156
+ search.style.left = r.left - h.left + 'px'; search.style.top = r.top - h.top + 'px';
157
+ search.style.width = r.width + 'px'; search.style.height = r.height + 'px';
158
+ };
120
159
  // Posiciones anteriores de los nodos (para animar el cambio de foco).
121
160
  const nodePos = () => {
122
161
  const m = new Map();
@@ -183,7 +222,7 @@ export function render(target, spec, opts = {}) {
183
222
  const draw = () => {
184
223
  const before = nodePos();
185
224
  const fills = regionFills();
186
- chart.innerHTML = plotFn({ ...state, width: width() });
225
+ chart.innerHTML = plotFn({ ...state, width: width(), _search: !!search });
187
226
  animateFrom(before);
188
227
  tweenFills(fills);
189
228
  chart.__pa = false;
@@ -232,6 +271,53 @@ export function render(target, spec, opts = {}) {
232
271
  n.addEventListener('click', go);
233
272
  n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
234
273
  }
274
+ // Tabla: paso de página y orden por columna (clic o Enter/Espacio; ←/→ en el paginador).
275
+ placeSearch();
276
+ const refocus = (sel) => { const m = chart.querySelector(sel); if (m) m.focus(); return !!m; };
277
+ // Las flechas ‹ › llevan data-arrow; tras el cambio se busca la misma flecha.
278
+ const refocusSame = (arrow, p) => (arrow ? refocus(`[data-arrow="${arrow}"][data-page]`) : refocus(`[data-page="${p}"]:not([data-arrow])`));
279
+ for (const n of chart.querySelectorAll('[data-page]')) {
280
+ const p = +n.getAttribute('data-page');
281
+ const arrow = n.getAttribute('data-arrow');
282
+ n.setAttribute('tabindex', '0');
283
+ n.setAttribute('role', 'button');
284
+ const go = (e) => {
285
+ state = { ...state, page: p };
286
+ draw();
287
+ // Con teclado el foco sigue en el mismo botón (o en la página actual si esa flecha se apagó).
288
+ if (e && e.type === 'keydown' && !refocusSame(arrow, p)) refocus('.pa-pg-on');
289
+ };
290
+ n.addEventListener('click', go);
291
+ n.addEventListener('keydown', (e) => {
292
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); return; }
293
+ const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
294
+ if (!d) return;
295
+ const pages = [...chart.querySelectorAll('[data-page]')];
296
+ const k = pages.indexOf(n) + d;
297
+ if (pages[k]) { e.preventDefault(); pages[k].focus(); }
298
+ });
299
+ }
300
+ for (const n of chart.querySelectorAll('.pa-th[data-sort]')) {
301
+ const f = n.getAttribute('data-sort');
302
+ n.setAttribute('tabindex', '0');
303
+ n.setAttribute('aria-label', `${words(state.locale).sort} ${n.textContent}`);
304
+ // Primer clic: el orden natural (cifras de mayor a menor, texto A→Z); segundo: al revés; tercero: el orden original.
305
+ const go = (e) => {
306
+ const cur = n.getAttribute('aria-sort');
307
+ const numeric = n.getAttribute('data-num') === '1';
308
+ const first = numeric ? 'desc' : 'asc';
309
+ const firstAria = first === 'desc' ? 'descending' : 'ascending';
310
+ let next;
311
+ if (cur === 'none' || !cur) next = { sortBy: f, sort: first };
312
+ else if (cur === firstAria) next = { sortBy: f, sort: first === 'desc' ? 'asc' : 'desc' };
313
+ else next = { sortBy: undefined, sort: undefined };
314
+ state = { ...state, ...next, sortOrder: undefined, page: 1 };
315
+ draw();
316
+ if (e && e.type === 'keydown') refocus(`.pa-th[data-sort="${CSS.escape ? CSS.escape(f) : f}"]`);
317
+ };
318
+ n.addEventListener('click', go);
319
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
320
+ }
235
321
  };
236
322
  const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
237
323
 
@@ -346,13 +432,15 @@ export function render(target, spec, opts = {}) {
346
432
  set();
347
433
  }
348
434
 
349
- host.addEventListener('keydown', (e) => {
435
+ // El oyente se guarda para quitarlo en update()/destroy(): si no, cada redibujo (filtros de un dashboard) suma uno más.
436
+ const onEsc = (e) => {
350
437
  if (e.key !== 'Escape') return;
351
438
  if (state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); return; }
352
439
  // Treemap/sunburst: Esc sube un nivel; en la raíz, vuelve a sumar lo excluido.
353
440
  if (Array.isArray(state.focus) && state.focus.length) { state = { ...state, focus: state.focus.slice(0, -1) }; draw(); return; }
354
441
  if (state.exclude && [].concat(state.exclude).length) { state = { ...state, exclude: undefined }; draw(); }
355
- });
442
+ };
443
+ host.addEventListener('keydown', onEsc);
356
444
 
357
445
  draw();
358
446
  if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
@@ -363,8 +451,8 @@ export function render(target, spec, opts = {}) {
363
451
  return {
364
452
  get svg() { return chart.querySelector('svg'); },
365
453
  get spec() { return state; },
366
- update(next) { clearInterval(timer); ro && ro.disconnect(); return render(host, next, opts); },
367
- destroy() { clearInterval(timer); ro && ro.disconnect(); host.innerHTML = ''; },
454
+ update(next) { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); return render(host, next, opts); },
455
+ destroy() { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); host.innerHTML = ''; },
368
456
  };
369
457
  }
370
458
 
package/src/scale.js CHANGED
@@ -39,7 +39,8 @@ export function niceDomain(min, max, count = 5, { integer = false } = {}) {
39
39
  step = st(hi - lo);
40
40
  lo = Math.floor(min / step + 1e-9) * step;
41
41
  hi = Math.ceil(max / step - 1e-9) * step;
42
- return { min: lo, max: hi, step };
42
+ // Sin ruido de punto flotante (0,30000000000000004 → 0,3).
43
+ return { min: +lo.toPrecision(15), max: +hi.toPrecision(15), step: +step.toPrecision(15) };
43
44
  }
44
45
 
45
46
  export function linear(d0, d1, r0, r1) {
@@ -57,7 +58,7 @@ export function linear(d0, d1, r0, r1) {
57
58
  if ((hi - lo) / st > 1000) st = niceStep(hi - lo, 5);
58
59
  const start = Math.ceil(lo / st - 1e-9) * st;
59
60
  if (start + st === start) return [lo];
60
- for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(12));
61
+ for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(15));
61
62
  return out;
62
63
  };
63
64
  return s;
package/src/schema.js CHANGED
@@ -38,7 +38,7 @@ export const schema = {
38
38
  type: 'string',
39
39
  enum: [...new Set(['auto', ...TYPES, 'pointmap', ...Object.keys(ALIASES)])],
40
40
  description:
41
- 'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar. Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
41
+ 'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar, corr (correlation matrix; clustermap), pairplot (scatter-plot matrix). Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
42
42
  },
43
43
  data: {
44
44
  description: 'Array of rows, e.g. [{ "region": "Biobío", "sales": 1240 }]. For kpi it can be omitted in favor of `items`.',
@@ -47,7 +47,7 @@ export const schema = {
47
47
  title: { type: 'string', description: 'The finding as a full sentence, ideally with the key number, e.g. "Biobío accounts for 32% of sales".' },
48
48
  subtitle: { type: 'string', description: 'How to read the chart: measure, unit and period, e.g. "Sales by region, CLP millions, 2025".' },
49
49
  source: { type: 'string', description: 'Data source, shown at the bottom right.' },
50
- locale: { type: 'string', enum: ['es', 'en'], description: 'Language and number format. Default "es" (1.234,5); use "en" for 1,234.5.' },
50
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'], description: 'Language and number format. Default "es" (1.234,5); "en" for 1,234.5; also "pt", "fr", "de". "auto" recognizes the language from the title, subtitle, column names and categories (tables do this when `locale` is omitted).' },
51
51
  x: field('x-axis or category field (bar, line, area, scatter, heatmap, box, violin…).'),
52
52
  y: fieldOrList('Value field. For line/bar a list of fields (wide table) turns each one into a series.'),
53
53
  series: field('Field that splits the data into colored series. Alias: hue.'),
@@ -55,7 +55,7 @@ export const schema = {
55
55
  hue: field('Alias of series (seaborn naming).'),
56
56
  value: field('Value field for treemap, sunburst, donut, heatmap, sankey and radar.'),
57
57
  label: field('Name field (treemap, donut, point labels in scatter/regplot).'),
58
- group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase).'),
58
+ group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase). table: rows grouped under a header per value.'),
59
59
  path: { type: 'array', items: { type: 'string' }, description: 'Hierarchy for treemap/sunburst, any depth: [level1, level2, …].' },
60
60
  size: field('Bubble size field (area-proportional).'),
61
61
  from: field('Sankey source field.'),
@@ -158,7 +158,33 @@ export const schema = {
158
158
  facet: field('One panel per value of this field (small multiples, seaborn col). Shared scales and colors.'),
159
159
  col: field('Alias of facet (seaborn naming).'),
160
160
  facetOrder: { type: 'array', items: { type: 'string' }, description: 'facet: panel order (values of the facet field; the rest follow).' },
161
- columns: { type: 'integer', description: 'facet: panels per row (seaborn col_wrap). Reduced automatically on narrow screens. kpi: tiles per row.' },
161
+ columns: {
162
+ description: 'table: the columns, in order: field names or objects { field, label, type, format, align, width, good, target, colorScale, total, lines }. Column types: text, number, bar (data bar), heat (cell color), delta (▲/▼ change, colored by `good`), sparkline (field holds a list of numbers), progress (meter vs `target` field/number, or 0–1 / 0–100), badge (status pill; status words in es/en/pt/fr/de get good/warning/critical colors), check (booleans as ✓), date, link, rank. Omit to infer everything. facet: panels per row (an integer, seaborn col_wrap). kpi: tiles per row.',
163
+ anyOf: [
164
+ { type: 'integer' },
165
+ { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: {
166
+ field: { type: 'string' }, label: { type: 'string' },
167
+ type: { type: 'string', enum: ['text', 'number', 'bar', 'heat', 'delta', 'sparkline', 'progress', 'badge', 'check', 'date', 'link', 'rank'] },
168
+ format: { anyOf: [{ type: 'string' }, { type: 'object' }] }, align: { type: 'string', enum: ['left', 'right', 'center'] }, width: { type: 'number' },
169
+ good: { type: 'string', enum: ['up', 'down'] }, target: { anyOf: [{ type: 'string' }, { type: 'number' }] }, colorScale: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }, { type: 'object' }] },
170
+ total: { anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max', 'count'] }] }, lines: { type: 'integer', minimum: 1, maximum: 6 }, text: { type: 'string', description: 'link: field with the text to show instead of the URL.' }, sortable: { type: 'boolean' },
171
+ } }] } },
172
+ ],
173
+ },
174
+ sortBy: field('table: column to sort by (field or label). Numbers sort high→low by default, text A→Z. In the browser a click on a header sorts.'),
175
+ sortOrder: { type: 'string', enum: ['asc', 'desc'], description: 'table: sort direction (same as `sort: "asc" | "desc"`).' },
176
+ pageSize: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'boolean', enum: [false] }], description: 'table: rows per page. Default: all rows up to 15, then pages of 10. false shows every row.' },
177
+ page: { type: 'integer', minimum: 1, description: 'table: page to show (1-based). In the browser the pager changes it.' },
178
+ query: { type: 'string', description: 'table: show only rows that contain these words (accent- and case-insensitive).' },
179
+ search: { type: 'boolean', description: 'table: search box in the browser (default: on above 20 rows).' },
180
+ total: { description: 'table: totals row. true sums numbers (and averages percents and progress); "mean"/"sum"; or { field: "sum" | "mean" | false, label }.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max'] }, { type: 'object' }] },
181
+ totalLabel: { type: 'string', description: 'table: label of the totals row (default "Total"/"Gesamt").' },
182
+ rank: { type: 'boolean', description: 'table: add a "#" column with the position under the current sort.' },
183
+ cards: { description: 'table: one card per row. "auto" (default) switches when the columns do not fit (phones); true always; false never (columns shrink and truncate).', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['auto'] }] },
184
+ density: { type: 'string', enum: ['compact', 'normal', 'comfortable'], description: 'table: row height.' },
185
+ striped: { type: 'boolean', description: 'table: tint every other row (helps on very wide tables).' },
186
+ bars: { type: 'boolean', description: 'table: false turns off the automatic data bar on the main numeric column.' },
187
+ sortable: { type: 'boolean', description: 'table: false disables click-to-sort on headers in the browser.' },
162
188
  sharex: bool('facet: same x scale in every panel (default true).'),
163
189
  panelHeight: number('facet: height of each panel in px.'),
164
190
  sharey: { type: 'boolean', description: 'facet: same y scale in every panel (default true).' },
@@ -194,7 +220,7 @@ export const schema = {
194
220
  yFormat: formatSpec('Format of y values (overrides `format` on the y axis).'),
195
221
 
196
222
  // Emphasis and annotation
197
- highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
223
+ highlight: { description: 'Emphasize one or more categories/series and mute the rest. table: the rows whose label matches.', ...strOrList },
198
224
  dashed: { description: 'Draw some shapes dashed (hatched fill + dashed outline; dashed stroke for lines) to mark estimates, projections or provisional figures. A category/series/region name or a list of them, a data field whose truthy rows are dashed, { field, equals }, or true for all.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { type: 'object', properties: { field: { type: 'string' }, equals: {} } }] },
199
225
  dashedFrom: { description: 'line/area/bar on a time axis: x value from which marks are dashed (a projection). Lines turn dashed from the last firm point, over a soft labeled band.', anyOf: [{ type: 'string' }, { type: 'number' }] },
200
226
  dashedLabel: { description: 'What the dashed shapes mean ("Projection", "Preliminary"): legend entry, band label and tooltip line. false hides the band label.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
@@ -205,7 +231,7 @@ export const schema = {
205
231
  // Bars and lines
206
232
  stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
207
233
  horizontal: { type: 'boolean', description: 'Force horizontal orientation (otherwise chosen from label length and count).' },
208
- top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
234
+ top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other". table: keep the first N rows after sorting.' },
209
235
  endLabels: bool('line/regplot: false removes the series names at the end of each line (a legend is drawn instead).'),
210
236
  labelsFor: { type: 'object', additionalProperties: { type: 'string' }, description: 'Wide table (y as a list): display names per column, e.g. { "rev": "Revenue" }.' },
211
237
  index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
@@ -222,12 +248,12 @@ export const schema = {
222
248
  kde: { type: 'boolean', description: 'hist: overlay a density curve.' },
223
249
  multiple: { type: 'string', enum: ['layer', 'stack', 'dodge', 'fill'], description: 'hist/kde with hue: how groups are combined.' },
224
250
  fill: { type: 'boolean', description: 'kde: fill the area. line: false removes the gradient under a single line.' },
225
- order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
251
+ order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list). corr: variable order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
226
252
  lowess: { type: 'boolean', description: 'regplot: local regression instead of a straight line.' },
227
253
  logx: { type: 'boolean', description: 'regplot: fit y ~ log(x) with a log x axis.' },
228
254
  trend: { type: 'boolean', description: 'scatter: add a least-squares line and R².' },
229
255
  annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
230
- kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
256
+ kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot. pairplot: "reg" adds a least-squares line to every scatter panel.' },
231
257
  marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
232
258
  inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'stick', 'none'], description: 'violin: what goes inside.' },
233
259
  bandwidth: number('violin: KDE bandwidth (default Scott\'s rule).'),
@@ -252,6 +278,17 @@ export const schema = {
252
278
  centerValue: { type: 'string', description: 'donut/sunburst: text in the center (default: the total).' },
253
279
  centerLabel: { type: 'string', description: 'donut/sunburst: caption under the center value.' },
254
280
  cellHeight: number('heatmap: maximum cell height in px.'),
281
+ vars: { type: 'array', items: { type: 'string' }, description: 'corr/pairplot: the numeric fields to relate, in order (seaborn `vars`). Default: every numeric field (pairplot: the first 6). `y: [...]` works too.' },
282
+ method: { type: 'string', enum: ['pearson', 'spearman'], description: 'corr: correlation coefficient. "pearson" (default, linear) or "spearman" (ranks: monotonic relations, robust to outliers).' },
283
+ triangle: { anyOf: [{ type: 'string', enum: ['lower', 'full'] }, { type: 'boolean' }], description: 'corr: "lower" (default) shows each pair once, below the diagonal; "full" shows the whole symmetric matrix (default with `cluster`).' },
284
+ cluster: bool('corr: reorder variables by hierarchical clustering (average linkage on 1 − r) and draw the dendrogram on top, like sns.clustermap. `type: "clustermap"` sets it.'),
285
+ dendrogram: bool('corr with `cluster`: false hides the dendrogram but keeps the clustered order.'),
286
+ alpha: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1, description: 'corr: significance level (e.g. 0.05). Cells with p ≥ alpha are greyed out and a legend key explains it. p-values use Fisher\'s z.' },
287
+ cellSize: number('corr: maximum cell size in px (cells are square).'),
288
+ diag: { type: 'string', enum: ['hist', 'kde'], description: 'pairplot: what goes on the diagonal. Default "hist", or "kde" when `hue` is set.' },
289
+ upper: { anyOf: [{ type: 'string', enum: ['corr', 'scatter'] }, { type: 'boolean', enum: [false] }], description: 'pairplot: above the diagonal, "corr" (default: Pearson r, tinted by strength, per `hue` group when there is room), "scatter" (mirror), or false (empty).' },
290
+ corner: bool('pairplot: draw only the lower triangle and the diagonal (seaborn corner=True).'),
291
+ panelSize: number('pairplot: maximum panel size in px.'),
255
292
  diverging: bool('heatmap/choropleth: diverging color scale around 0 (or `midpoint`).'),
256
293
 
257
294
  // Appearance
@@ -293,13 +330,12 @@ export const dashboardSchema = {
293
330
  title: 'plotagents dashboard',
294
331
  description: 'A dashboard with best-practice defaults: KPIs on top, the main chart wide, the rest in balanced full rows of a 12-column grid, one color per entity across charts, shared filters, one source line.',
295
332
  type: 'object',
296
- required: ['charts'],
297
333
  properties: {
298
334
  title: { type: 'string', description: 'The question the dashboard answers, e.g. "How are southern sales doing?"' },
299
335
  subtitle: { type: 'string', description: 'Scope: measure, unit, period.' },
300
336
  source: { type: 'string', description: 'Data source, shown once at the bottom.' },
301
337
  updated: { type: 'string', description: 'Last update, e.g. "2026-01-05".' },
302
- locale: { type: 'string', enum: ['es', 'en'] },
338
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'] },
303
339
  theme: { description: 'One theme for every chart: "papel" (light) or "noche" (dark), or a theme object.', anyOf: [{ type: 'string' }, { type: 'object' }] },
304
340
  data: { type: 'array', items: { type: 'object' }, description: 'Shared rows: charts without their own `data` use these, and computed KPIs and filters work on them.' },
305
341
  kpis: {
@@ -310,6 +346,7 @@ export const dashboardSchema = {
310
346
  scale: { type: 'number', description: 'Multiply the computed value (e.g. 1000 to show thousands).' },
311
347
  compare: { type: 'object', description: 'Current vs previous period: { field: "year", current: 2025, previous: 2024 }.', properties: { field: { type: 'string' }, current: {}, previous: {} } },
312
348
  trendBy: { type: 'string', description: 'Field to draw a sparkline by (e.g. "month").' },
349
+ previous: { type: 'number', description: 'Previous value: the change is computed from it.' }, display: { type: 'string', description: 'Text shown instead of the formatted value.' },
313
350
  format: {}, good: { type: 'string', enum: ['up', 'down'] }, trend: { type: 'array', items: { type: 'number' } },
314
351
  } },
315
352
  },
@@ -317,6 +354,12 @@ export const dashboardSchema = {
317
354
  filters: { type: 'array', description: 'Fields to filter every chart and computed KPI by: "region" or { field, label, control: "buttons" | "select", value }.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
318
355
  layout: { type: 'string', enum: ['auto', 'executive', 'grid'], description: 'auto (default): balanced rows by chart type. executive: main chart full width, the rest in pairs. grid: equal panels.' },
319
356
  colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities across every chart.' },
357
+ order: { type: 'string', enum: ['given'], description: '"given" keeps the charts in the order you wrote them (otherwise the main time series goes first).' },
358
+ note: { type: 'string', description: 'Short note next to the source, at the bottom.' },
359
+ font: { description: 'Font for every chart: a Google Fonts name, a CSS stack or { family, import }.', anyOf: [{ type: 'string' }, { type: 'object' }] },
360
+ palette: { description: 'Categorical palette for every chart: a name ("tab10", "okabe-ito") or a list of colors.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
361
+ ramp: { description: 'Sequential ramp for every chart.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
362
+ textScale: { type: 'number', description: 'Multiplies every text (1.5 for presentations).' },
320
363
  },
321
364
  };
322
365
 
@@ -330,7 +373,7 @@ export function toolDefinition({ name, flavor = 'anthropic', kind = 'chart' } =
330
373
  }
331
374
  name = name ?? 'plot';
332
375
  const description =
333
- 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
376
+ 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, corr (correlation matrix of numeric fields, optional clustering: clustermap), pairplot (scatter-plot matrix with distributions and r), hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, table (exact values with per-column indicators: data bars, ▲/▼ changes, cell colors, sparklines, progress meters, status pills; paginated, cards on phones), choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
334
377
  const { $schema, $id, title, ...params } = schema;
335
378
  if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
336
379
  return { name, description, input_schema: params };
package/src/stats.js CHANGED
@@ -111,11 +111,13 @@ export function histogram(vs, { bins, binWidth, domain } = {}) {
111
111
  }
112
112
  step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
113
113
  }
114
- const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
114
+ const start = bins ? lo : +(Math.floor(lo / step + 1e-9) * step).toPrecision(15);
115
115
  const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
116
- const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
116
+ const out = Array.from({ length: count }, (_, i) => ({ x0: +(start + i * step).toPrecision(15), x1: +(start + (i + 1) * step).toPrecision(15), count: 0 }));
117
117
  for (const v of s) {
118
- let i = Math.floor((v - start) / step);
118
+ // Con `domain`, lo que cae fuera no se cuenta (antes se apilaba en la última barra).
119
+ if (domain && (v < domain[0] || v > domain[1])) continue;
120
+ let i = Math.floor((v - start) / step + 1e-9);
119
121
  if (i >= count) i = count - 1;
120
122
  if (i >= 0) out[i].count++;
121
123
  }
package/src/svg.js CHANGED
@@ -3,7 +3,10 @@
3
3
  import { ctx } from './context.js';
4
4
 
5
5
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
6
- export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
6
+ // Además de escapar, quita los caracteres que XML 1.0 prohíbe (controles y
7
+ // sustitutos sueltos): un solo \x00 en un dato invalida todo el SVG.
8
+ const BAD_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
9
+ export const esc = (s) => String(s ?? '').replace(BAD_XML, '').replace(/[&<>"']/g, (c) => ESC[c]);
7
10
 
8
11
  /** Redondea a 2 decimales para SVG compactos. */
9
12
  export const r2 = (n) => Math.round(n * 100) / 100;
package/src/text.js CHANGED
@@ -24,6 +24,11 @@ set('—', 0.9);
24
24
  set('▲▼■●', 0.9);
25
25
  set('·', 0.3);
26
26
  set('→', 0.9);
27
+ set('\u202F', 0.17); // espacio fino (miles en francés)
28
+ set('\u00A0', 0.27);
29
+ set('✓✗', 0.7);
30
+ set('‹›', 0.32);
31
+ set('#', 0.6);
27
32
 
28
33
  export function measure(str, size = 12, weight = 400) {
29
34
  let w = 0;