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.
- package/AGENTS.md +5 -1
- package/README.md +18 -1
- package/bin/plotagents.js +3 -1
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +1857 -110
- package/docs/charts.md +86 -1
- package/docs/es/agents.md +102 -0
- package/docs/es/api.md +172 -0
- package/docs/es/charts.md +436 -0
- package/docs/es/colorscales.md +46 -0
- package/docs/es/dashboards.md +79 -0
- package/docs/es/getting-started.md +142 -0
- package/docs/es/rendering.md +47 -0
- package/docs/es/themes.md +116 -0
- package/index.d.ts +92 -8
- package/package.json +3 -2
- package/schema.json +359 -9
- package/skills/plotagents/SKILL.md +105 -0
- package/src/charts/bar.js +13 -5
- package/src/charts/chord.js +3 -1
- package/src/charts/choropleth.js +3 -1
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +2 -1
- package/src/charts/donut.js +3 -1
- package/src/charts/kpi.js +2 -2
- package/src/charts/line.js +19 -7
- package/src/charts/network.js +13 -8
- package/src/charts/pointmap.js +9 -6
- package/src/charts/regplot.js +9 -3
- package/src/charts/sankey.js +1 -1
- package/src/charts/scatter.js +4 -2
- package/src/charts/sunburst.js +2 -2
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +2 -2
- package/src/charts/wordcloud.js +17 -3
- package/src/colorscales.js +12 -1
- package/src/dashboard.js +21 -8
- package/src/data.js +2 -0
- package/src/facet.js +4 -3
- package/src/format.js +36 -13
- package/src/frame.js +8 -5
- package/src/geo/projection.js +4 -2
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +17 -4
- package/src/infer.js +9 -1
- package/src/lint.js +66 -4
- package/src/runtime.js +94 -6
- package/src/scale.js +3 -2
- package/src/schema.js +54 -11
- package/src/stats.js +5 -3
- package/src/svg.js +4 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|
|
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: {
|
|
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
|
-
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
6
|
-
|
|
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;
|