plotagents 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -5
- package/README.md +44 -15
- package/bin/plotagents.js +40 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +6161 -757
- package/docs/api.md +23 -5
- package/docs/charts.md +229 -10
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- 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/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +275 -20
- package/package.json +11 -4
- package/schema.json +1382 -53
- package/skills/plotagents/SKILL.md +105 -0
- package/src/axes.js +11 -3
- package/src/charts/bar.js +40 -21
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +6 -4
- package/src/charts/choropleth.js +287 -37
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +39 -21
- package/src/charts/donut.js +4 -2
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +11 -8
- package/src/charts/line.js +187 -43
- package/src/charts/network.js +557 -189
- package/src/charts/pointmap.js +602 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +16 -4
- package/src/charts/sankey.js +42 -6
- package/src/charts/scatter.js +103 -23
- package/src/charts/sunburst.js +7 -6
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +12 -9
- package/src/charts/wordcloud.js +177 -0
- package/src/colorscales.js +193 -0
- package/src/dashboard.js +451 -0
- package/src/dashed.js +1 -1
- package/src/data.js +4 -2
- package/src/facet.js +4 -3
- package/src/format.js +80 -25
- package/src/frame.js +101 -29
- package/src/geo/projection.js +76 -9
- package/src/geo/registry.js +87 -8
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +92 -14
- package/src/infer.js +18 -1
- package/src/interact.js +157 -36
- package/src/lint.js +353 -11
- package/src/runtime.js +301 -101
- package/src/scale.js +82 -11
- package/src/schema.js +235 -36
- package/src/stats.js +76 -17
- package/src/svg.js +4 -1
- package/src/text.js +5 -0
- package/src/theme.js +101 -14
package/src/lint.js
CHANGED
|
@@ -4,13 +4,33 @@
|
|
|
4
4
|
import { ensureData, fieldsOf, fieldKind, uniq, num, PlotError } from './data.js';
|
|
5
5
|
import { inferType, TYPES, normalizeType } from './infer.js';
|
|
6
6
|
import { validatePalette } from './palette.js';
|
|
7
|
-
import { themeFor } from './theme.js';
|
|
7
|
+
import { themeFor, themes, palettes, ramps } from './theme.js';
|
|
8
|
+
import { resolveColorScale, colorScaleNames, RAINBOW } from './colorscales.js';
|
|
9
|
+
import { schema } from './schema.js';
|
|
10
|
+
import { parseDate } from './format.js';
|
|
11
|
+
import { autoLocale } from './i18n.js';
|
|
12
|
+
import { COLUMN_TYPES, columnType } from './charts/table.js';
|
|
8
13
|
|
|
9
14
|
/** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
|
|
10
15
|
let RENDER_PROBE = null;
|
|
11
16
|
export const _setProbe = (fn) => { RENDER_PROBE = fn; };
|
|
12
17
|
|
|
18
|
+
/** Nunca lanza: si la revisión misma falla con un spec raro, lo dice como aviso y sigue. */
|
|
13
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 = {}) {
|
|
28
|
+
if (!spec || typeof spec !== 'object') spec = {};
|
|
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');
|
|
32
|
+
/** Mensajes nuevos: se escriben directo en ambos idiomas. */
|
|
33
|
+
const L = (es, english) => (en ? english : es);
|
|
14
34
|
const errors = [];
|
|
15
35
|
const warnings = [];
|
|
16
36
|
const tips = [];
|
|
@@ -19,9 +39,10 @@ export function lint(spec = {}) {
|
|
|
19
39
|
const sug = (code, message, fix) => tips.push({ code, message, fix });
|
|
20
40
|
|
|
21
41
|
let data = [];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
const rawWords = !spec.data && (typeof spec.text === 'string' || Array.isArray(spec.text));
|
|
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 }]'); }
|
|
44
|
+
const type = spec.type && spec.type !== 'auto' ? normalizeType(String(spec.type).trim())[0] : inferType(spec, data);
|
|
45
|
+
if (!KNOWN_TYPES.has(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${[...KNOWN_TYPES].join(', ')}`);
|
|
25
46
|
const fields = fieldsOf(data);
|
|
26
47
|
const check = (f, role) => {
|
|
27
48
|
for (const ff of [].concat(f ?? [])) if (ff && !fields.includes(ff)) err('field', `El campo "${ff}" (${role}) no existe.`, `Campos disponibles: ${fields.join(', ')}`);
|
|
@@ -30,10 +51,10 @@ export function lint(spec = {}) {
|
|
|
30
51
|
check(spec.x, 'x'); check(spec.y, 'y'); check(spec.series ?? spec.color, 'series'); check(spec.size, 'size');
|
|
31
52
|
check(spec.value, 'value'); check(spec.label, 'label'); if (!['network', 'flowchart'].includes(type)) check(spec.group, 'group'); check(spec.path, 'path');
|
|
32
53
|
check(spec.from, 'from'); check(spec.to, 'to');
|
|
33
|
-
} else if (type !== 'kpi' && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
|
|
54
|
+
} else if (type !== 'kpi' && !rawWords && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
|
|
34
55
|
|
|
35
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".');
|
|
36
|
-
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.');
|
|
37
58
|
if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
|
|
38
59
|
|
|
39
60
|
const series = spec.series ?? spec.color;
|
|
@@ -59,17 +80,18 @@ export function lint(spec = {}) {
|
|
|
59
80
|
if (a && b && Math.max(a, b) / Math.min(a, b) > 10) warn('dual-scale', 'Las dos medidas tienen escalas muy distintas (no hay doble eje: inventa correlaciones).', 'Usa `index: true` (base 100) o dos gráficos separados.');
|
|
60
81
|
}
|
|
61
82
|
}
|
|
62
|
-
if (type === 'donut') {
|
|
83
|
+
if (type === 'donut' || type === 'pie') {
|
|
84
|
+
const shape = type === 'pie' ? 'torta' : 'dona';
|
|
63
85
|
const lf = spec.label ?? spec.x ?? fields.find((f) => fieldKind(data, f) === 'category');
|
|
64
86
|
const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
|
|
65
87
|
const n = lf ? uniq(data.map((r) => String(r[lf]))).length : 0;
|
|
66
|
-
if (n > 6) warn('donut-slices', `${n} segmentos: sobre 6 la
|
|
88
|
+
if (n > 6) warn('donut-slices', `${n} segmentos: sobre 6 la ${shape} no se lee; se agrupan en "Otros".`, 'Usa `type: "bar"` o `type: "treemap"`.');
|
|
67
89
|
if (n === 2) sug('donut-two', 'Dos segmentos son un solo número.', 'Usa `type: "kpi"` con el porcentaje.');
|
|
68
90
|
if (vf && n >= 3) {
|
|
69
91
|
const vals = data.map((r) => num(r[vf])).filter((v) => v > 0).sort((a, b) => b - a);
|
|
70
92
|
if (vals.length >= 3 && vals[0] / vals[vals.length - 1] < 1.35) sug('donut-close', 'Los valores son muy parecidos: los ángulos no permiten compararlos.', 'Usa `type: "bar"` ordenado.');
|
|
71
93
|
}
|
|
72
|
-
if (data.some((r) => num(r[vf]) < 0)) err('negative',
|
|
94
|
+
if (vf && data.some((r) => num(r[vf]) < 0)) err('negative', `La ${shape} no admite valores negativos.`, 'Usa barras.');
|
|
73
95
|
}
|
|
74
96
|
if (type === 'treemap') {
|
|
75
97
|
const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
|
|
@@ -103,6 +125,26 @@ export function lint(spec = {}) {
|
|
|
103
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.');
|
|
104
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"`.');
|
|
105
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
|
+
}
|
|
106
148
|
if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
|
|
107
149
|
const counts = new Map();
|
|
108
150
|
for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
|
|
@@ -127,11 +169,311 @@ export function lint(spec = {}) {
|
|
|
127
169
|
if (sf && fields.includes(sf)) { const n = uniq(data.map((r) => String(r[sf]))).length; if (n !== 2) err('pyramid-sides', `La pirámide necesita exactamente 2 grupos en "${sf}" (hay ${n}).`, 'Usa `type: "bar"` con `series` para más grupos.'); }
|
|
128
170
|
}
|
|
129
171
|
if (type === 'chord' && data.length && uniq(data.flatMap((r) => [r[spec.from ?? 'from'], r[spec.to ?? 'to']])).length > 16) warn('chord-many', 'Más de 16 grupos en un diagrama de cuerdas: los arcos se vuelven ilegibles.', 'Agrupa los menores en "Otros" o usa `type: "sankey"`.');
|
|
130
|
-
if (type === 'network' && data.length > 2000) sug('network-big', `${data.length} conexiones: la red puede verse como una mancha.`, 'Filtra las conexiones débiles o usa `layout: "
|
|
172
|
+
if (type === 'network' && data.length > 2000) sug('network-big', `${data.length} conexiones: la red puede verse como una mancha.`, 'Filtra las conexiones débiles o usa `layout: "circular"` o `layout: "clusters"`.');
|
|
173
|
+
|
|
174
|
+
if (type === 'wordcloud') {
|
|
175
|
+
if (!data.length && !spec.text) err('empty', 'La nube no tiene palabras.', 'Pasa filas { palabra, valor } o `text: "…"` con el texto a contar.');
|
|
176
|
+
if (data.length && data.length <= 12) sug('wordcloud-few', `Con ${data.length} palabras, una nube compara mal los tamaños.`, 'Para ordenar y comparar, usa `type: "bar"` con `top: 15`.');
|
|
177
|
+
if (!spec.subtitle) sug('wordcloud-measure', 'Una nube no dice qué mide el tamaño.', 'Escribe en `subtitle` qué se contó: "Menciones en 1.240 respuestas abiertas".');
|
|
178
|
+
}
|
|
179
|
+
if (type === 'gantt' && data.length) {
|
|
180
|
+
const sF = spec.start ?? fields.find((f) => /^(start|inicio|desde|begin|comienzo|from)/i.test(f));
|
|
181
|
+
const eF = spec.end ?? fields.find((f) => /^(end|fin|hasta|finish|termino|término|due|vence|to)$/i.test(f));
|
|
182
|
+
if (!sF) err('gantt-start', 'La carta Gantt necesita una fecha de inicio por tarea.', 'Indica `start` y `end` (o `duration`): start: "inicio", end: "fin".');
|
|
183
|
+
else if (eF && fields.includes(eF)) {
|
|
184
|
+
const back = data.filter((r) => { const a = parseDate(r[sF]), b = parseDate(r[eF]); return Number.isFinite(a) && Number.isFinite(b) && b < a; });
|
|
185
|
+
if (back.length) warn('gantt-backwards', `${back.length} tarea(s) terminan antes de empezar.`, 'Revisa si `start` y `end` están invertidos.');
|
|
186
|
+
}
|
|
187
|
+
if (data.length > 40) sug('gantt-many', `${data.length} tareas en una sola carta: cuesta encontrar la ruta crítica.`, 'Agrupa por fase con `group`, resume subtareas o usa `facet` por equipo.');
|
|
188
|
+
if (spec.today === undefined && ![].concat(spec.refLines ?? []).length) sug('gantt-today', 'Una línea de "hoy" dice de un vistazo qué va atrasado.', 'Agrega `today: "2026-03-12"` (o `today: true`).');
|
|
189
|
+
}
|
|
190
|
+
if (type === 'gantt' && data.length) {
|
|
191
|
+
const gF = spec.group ?? spec.series ?? spec.hue;
|
|
192
|
+
if (gF && fields.includes(gF)) {
|
|
193
|
+
const phases = uniq(data.map((r) => String(r[gF] ?? '')));
|
|
194
|
+
if (spec.collapsed !== undefined && spec.collapsed !== true && spec.collapsed !== false) {
|
|
195
|
+
const bad = [].concat(spec.collapsed).map(String).filter((v) => !phases.includes(v));
|
|
196
|
+
if (bad.length) warn('gantt-collapsed-unknown', L(`\`collapsed\` nombra fases que no existen: ${bad.slice(0, 4).join(', ')}.`, `\`collapsed\` names phases that do not exist: ${bad.slice(0, 4).join(', ')}.`), L(`Usa valores de \`${gF}\`: ${phases.slice(0, 6).join(', ')}.`, `Use values of \`${gF}\`: ${phases.slice(0, 6).join(', ')}.`));
|
|
197
|
+
}
|
|
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).`));
|
|
199
|
+
}
|
|
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
|
+
}
|
|
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" }]');
|
|
131
225
|
|
|
132
|
-
|
|
226
|
+
checkOptions(spec, { type, data, fields, err, warn, sug, L });
|
|
133
227
|
|
|
228
|
+
if (en) for (const list of [errors, warnings, tips]) list.forEach((it, i) => { list[i] = toEnglish(it); });
|
|
134
229
|
return { ok: errors.length === 0, type, errors, warnings, suggestions: tips };
|
|
135
230
|
}
|
|
136
231
|
|
|
232
|
+
// ── English messages (locale: "en") ───────────────────────────────────────
|
|
233
|
+
// Agents read these to repair their own spec, so they follow the chart's
|
|
234
|
+
// language. The Spanish message is the source; dynamic parts are reused.
|
|
235
|
+
const qs = (s) => [...String(s).matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
|
236
|
+
const n0 = (s) => (/(\d+)/.exec(String(s)) || [])[1];
|
|
237
|
+
const after = (s) => String(s ?? '').replace(/^[^:]*:\s*/, '');
|
|
238
|
+
const EN = {
|
|
239
|
+
data: (m) => [m.message, 'Pass `data` as an array of rows: [{ category: "A", value: 10 }]'],
|
|
240
|
+
type: (m) => [`Chart type "${qs(m.message)[0]}" is not supported.`, `Use one of: ${after(m.fix)}`],
|
|
241
|
+
field: (m) => { const [f] = qs(m.message); const role = (/\(([^)]+)\)/.exec(m.message) || [])[1]; return [`The field "${f}" (${role}) does not exist.`, `Available fields: ${after(m.fix)}`]; },
|
|
242
|
+
empty: () => ['`data` has no rows.', 'Pass at least one row.'],
|
|
243
|
+
title: () => ['The title is missing.', 'Write the finding as a full sentence: "Biobío accounts for 32% of sales", not "Sales by region".'],
|
|
244
|
+
'title-fact': () => ['The title describes the chart but does not say what it shows.', 'A good title is a fact, ideally with the key number.'],
|
|
245
|
+
source: () => ['The data source is missing.', 'Add `source: "Census Bureau, 2025"` (or "illustrative data").'],
|
|
246
|
+
'series-max': (m) => [`${n0(m.message)} series: there are only 8 distinguishable colors; from the 9th on they turn gray.`, 'Fold the tail into "Other", use `highlight` or split into several charts.'],
|
|
247
|
+
'one-bar': () => ['A single bar compares nothing.', 'Use `type: "kpi"` to show the number.'],
|
|
248
|
+
'too-many-bars': (m) => [`${n0(m.message)} categories: the 25 largest will be shown and the rest folded into "Other".`, 'Set `top: N` to control the cut.'],
|
|
249
|
+
'stack-many': () => ['Stacking more than 5 series makes the middle segments hard to compare.', 'Consider grouped bars, `highlight` or `stack: "percent"`.'],
|
|
250
|
+
'bar-time': () => ['Many periods drawn as bars.', 'For long trends, `type: "line"` reads better.'],
|
|
251
|
+
'line-many': (m) => [`${n0(m.message)} lines: beyond 3 or 4 the chart turns into spaghetti.`, 'Use `highlight: "Series"` to emphasize one and mute the rest, or split into several charts.'],
|
|
252
|
+
'line-nominal': () => ['The x axis holds unordered categories: lines suggest continuity.', 'To compare categories, use `type: "bar"`.'],
|
|
253
|
+
'line-few': () => ['Fewer than 3 points in time.', 'With 2 periods, grouped bars or a `kpi` with its change say it better.'],
|
|
254
|
+
'dual-scale': () => ['The two measures have very different scales (there is no dual axis: it invents correlations).', 'Use `index: true` (base 100) or two separate charts.'],
|
|
255
|
+
'donut-slices': (m) => [`${n0(m.message)} slices: beyond 6 a ${/torta/.test(m.message) ? 'pie' : 'donut'} cannot be read; the rest fold into "Other".`, 'Use `type: "bar"` or `type: "treemap"`.'],
|
|
256
|
+
'donut-two': () => ['Two slices are a single number.', 'Use `type: "kpi"` with the percentage.'],
|
|
257
|
+
'donut-close': () => ['The values are very similar: angles cannot tell them apart.', 'Use a sorted `type: "bar"`.'],
|
|
258
|
+
negative: (m) => (/dona|torta/.test(m.message) ? [`${/torta/.test(m.message) ? 'Pies' : 'Donuts'} cannot show negative values.`, 'Use bars.'] : ['Negative values are left out of the treemap.', 'Use bars for signed data.']),
|
|
259
|
+
'groups-max': () => ['More than 8 groups: from the 8th on they go into a gray "Other".', 'Merge categories or use another hierarchy level.'],
|
|
260
|
+
'scatter-colors': (m) => [`${n0(m.message)} color groups in a scatter: only the first 3 colors stay distinct across every pair for color-blind readers.`, 'Use `highlight`, merge groups or split into several charts.'],
|
|
261
|
+
overplot: (m) => [`${n0(m.message)} points: they may overlap.`, 'Reduce `radius` or use a heatmap.'],
|
|
262
|
+
numeric: (m) => { const [f] = qs(m.message); return /regresi/.test(m.message) ? [`"${f}" is not numeric: a regression needs two measures.`, 'Use `type: "box"` to compare a measure across categories.'] : [`"${f}" is not numeric: this chart needs a measure.`, 'To count categories, use `type: "countplot"`.']; },
|
|
263
|
+
'few-obs': (m) => (/recta/.test(m.message) ? [`Only ${n0(m.message)} points: the fit and its interval are unreliable.`, 'Show the points with `type: "scatter"`.'] : [`Only ${n0(m.message)} observations: the shape of the distribution is unreliable.`, 'With little data, `type: "strip"` shows every point.']),
|
|
264
|
+
'few-per-group': (m) => [`Groups with fewer than 5 observations: ${after(m.message).replace(/\.$/, '')}.`, '`type: "strip"` or `points: true` show each observation.'],
|
|
265
|
+
'radar-many': (m) => [`${n0(m.message)} series on a radar hide one another.`, 'Use `highlight`, `facet` or grouped bars.'],
|
|
266
|
+
'log-nonpositive': () => ['There are values ≤ 0: a log scale cannot show them.', 'Drop the log scale or filter those values.'],
|
|
267
|
+
'geo-unmatched': (m) => [`${n0(m.message)} row(s) match no shape on the map: ${after(m.message).replace(/\.$/, '')}.`, 'Check the names or use codes ("XIII", "ARG", "TX"), or set `featureKey`.'],
|
|
268
|
+
'dashed-label': () => ['Some shapes are dashed but nothing says what they mean (the legend will say "Estimate").', 'Add `dashedLabel: "Projection"`, "Preliminary" or whatever applies.'],
|
|
269
|
+
'dashed-from-type': () => ['`dashedFrom` only applies to lines, areas and columns on a time axis.', 'Use `dashed` with a list of categories or a data field.'],
|
|
270
|
+
'pyramid-sides': (m) => { const [f] = qs(m.message); return [`A population pyramid needs exactly 2 groups in "${f}" (found ${(/hay (\d+)/.exec(m.message) || [])[1]}).`, 'Use `type: "bar"` with `series` for more groups.']; },
|
|
271
|
+
'chord-many': () => ['More than 16 groups in a chord diagram: the ribbons become unreadable.', 'Fold the smallest into "Other" or use `type: "sankey"`.'],
|
|
272
|
+
'network-big': (m) => [`${n0(m.message)} links: the network may look like a blob.`, 'Filter weak links or use `layout: "circular"` or `layout: "clusters"`.'],
|
|
273
|
+
'gantt-start': () => ['A Gantt chart needs a start date per task.', 'Set `start` and `end` (or `duration`): start: "start", end: "end".'],
|
|
274
|
+
'gantt-backwards': (m) => [`${n0(m.message)} task(s) end before they start.`, 'Check whether `start` and `end` are swapped.'],
|
|
275
|
+
'gantt-many': (m) => [`${n0(m.message)} tasks in one chart: the critical path is hard to find.`, 'Group by phase with `group`, roll up subtasks or use `facet` by team.'],
|
|
276
|
+
'gantt-today': () => ['A "today" line shows at a glance what is late.', 'Add `today: "2026-03-12"` (or `today: true`).'],
|
|
277
|
+
'wordcloud-few': (m) => [`With ${n0(m.message)} words, a cloud compares sizes poorly.`, 'To rank and compare, use `type: "bar"` with `top: 15`.'],
|
|
278
|
+
'wordcloud-measure': () => ['A word cloud does not say what its sizes measure.', 'Say what was counted in `subtitle`: "Mentions in 1,240 open answers".'],
|
|
279
|
+
items: () => ['A KPI needs `items`.', 'items: [{ label: "Sales", value: 1250, delta: 0.12, period: "vs 2025" }]'],
|
|
280
|
+
};
|
|
281
|
+
function toEnglish(it) {
|
|
282
|
+
const f = EN[it.code] || (it.code.startsWith('palette-') ? (m) => [m.message.replace(/^Paleta:/, 'Palette:'), 'Change the order or the hues, or use the default palette.'] : null);
|
|
283
|
+
if (!f) return it;
|
|
284
|
+
const [message, fix] = f(it);
|
|
285
|
+
return { ...it, message, fix };
|
|
286
|
+
}
|
|
287
|
+
|
|
137
288
|
export { PlotError };
|
|
289
|
+
|
|
290
|
+
// ── Opciones: nombres desconocidos, valores fuera de catálogo ─────────────
|
|
291
|
+
// Solo avisan (warnings) salvo cuando el valor no puede funcionar (errors).
|
|
292
|
+
|
|
293
|
+
export const KNOWN_TYPES = new Set([...TYPES, 'pointmap']);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Opciones de primer nivel que el código lee además de las del schema
|
|
297
|
+
* (alias, atajos, opciones avanzadas e internas). Mejor de más que de menos:
|
|
298
|
+
* un nombre válido marcado como desconocido confunde al agente.
|
|
299
|
+
*/
|
|
300
|
+
const EXTRA_OPTIONS = `
|
|
301
|
+
ageOrder aggregate annotate antarctica axisInside background bandwidth barHeight barWidth bars bw_adjust bwAdjust
|
|
302
|
+
cellHeight center centerLabel centerValue change changeDomain clip cmap col colWrap col_wrap collapsed collapsible
|
|
303
|
+
color colorScale colormap commonNorm common_norm compareData compareField compareLabel complementary controls
|
|
304
|
+
critical criticalColor cumulative current curveness cut densityNorm diverging dodge domain edgeLabel element
|
|
305
|
+
endLabels exclude facetOrder fontFamily fontImport fontScale frac grid hole hueOrder invert iterations jitter join
|
|
306
|
+
labelOrientation labelScale labelValues labelsFor lane lanes lat latitude legend legendPosition legendTitle level
|
|
307
|
+
lineWidth lng lon longitude maxRadius maxSlices mean missing nodeId nodeLabel nodeWidth note orient orientation
|
|
308
|
+
other padding panelGap panelHeight panelLabels playInterval radius region ribbonColor rug scatter shareX shareY sharex
|
|
309
|
+
showMeans showValues sides size sizeFormat sizeTitle source_field span swimlanes territories thickness thresholds
|
|
310
|
+
tileOpacity tileStyle tiles tilesKey todayColor truncate unit valueDomain valueFormat valueScale weekends word
|
|
311
|
+
xFormat yFormat yTicks zoom curveness exclude colorScale filters kpis charts updated primary layout
|
|
312
|
+
after area good minLength sizeKey x2
|
|
313
|
+
orderBy perPage paginate stacked totalLabel
|
|
314
|
+
id name description notes meta alt $schema comment
|
|
315
|
+
`.trim().split(/\s+/);
|
|
316
|
+
|
|
317
|
+
let KNOWN_OPTIONS = null;
|
|
318
|
+
const knownOptions = () => (KNOWN_OPTIONS ??= new Set([...Object.keys(schema.properties || {}), ...EXTRA_OPTIONS]));
|
|
319
|
+
|
|
320
|
+
/** Distancia de edición (Levenshtein), con corte temprano en `max`. */
|
|
321
|
+
export function editDistance(a, b, max = 3) {
|
|
322
|
+
a = String(a); b = String(b);
|
|
323
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
324
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
325
|
+
for (let i = 1; i <= a.length; i++) {
|
|
326
|
+
const cur = [i];
|
|
327
|
+
let best = i;
|
|
328
|
+
for (let j = 1; j <= b.length; j++) {
|
|
329
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
330
|
+
if (cur[j] < best) best = cur[j];
|
|
331
|
+
}
|
|
332
|
+
if (best > max) return max + 1;
|
|
333
|
+
prev = cur;
|
|
334
|
+
}
|
|
335
|
+
return prev[b.length];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Candidato más parecido (≤ 2 ediciones, sin distinguir mayúsculas) o null. */
|
|
339
|
+
export function didYouMean(value, candidates, max = 2) {
|
|
340
|
+
const v = String(value).toLowerCase();
|
|
341
|
+
let best = null, bestD = max + 1;
|
|
342
|
+
// "percentage" → "percent": un prefijo largo cuenta como parecido.
|
|
343
|
+
for (const c of candidates) { const lc = String(c).toLowerCase(); if (lc.length >= 4 && v.length >= 4 && (v.startsWith(lc) || lc.startsWith(v))) return c; }
|
|
344
|
+
for (const c of candidates) {
|
|
345
|
+
const d = editDistance(v, String(c).toLowerCase(), max);
|
|
346
|
+
if (d < bestD) { best = c; bestD = d; }
|
|
347
|
+
}
|
|
348
|
+
return best;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const FORMAT_TYPES = ['number', 'compact', 'percent', 'currency', 'integer', 'year'];
|
|
352
|
+
const SCALES = ['linear', 'log'];
|
|
353
|
+
const CURVES = ['linear', 'smooth', 'step'];
|
|
354
|
+
const LEGEND_POS = ['top', 'bottom', 'left', 'right', 'arriba', 'abajo', 'izquierda', 'derecha'];
|
|
355
|
+
const NETWORK_LAYOUTS = ['force', 'circular', 'circle', 'radial', 'concentric', 'clusters', 'tree', 'grid', 'ring', 'cluster', 'groups', 'hierarchy', 'concentrico', 'arbol', 'árbol', 'grilla', 'fuerzas'];
|
|
356
|
+
const TILES = ['osm', 'esri-gray', 'esri-dark', 'carto-light', 'carto-dark', 'mapbox-light', 'mapbox-dark', 'esri', 'carto', 'mapbox', 'none', 'openstreetmap', 'esri-light', 'esri-grey', 'positron', 'dark-matter', 'darkmatter'];
|
|
357
|
+
const THEME_KEYS = 'extends mode name surface background ink text lede muted grid axis tint tint2 dim other link accent categorical sequential diverging status font type marks axes layout tooltip radius barRadius'.split(' ');
|
|
358
|
+
const PALETTE_NAMES = () => [...Object.keys(palettes), ...Object.values(colorScaleNames()).flat()];
|
|
359
|
+
const MAPS = new Set(['choropleth', 'symbolmap', 'pointmap']);
|
|
360
|
+
|
|
361
|
+
function checkOptions(spec, { type, data, fields, err, warn, L }) {
|
|
362
|
+
const q = (v) => (typeof v === 'string' ? `"${v}"` : JSON.stringify(v));
|
|
363
|
+
const enumCheck = (key, value, allowed, example) => {
|
|
364
|
+
if (typeof value !== 'string' || allowed.includes(value)) return;
|
|
365
|
+
const near = didYouMean(value, allowed);
|
|
366
|
+
err('enum-' + key, L(`\`${key}: ${q(value)}\` no es un valor válido.`, `\`${key}: ${q(value)}\` is not a valid value.`),
|
|
367
|
+
(near ? L(`¿Quisiste decir \`${key}: "${near}"\`? `, `Did you mean \`${key}: "${near}"\`? `) : '') + L(`Valores: ${example ?? allowed.map((a) => `"${a}"`).join(', ')}.`, `Values: ${example ?? allowed.map((a) => `"${a}"`).join(', ')}.`));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// 1. Nombres de opciones desconocidos (probablemente un error de tipeo).
|
|
371
|
+
const known = knownOptions();
|
|
372
|
+
for (const k of Object.keys(spec)) {
|
|
373
|
+
if (known.has(k) || k.startsWith('_')) continue;
|
|
374
|
+
const near = didYouMean(k, known);
|
|
375
|
+
warn('unknown-option', L(`Opción desconocida \`${k}\`: se ignora.`, `Unknown option \`${k}\`: it is ignored.`),
|
|
376
|
+
near ? L(`¿Quisiste decir \`${near}\`?`, `Did you mean \`${near}\`?`) : L('Revisa el nombre en el schema (`plotagents --schema`).', 'Check the name in the schema (`plotagents --schema`).'));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 2. Valores fuera de catálogo.
|
|
380
|
+
const f = spec.format;
|
|
381
|
+
if (typeof f === 'string') enumCheck('format', f, FORMAT_TYPES, '"number", "compact", "percent", "currency", "integer" o { prefix, suffix, decimals }');
|
|
382
|
+
else if (f && typeof f === 'object') {
|
|
383
|
+
if (f.type !== undefined) enumCheck('format', f.type, FORMAT_TYPES);
|
|
384
|
+
if (f.decimals !== undefined && !(Number.isInteger(f.decimals) && f.decimals >= 0 && f.decimals <= 20)) {
|
|
385
|
+
err('format-decimals', L(`\`format.decimals: ${q(f.decimals)}\` debe ser un entero entre 0 y 20.`, `\`format.decimals: ${q(f.decimals)}\` must be an integer between 0 and 20.`), L('Usa entre 0 y 3 decimales: `format: { decimals: 1 }`.', 'Use 0 to 3 decimals: `format: { decimals: 1 }`.'));
|
|
386
|
+
}
|
|
387
|
+
} else if (f !== undefined && f !== null) err('enum-format', L('`format` debe ser un texto o un objeto.', '`format` must be a string or an object.'), 'format: "percent" | { prefix: "$", decimals: 0 }');
|
|
388
|
+
if (spec.stack !== undefined && typeof spec.stack !== 'boolean') {
|
|
389
|
+
if (spec.stack !== 'percent') {
|
|
390
|
+
const near = typeof spec.stack === 'string' ? didYouMean(spec.stack, ['percent']) : null;
|
|
391
|
+
err('enum-stack', L(`\`stack: ${q(spec.stack)}\` no es válido.`, `\`stack: ${q(spec.stack)}\` is not valid.`), (near ? L('¿Quisiste decir `stack: "percent"`? ', 'Did you mean `stack: "percent"`? ') : '') + L('Usa `stack: true` (apilar) o `stack: "percent"` (100%).', 'Use `stack: true` (stacked) or `stack: "percent"` (100%).'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const k of ['xScale', 'yScale']) if (spec[k] !== undefined) enumCheck(k, spec[k], SCALES, '"linear", "log"');
|
|
395
|
+
if (spec.curve !== undefined) enumCheck('curve', spec.curve, CURVES);
|
|
396
|
+
if (spec.legend !== undefined && typeof spec.legend !== 'boolean') {
|
|
397
|
+
const pos = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : undefined;
|
|
398
|
+
if (pos !== undefined) enumCheck('legend', String(pos).toLowerCase(), LEGEND_POS, 'true, false, "top", "bottom", "left", "right" o { position }');
|
|
399
|
+
}
|
|
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']);
|
|
406
|
+
if (type === 'pointmap') {
|
|
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');
|
|
408
|
+
if (spec.tileStyle !== undefined) enumCheck('tileStyle', spec.tileStyle, ['muted', 'original']);
|
|
409
|
+
}
|
|
410
|
+
// Un token secreto de Mapbox quedaría a la vista en el SVG.
|
|
411
|
+
const token = spec.tilesKey ?? (spec.tiles && typeof spec.tiles === 'object' ? spec.tiles.key ?? (/[?&]access_token=([^&]+)/.exec(String(spec.tiles.url || '')) || [])[1] : undefined);
|
|
412
|
+
if (token !== undefined && token !== null && String(token).trim().startsWith('sk.')) err('tiles-secret', L('El token de Mapbox es secreto (empieza con "sk."): quedaría a la vista de cualquiera en el SVG.', 'The Mapbox token is secret (it starts with "sk."): anyone could read it in the SVG.'), L('Usa un token público ("pk.…") restringido a tu dominio, de account.mapbox.com.', 'Use a public token ("pk.…") restricted to your domain, from account.mapbox.com.'));
|
|
413
|
+
|
|
414
|
+
// 3. Escalas de color (matplotlib).
|
|
415
|
+
for (const key of ['colorScale', 'cmap', 'colormap']) {
|
|
416
|
+
const v = spec[key];
|
|
417
|
+
if (v === undefined || v === null || v === false) continue;
|
|
418
|
+
const nameOf = typeof v === 'string' ? v : v && typeof v === 'object' && !Array.isArray(v) && typeof (v.name ?? v.colors) === 'string' ? v.name ?? v.colors : null;
|
|
419
|
+
const cs = resolveColorScale(v);
|
|
420
|
+
if (!cs) {
|
|
421
|
+
if (nameOf !== null) {
|
|
422
|
+
const all = Object.values(colorScaleNames()).flat();
|
|
423
|
+
const base = String(nameOf).replace(/_r$/i, '');
|
|
424
|
+
const near = didYouMean(base, all);
|
|
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.'));
|
|
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 }.'));
|
|
427
|
+
} else if (cs.kind === 'qualitative') {
|
|
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".`));
|
|
429
|
+
} else if (RAINBOW.has(cs.name.replace(/_r$/, ''))) {
|
|
430
|
+
warn('colorscale-rainbow', L(`"${cs.name}" es un arcoíris: inventa bordes, no se lee en escala de grises ni con daltonismo.`, `"${cs.name}" is a rainbow map: it invents edges and fails in grayscale and for color-blind readers.`), L('Usa "viridis" (o "turbo" si necesitas muchos tonos).', 'Use "viridis" (or "turbo" if you need many hues).'));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (typeof spec.palette === 'string' && !palettes[spec.palette] && !resolveColorScale(spec.palette)) {
|
|
434
|
+
const near = didYouMean(spec.palette, PALETTE_NAMES());
|
|
435
|
+
warn('palette-unknown', L(`La paleta "${spec.palette}" no existe: se usa la de fábrica.`, `The palette "${spec.palette}" does not exist: the default is used.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Nombres: "okabe-ito", "tab10", "Set2", "Dark2"… o una lista de colores.', 'Names: "okabe-ito", "tab10", "Set2", "Dark2"… or a list of colors.'));
|
|
436
|
+
}
|
|
437
|
+
if (typeof spec.ramp === 'string' && !ramps[spec.ramp] && !resolveColorScale(spec.ramp)) {
|
|
438
|
+
const near = didYouMean(spec.ramp, [...Object.keys(ramps), ...Object.values(colorScaleNames()).flat()]);
|
|
439
|
+
warn('ramp-unknown', L(`La rampa "${spec.ramp}" no existe: se usa la de fábrica.`, `The ramp "${spec.ramp}" does not exist: the default is used.`), near ? L(`¿Quisiste decir "${near}"?`, `Did you mean "${near}"?`) : L('Usa "indigo", "teal", "viridis", "Blues"…', 'Use "indigo", "teal", "viridis", "Blues"…'));
|
|
440
|
+
}
|
|
441
|
+
if (typeof spec.theme === 'string' && !themes[spec.theme]) {
|
|
442
|
+
const near = didYouMean(spec.theme, Object.keys(themes));
|
|
443
|
+
warn('theme-unknown', L(`El tema "${spec.theme}" no existe: se usa "papel".`, `The theme "${spec.theme}" does not exist: "papel" is used.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Usa "papel", "noche", un tema de registerTheme() o un objeto.', 'Use "papel", "noche", a theme from registerTheme() or an object.'));
|
|
444
|
+
} else if (spec.theme && typeof spec.theme === 'object') {
|
|
445
|
+
for (const k of Object.keys(spec.theme)) {
|
|
446
|
+
if (THEME_KEYS.includes(k)) continue;
|
|
447
|
+
const near = didYouMean(k, THEME_KEYS);
|
|
448
|
+
warn('theme-key', L(`\`theme.${k}\` no es un token del tema: se ignora.`, `\`theme.${k}\` is not a theme token: it is ignored.`), near ? L(`¿Quisiste decir \`theme.${near}\`?`, `Did you mean \`theme.${near}\`?`) : L(`Tokens: ${THEME_KEYS.join(', ')}.`, `Tokens: ${THEME_KEYS.join(', ')}.`));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 4. Formas: refLines y annotations van como lista.
|
|
453
|
+
for (const k of ['refLines', 'annotations']) {
|
|
454
|
+
const v = spec[k];
|
|
455
|
+
if (v !== undefined && v !== null && !Array.isArray(v)) warn('not-a-list', L(`\`${k}\` debe ser una lista.`, `\`${k}\` must be a list.`), `${k}: [${typeof v === 'object' ? JSON.stringify(v) : '{ y: 600, label: "Meta" }'}]`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 5. highlight / dashed / exclude que no nombran nada de los datos.
|
|
459
|
+
if (!data.length || MAPS.has(type)) return;
|
|
460
|
+
let names = null;
|
|
461
|
+
const has = (v) => {
|
|
462
|
+
if (!names) {
|
|
463
|
+
names = new Set(fields.map((x) => x.toLowerCase()));
|
|
464
|
+
for (const r of data) for (const x of fields) { const c = r[x]; if (c !== null && c !== undefined && typeof c !== 'object') names.add(String(c).toLowerCase()); }
|
|
465
|
+
for (const n of [].concat(spec.nodes || [])) if (n && typeof n === 'object') for (const c of [n.id, n.label, n.name]) if (c !== undefined) names.add(String(c).toLowerCase());
|
|
466
|
+
for (const y of [].concat(spec.y || [])) names.add(String(y).toLowerCase());
|
|
467
|
+
}
|
|
468
|
+
return names.has(String(v).toLowerCase());
|
|
469
|
+
};
|
|
470
|
+
for (const key of ['highlight', 'exclude', 'dashed']) {
|
|
471
|
+
const v = spec[key];
|
|
472
|
+
if (v === undefined || v === null || typeof v === 'boolean' || (typeof v === 'object' && !Array.isArray(v))) continue;
|
|
473
|
+
// Fechas y "Otros" pueden no estar escritas igual en los datos: no se marcan.
|
|
474
|
+
const bad = [].concat(v).filter((x) => x !== null && x !== undefined && typeof x !== 'object' && !has(x) && !/^\d{4}(-\d{1,2}){0,2}$/.test(String(x)) && !/^(otros|other|others)$/i.test(String(x))).map(String);
|
|
475
|
+
if (!bad.length) continue;
|
|
476
|
+
warn(key + '-unknown', L(`\`${key}\` nombra valores que no están en los datos: ${bad.slice(0, 4).map((b) => `"${b}"`).join(', ')}.`, `\`${key}\` names values that are not in the data: ${bad.slice(0, 4).map((b) => `"${b}"`).join(', ')}.`),
|
|
477
|
+
L('Usa el texto exacto de una categoría o serie (se distinguen tildes y espacios).', 'Use the exact text of a category or series (accents and spaces count).'));
|
|
478
|
+
}
|
|
479
|
+
}
|