plotagents 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
package/src/lint.js ADDED
@@ -0,0 +1,417 @@
1
+ // Revisión de un spec ANTES de dibujar: errores que impiden el gráfico y
2
+ // sugerencias de diseño (de los cursos de visualización para la gestión).
3
+ // Pensado para que un agente corrija su propio spec en un segundo intento.
4
+ import { ensureData, fieldsOf, fieldKind, uniq, num, PlotError } from './data.js';
5
+ import { inferType, TYPES, normalizeType } from './infer.js';
6
+ import { validatePalette } from './palette.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
+
12
+ /** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
13
+ let RENDER_PROBE = null;
14
+ export const _setProbe = (fn) => { RENDER_PROBE = fn; };
15
+
16
+ export function lint(spec = {}) {
17
+ if (!spec || typeof spec !== 'object') spec = {};
18
+ const en = String(spec.locale || '').toLowerCase().startsWith('en');
19
+ /** Mensajes nuevos: se escriben directo en ambos idiomas. */
20
+ const L = (es, english) => (en ? english : es);
21
+ const errors = [];
22
+ const warnings = [];
23
+ const tips = [];
24
+ const err = (code, message, fix) => errors.push({ code, message, fix });
25
+ const warn = (code, message, fix) => warnings.push({ code, message, fix });
26
+ const sug = (code, message, fix) => tips.push({ code, message, fix });
27
+
28
+ let data = [];
29
+ const rawWords = !spec.data && (typeof spec.text === 'string' || Array.isArray(spec.text));
30
+ 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);
32
+ if (!KNOWN_TYPES.has(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${[...KNOWN_TYPES].join(', ')}`);
33
+ const fields = fieldsOf(data);
34
+ const check = (f, role) => {
35
+ for (const ff of [].concat(f ?? [])) if (ff && !fields.includes(ff)) err('field', `El campo "${ff}" (${role}) no existe.`, `Campos disponibles: ${fields.join(', ')}`);
36
+ };
37
+ if (data.length) {
38
+ check(spec.x, 'x'); check(spec.y, 'y'); check(spec.series ?? spec.color, 'series'); check(spec.size, 'size');
39
+ check(spec.value, 'value'); check(spec.label, 'label'); if (!['network', 'flowchart'].includes(type)) check(spec.group, 'group'); check(spec.path, 'path');
40
+ check(spec.from, 'from'); check(spec.to, 'to');
41
+ } else if (type !== 'kpi' && !rawWords && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
42
+
43
+ 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.');
45
+ if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
46
+
47
+ const series = spec.series ?? spec.color;
48
+ const nSeries = series && fields.includes(series) ? uniq(data.map((r) => String(r[series]))).length : Array.isArray(spec.y) ? spec.y.length : 1;
49
+ if (nSeries > 8) warn('series-max', `${nSeries} series: solo hay 8 colores distinguibles; desde la 9.ª se pintan en gris.`, 'Agrupa la cola en "Otros", usa `highlight` o divide en varios gráficos.');
50
+
51
+ if (type === 'bar') {
52
+ const x = spec.x ?? fields.find((f) => fieldKind(data, f) === 'category');
53
+ const nCat = x && fields.includes(x) ? uniq(data.map((r) => String(r[x]))).length : 0;
54
+ if (nCat === 1 && nSeries === 1) sug('one-bar', 'Una sola barra no compara nada.', 'Usa `type: "kpi"` para mostrar el número.');
55
+ if (nCat > 40 && !spec.top) warn('too-many-bars', `${nCat} categorías: se mostrarán las 25 mayores y el resto como "Otros".`, 'Fija `top: N` para controlar el corte.');
56
+ if (spec.stack && nSeries > 5) sug('stack-many', 'Apilar más de 5 series dificulta comparar los segmentos del medio.', 'Considera barras agrupadas, `highlight` o `stack: "percent"`.');
57
+ if (x && fieldKind(data, x) === 'date' && nCat > 24) sug('bar-time', 'Muchos periodos en barras.', 'Para tendencias largas, `type: "line"` se lee mejor.');
58
+ }
59
+ if (type === 'line' || type === 'area') {
60
+ const x = spec.x;
61
+ if (nSeries > 4 && spec.highlight === undefined) warn('line-many', `${nSeries} líneas: sobre 3-4 el gráfico se vuelve espagueti.`, 'Usa `highlight: "Serie"` para destacar una y atenuar el resto, o separa en varios gráficos.');
62
+ if (x && fields.includes(x) && fieldKind(data, x) === 'category' && !/^(q|t)\d|^\d/i.test(String(data[0]?.[x]))) sug('line-nominal', 'El eje x tiene categorías sin orden: las líneas sugieren continuidad.', 'Para comparar categorías usa `type: "bar"`.');
63
+ const pts = x && fields.includes(x) ? uniq(data.map((r) => String(r[x]))).length : 0;
64
+ if (pts && pts < 3) sug('line-few', 'Menos de 3 puntos en el tiempo.', 'Con 2 periodos, barras agrupadas o un `kpi` con variación comunican mejor.');
65
+ if (Array.isArray(spec.y) && spec.y.length === 2 && !spec.index) {
66
+ const [a, b] = spec.y.map((f) => Math.max(...data.map((r) => Math.abs(num(r[f]))).filter(Number.isFinite)));
67
+ 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.');
68
+ }
69
+ }
70
+ if (type === 'donut' || type === 'pie') {
71
+ const shape = type === 'pie' ? 'torta' : 'dona';
72
+ const lf = spec.label ?? spec.x ?? fields.find((f) => fieldKind(data, f) === 'category');
73
+ const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
74
+ const n = lf ? uniq(data.map((r) => String(r[lf]))).length : 0;
75
+ 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"`.');
76
+ if (n === 2) sug('donut-two', 'Dos segmentos son un solo número.', 'Usa `type: "kpi"` con el porcentaje.');
77
+ if (vf && n >= 3) {
78
+ const vals = data.map((r) => num(r[vf])).filter((v) => v > 0).sort((a, b) => b - a);
79
+ 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.');
80
+ }
81
+ if (vf && data.some((r) => num(r[vf]) < 0)) err('negative', `La ${shape} no admite valores negativos.`, 'Usa barras.');
82
+ }
83
+ if (type === 'treemap') {
84
+ const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
85
+ if (vf && data.some((r) => num(r[vf]) < 0)) warn('negative', 'Los valores negativos se omiten en el treemap.', 'Usa barras para datos con signo.');
86
+ const gf = spec.group ?? (Array.isArray(spec.path) && spec.path.length > 1 ? spec.path[0] : null);
87
+ if (gf && fields.includes(gf) && uniq(data.map((r) => String(r[gf]))).length > 8) warn('groups-max', 'Más de 8 grupos: desde el 8.º van en gris "Otros".', 'Agrupa categorías o usa otro nivel de jerarquía.');
88
+ }
89
+ if (type === 'scatter' || type === 'bubble') {
90
+ if (nSeries > 3) warn('scatter-colors', `${nSeries} grupos de color en dispersión: solo los 3 primeros colores son distinguibles entre todos los pares para daltonismo.`, 'Usa `highlight`, agrupa o separa en varios gráficos.');
91
+ if (data.length > 2000) sug('overplot', `${data.length} puntos: puede haber sobreposición.`, 'Reduce `radius` o usa un mapa de calor.');
92
+ }
93
+ // Campos de facetas y tiempo
94
+ if (data.length) { check(spec.facet ?? spec.col, 'facet'); check(spec.time, 'time'); check(spec.hue, 'hue'); }
95
+
96
+ // Paleta propia: se valida con las mismas reglas que la de fábrica.
97
+ const custom = spec.palette || (spec.theme && typeof spec.theme === 'object' && spec.theme.categorical);
98
+ if (custom) {
99
+ const th = themeFor(spec);
100
+ const n = Math.max(2, Math.min(th.categorical.length, nSeries));
101
+ const rep = validatePalette(th.categorical.slice(0, n), { mode: th.mode, surface: th.surface, pairs: type === 'scatter' || type === 'bubble' ? 'all' : 'adjacent' });
102
+ for (const c of rep.checks) if (c.status === 'fail') warn('palette-' + c.name, `Paleta: ${c.detail}`, 'Cambia el orden o los tonos, o usa la paleta de fábrica.');
103
+ }
104
+
105
+ const numericIn = (f) => f && fields.includes(f) && ['number', 'year'].includes(fieldKind(data, f));
106
+ if (['hist', 'kde', 'ecdf'].includes(type)) {
107
+ const f = spec.x ?? spec.y;
108
+ if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: ${type} necesita una medida.`, 'Para contar categorías usa `type: "countplot"`.');
109
+ if (data.length < 20) sug('few-obs', `Solo ${data.length} observaciones: la forma de la distribución es poco confiable.`, 'Con pocos datos, `type: "strip"` muestra cada punto.');
110
+ }
111
+ if (['regplot', 'residplot', 'jointplot'].includes(type)) {
112
+ 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
+ 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
+ }
115
+ if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
116
+ const counts = new Map();
117
+ for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
118
+ const small = [...counts].filter(([, n]) => n < 5).map(([k]) => k);
119
+ if (small.length) sug('few-per-group', `Grupos con menos de 5 datos: ${small.slice(0, 4).join(', ')}.`, '`type: "strip"` o `points: true` muestran cada observación.');
120
+ }
121
+ if (type === 'radar' && nSeries > 4) warn('radar-many', `${nSeries} series en un radar se tapan entre sí.`, 'Usa `highlight`, `facet` o barras agrupadas.');
122
+ if ((type === 'line' || type === 'area') && spec.yScale === 'log' && data.some((r) => num(r[Array.isArray(spec.y) ? spec.y[0] : spec.y]) <= 0)) warn('log-nonpositive', 'Hay valores ≤ 0: la escala log no los puede mostrar.', 'Quita la escala log o filtra esos valores.');
123
+
124
+ if (type === 'choropleth' || type === 'symbolmap') {
125
+ const _unmatched = [];
126
+ try { RENDER_PROBE?.({ ...spec, _unmatched }); } catch { /* el error ya se reporta al dibujar */ }
127
+ if (_unmatched.length) warn('geo-unmatched', `${_unmatched.length} fila(s) no coinciden con el mapa: ${_unmatched.slice(0, 5).join(', ')}.`, 'Revisa los nombres o usa códigos (p. ej. "XIII", "ARG", "TX"), o indica `featureKey`.');
128
+ }
129
+ // Formas punteadas: sin explicación, el rayado no se entiende.
130
+ if ((spec.dashed !== undefined && spec.dashed !== false) || spec.dashedFrom !== undefined) {
131
+ if (spec.dashedLabel === undefined) sug('dashed-label', 'Hay formas punteadas pero no se dice qué significan (la leyenda dirá "Estimado").', 'Agrega `dashedLabel: "Proyección"`, "Cifra preliminar" o lo que corresponda.');
132
+ if (spec.dashedFrom !== undefined && !['line', 'area', 'bar', 'column', 'count'].includes(type)) warn('dashed-from-type', '`dashedFrom` solo aplica a líneas, áreas y columnas con eje de tiempo.', 'Usa `dashed` con una lista de categorías o un campo de los datos.');
133
+ }
134
+ if (type === 'pyramid') {
135
+ const sf = spec.sex ?? spec.series ?? spec.hue;
136
+ 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.'); }
137
+ }
138
+ 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"`.');
139
+ 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"`.');
140
+
141
+ if (type === 'wordcloud') {
142
+ if (!data.length && !spec.text) err('empty', 'La nube no tiene palabras.', 'Pasa filas { palabra, valor } o `text: "…"` con el texto a contar.');
143
+ 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`.');
144
+ 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".');
145
+ }
146
+ if (type === 'gantt' && data.length) {
147
+ const sF = spec.start ?? fields.find((f) => /^(start|inicio|desde|begin|comienzo|from)/i.test(f));
148
+ const eF = spec.end ?? fields.find((f) => /^(end|fin|hasta|finish|termino|término|due|vence|to)$/i.test(f));
149
+ 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".');
150
+ else if (eF && fields.includes(eF)) {
151
+ const back = data.filter((r) => { const a = parseDate(r[sF]), b = parseDate(r[eF]); return Number.isFinite(a) && Number.isFinite(b) && b < a; });
152
+ if (back.length) warn('gantt-backwards', `${back.length} tarea(s) terminan antes de empezar.`, 'Revisa si `start` y `end` están invertidos.');
153
+ }
154
+ 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.');
155
+ 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`).');
156
+ }
157
+ if (type === 'gantt' && data.length) {
158
+ const gF = spec.group ?? spec.series ?? spec.hue;
159
+ if (gF && fields.includes(gF)) {
160
+ const phases = uniq(data.map((r) => String(r[gF] ?? '')));
161
+ if (spec.collapsed !== undefined && spec.collapsed !== true && spec.collapsed !== false) {
162
+ const bad = [].concat(spec.collapsed).map(String).filter((v) => !phases.includes(v));
163
+ 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(', ')}.`));
164
+ }
165
+ 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
+ }
167
+ }
168
+ 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
+
170
+ checkOptions(spec, { type, data, fields, err, warn, sug, L });
171
+
172
+ if (en) for (const list of [errors, warnings, tips]) list.forEach((it, i) => { list[i] = toEnglish(it); });
173
+ return { ok: errors.length === 0, type, errors, warnings, suggestions: tips };
174
+ }
175
+
176
+ // ── English messages (locale: "en") ───────────────────────────────────────
177
+ // Agents read these to repair their own spec, so they follow the chart's
178
+ // language. The Spanish message is the source; dynamic parts are reused.
179
+ const qs = (s) => [...String(s).matchAll(/"([^"]*)"/g)].map((m) => m[1]);
180
+ const n0 = (s) => (/(\d+)/.exec(String(s)) || [])[1];
181
+ const after = (s) => String(s ?? '').replace(/^[^:]*:\s*/, '');
182
+ const EN = {
183
+ data: (m) => [m.message, 'Pass `data` as an array of rows: [{ category: "A", value: 10 }]'],
184
+ type: (m) => [`Chart type "${qs(m.message)[0]}" is not supported.`, `Use one of: ${after(m.fix)}`],
185
+ 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)}`]; },
186
+ empty: () => ['`data` has no rows.', 'Pass at least one row.'],
187
+ title: () => ['The title is missing.', 'Write the finding as a full sentence: "Biobío accounts for 32% of sales", not "Sales by region".'],
188
+ '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.'],
189
+ source: () => ['The data source is missing.', 'Add `source: "Census Bureau, 2025"` (or "illustrative data").'],
190
+ '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.'],
191
+ 'one-bar': () => ['A single bar compares nothing.', 'Use `type: "kpi"` to show the number.'],
192
+ '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.'],
193
+ 'stack-many': () => ['Stacking more than 5 series makes the middle segments hard to compare.', 'Consider grouped bars, `highlight` or `stack: "percent"`.'],
194
+ 'bar-time': () => ['Many periods drawn as bars.', 'For long trends, `type: "line"` reads better.'],
195
+ '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.'],
196
+ 'line-nominal': () => ['The x axis holds unordered categories: lines suggest continuity.', 'To compare categories, use `type: "bar"`.'],
197
+ 'line-few': () => ['Fewer than 3 points in time.', 'With 2 periods, grouped bars or a `kpi` with its change say it better.'],
198
+ '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.'],
199
+ '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"`.'],
200
+ 'donut-two': () => ['Two slices are a single number.', 'Use `type: "kpi"` with the percentage.'],
201
+ 'donut-close': () => ['The values are very similar: angles cannot tell them apart.', 'Use a sorted `type: "bar"`.'],
202
+ 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.']),
203
+ 'groups-max': () => ['More than 8 groups: from the 8th on they go into a gray "Other".', 'Merge categories or use another hierarchy level.'],
204
+ '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.'],
205
+ overplot: (m) => [`${n0(m.message)} points: they may overlap.`, 'Reduce `radius` or use a heatmap.'],
206
+ 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"`.']; },
207
+ '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.']),
208
+ 'few-per-group': (m) => [`Groups with fewer than 5 observations: ${after(m.message).replace(/\.$/, '')}.`, '`type: "strip"` or `points: true` show each observation.'],
209
+ 'radar-many': (m) => [`${n0(m.message)} series on a radar hide one another.`, 'Use `highlight`, `facet` or grouped bars.'],
210
+ 'log-nonpositive': () => ['There are values ≤ 0: a log scale cannot show them.', 'Drop the log scale or filter those values.'],
211
+ '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`.'],
212
+ 'dashed-label': () => ['Some shapes are dashed but nothing says what they mean (the legend will say "Estimate").', 'Add `dashedLabel: "Projection"`, "Preliminary" or whatever applies.'],
213
+ '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.'],
214
+ '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.']; },
215
+ 'chord-many': () => ['More than 16 groups in a chord diagram: the ribbons become unreadable.', 'Fold the smallest into "Other" or use `type: "sankey"`.'],
216
+ 'network-big': (m) => [`${n0(m.message)} links: the network may look like a blob.`, 'Filter weak links or use `layout: "circular"` or `layout: "clusters"`.'],
217
+ 'gantt-start': () => ['A Gantt chart needs a start date per task.', 'Set `start` and `end` (or `duration`): start: "start", end: "end".'],
218
+ 'gantt-backwards': (m) => [`${n0(m.message)} task(s) end before they start.`, 'Check whether `start` and `end` are swapped.'],
219
+ '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.'],
220
+ 'gantt-today': () => ['A "today" line shows at a glance what is late.', 'Add `today: "2026-03-12"` (or `today: true`).'],
221
+ 'wordcloud-few': (m) => [`With ${n0(m.message)} words, a cloud compares sizes poorly.`, 'To rank and compare, use `type: "bar"` with `top: 15`.'],
222
+ 'wordcloud-measure': () => ['A word cloud does not say what its sizes measure.', 'Say what was counted in `subtitle`: "Mentions in 1,240 open answers".'],
223
+ items: () => ['A KPI needs `items`.', 'items: [{ label: "Sales", value: 1250, delta: 0.12, period: "vs 2025" }]'],
224
+ };
225
+ function toEnglish(it) {
226
+ 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);
227
+ if (!f) return it;
228
+ const [message, fix] = f(it);
229
+ return { ...it, message, fix };
230
+ }
231
+
232
+ export { PlotError };
233
+
234
+ // ── Opciones: nombres desconocidos, valores fuera de catálogo ─────────────
235
+ // Solo avisan (warnings) salvo cuando el valor no puede funcionar (errors).
236
+
237
+ export const KNOWN_TYPES = new Set([...TYPES, 'pointmap']);
238
+
239
+ /**
240
+ * Opciones de primer nivel que el código lee además de las del schema
241
+ * (alias, atajos, opciones avanzadas e internas). Mejor de más que de menos:
242
+ * un nombre válido marcado como desconocido confunde al agente.
243
+ */
244
+ const EXTRA_OPTIONS = `
245
+ ageOrder aggregate annotate antarctica axisInside background bandwidth barHeight barWidth bars bw_adjust bwAdjust
246
+ cellHeight center centerLabel centerValue change changeDomain clip cmap col colWrap col_wrap collapsed collapsible
247
+ color colorScale colormap commonNorm common_norm compareData compareField compareLabel complementary controls
248
+ critical criticalColor cumulative current curveness cut densityNorm diverging dodge domain edgeLabel element
249
+ endLabels exclude facetOrder fontFamily fontImport fontScale frac grid hole hueOrder invert iterations jitter join
250
+ labelOrientation labelScale labelValues labelsFor lane lanes lat latitude legend legendPosition legendTitle level
251
+ lineWidth lng lon longitude maxRadius maxSlices mean missing nodeId nodeLabel nodeWidth note orient orientation
252
+ other padding panelGap panelHeight panelLabels playInterval radius region ribbonColor rug scatter shareX shareY sharex
253
+ showMeans showValues sides size sizeFormat sizeTitle source_field span swimlanes territories thickness thresholds
254
+ tileOpacity tileStyle tiles tilesKey todayColor truncate unit valueDomain valueFormat valueScale weekends word
255
+ xFormat yFormat yTicks zoom curveness exclude colorScale filters kpis charts updated primary layout
256
+ after area good minLength sizeKey x2
257
+ id name description notes meta alt $schema comment
258
+ `.trim().split(/\s+/);
259
+
260
+ let KNOWN_OPTIONS = null;
261
+ const knownOptions = () => (KNOWN_OPTIONS ??= new Set([...Object.keys(schema.properties || {}), ...EXTRA_OPTIONS]));
262
+
263
+ /** Distancia de edición (Levenshtein), con corte temprano en `max`. */
264
+ export function editDistance(a, b, max = 3) {
265
+ a = String(a); b = String(b);
266
+ if (Math.abs(a.length - b.length) > max) return max + 1;
267
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
268
+ for (let i = 1; i <= a.length; i++) {
269
+ const cur = [i];
270
+ let best = i;
271
+ for (let j = 1; j <= b.length; j++) {
272
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
273
+ if (cur[j] < best) best = cur[j];
274
+ }
275
+ if (best > max) return max + 1;
276
+ prev = cur;
277
+ }
278
+ return prev[b.length];
279
+ }
280
+
281
+ /** Candidato más parecido (≤ 2 ediciones, sin distinguir mayúsculas) o null. */
282
+ export function didYouMean(value, candidates, max = 2) {
283
+ const v = String(value).toLowerCase();
284
+ let best = null, bestD = max + 1;
285
+ // "percentage" → "percent": un prefijo largo cuenta como parecido.
286
+ 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; }
287
+ for (const c of candidates) {
288
+ const d = editDistance(v, String(c).toLowerCase(), max);
289
+ if (d < bestD) { best = c; bestD = d; }
290
+ }
291
+ return best;
292
+ }
293
+
294
+ const FORMAT_TYPES = ['number', 'compact', 'percent', 'currency', 'integer', 'year'];
295
+ const SCALES = ['linear', 'log'];
296
+ const CURVES = ['linear', 'smooth', 'step'];
297
+ const LEGEND_POS = ['top', 'bottom', 'left', 'right', 'arriba', 'abajo', 'izquierda', 'derecha'];
298
+ const NETWORK_LAYOUTS = ['force', 'circular', 'circle', 'radial', 'concentric', 'clusters', 'tree', 'grid', 'ring', 'cluster', 'groups', 'hierarchy', 'concentrico', 'arbol', 'árbol', 'grilla', 'fuerzas'];
299
+ 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'];
300
+ 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(' ');
301
+ const PALETTE_NAMES = () => [...Object.keys(palettes), ...Object.values(colorScaleNames()).flat()];
302
+ const MAPS = new Set(['choropleth', 'symbolmap', 'pointmap']);
303
+
304
+ function checkOptions(spec, { type, data, fields, err, warn, L }) {
305
+ const q = (v) => (typeof v === 'string' ? `"${v}"` : JSON.stringify(v));
306
+ const enumCheck = (key, value, allowed, example) => {
307
+ if (typeof value !== 'string' || allowed.includes(value)) return;
308
+ const near = didYouMean(value, allowed);
309
+ err('enum-' + key, L(`\`${key}: ${q(value)}\` no es un valor válido.`, `\`${key}: ${q(value)}\` is not a valid value.`),
310
+ (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(', ')}.`));
311
+ };
312
+
313
+ // 1. Nombres de opciones desconocidos (probablemente un error de tipeo).
314
+ const known = knownOptions();
315
+ for (const k of Object.keys(spec)) {
316
+ if (known.has(k) || k.startsWith('_')) continue;
317
+ const near = didYouMean(k, known);
318
+ warn('unknown-option', L(`Opción desconocida \`${k}\`: se ignora.`, `Unknown option \`${k}\`: it is ignored.`),
319
+ 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`).'));
320
+ }
321
+
322
+ // 2. Valores fuera de catálogo.
323
+ const f = spec.format;
324
+ if (typeof f === 'string') enumCheck('format', f, FORMAT_TYPES, '"number", "compact", "percent", "currency", "integer" o { prefix, suffix, decimals }');
325
+ else if (f && typeof f === 'object') {
326
+ if (f.type !== undefined) enumCheck('format', f.type, FORMAT_TYPES);
327
+ if (f.decimals !== undefined && !(Number.isInteger(f.decimals) && f.decimals >= 0 && f.decimals <= 20)) {
328
+ 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 }`.'));
329
+ }
330
+ } 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 }');
331
+ if (spec.stack !== undefined && typeof spec.stack !== 'boolean') {
332
+ if (spec.stack !== 'percent') {
333
+ const near = typeof spec.stack === 'string' ? didYouMean(spec.stack, ['percent']) : null;
334
+ 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%).'));
335
+ }
336
+ }
337
+ for (const k of ['xScale', 'yScale']) if (spec[k] !== undefined) enumCheck(k, spec[k], SCALES, '"linear", "log"');
338
+ if (spec.curve !== undefined) enumCheck('curve', spec.curve, CURVES);
339
+ if (spec.legend !== undefined && typeof spec.legend !== 'boolean') {
340
+ const pos = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : undefined;
341
+ if (pos !== undefined) enumCheck('legend', String(pos).toLowerCase(), LEGEND_POS, 'true, false, "top", "bottom", "left", "right" o { position }');
342
+ }
343
+ if (type === 'network' && spec.layout !== undefined) enumCheck('layout', String(spec.layout).toLowerCase(), NETWORK_LAYOUTS, '"force", "circular", "radial", "concentric", "clusters", "tree", "grid"');
344
+ if (type === 'pointmap') {
345
+ 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
+ if (spec.tileStyle !== undefined) enumCheck('tileStyle', spec.tileStyle, ['muted', 'original']);
347
+ }
348
+ // Un token secreto de Mapbox quedaría a la vista en el SVG.
349
+ const token = spec.tilesKey ?? (spec.tiles && typeof spec.tiles === 'object' ? spec.tiles.key ?? (/[?&]access_token=([^&]+)/.exec(String(spec.tiles.url || '')) || [])[1] : undefined);
350
+ 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.'));
351
+
352
+ // 3. Escalas de color (matplotlib).
353
+ for (const key of ['colorScale', 'cmap', 'colormap']) {
354
+ const v = spec[key];
355
+ if (v === undefined || v === null || v === false) continue;
356
+ const nameOf = typeof v === 'string' ? v : v && typeof v === 'object' && !Array.isArray(v) && typeof (v.name ?? v.colors) === 'string' ? v.name ?? v.colors : null;
357
+ const cs = resolveColorScale(v);
358
+ if (!cs) {
359
+ if (nameOf !== null) {
360
+ const all = Object.values(colorScaleNames()).flat();
361
+ const base = String(nameOf).replace(/_r$/i, '');
362
+ const near = didYouMean(base, all);
363
+ err('colorscale-unknown', L(`La escala de color "${nameOf}" no existe.`, `The color scale "${nameOf}" does not exist.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Usa un nombre de matplotlib ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` invierte) o una lista de colores hex.', 'Use a matplotlib name ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` reverses) or a list of hex colors.'));
364
+ } else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores hex (["#FFF", "#123"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two hex colors (["#FFF", "#123"]) or { name, reverse, mid }.'));
365
+ } else if (cs.kind === 'qualitative') {
366
+ 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
+ } else if (RAINBOW.has(cs.name.replace(/_r$/, ''))) {
368
+ 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).'));
369
+ }
370
+ }
371
+ if (typeof spec.palette === 'string' && !palettes[spec.palette] && !resolveColorScale(spec.palette)) {
372
+ const near = didYouMean(spec.palette, PALETTE_NAMES());
373
+ 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.'));
374
+ }
375
+ if (typeof spec.ramp === 'string' && !ramps[spec.ramp] && !resolveColorScale(spec.ramp)) {
376
+ const near = didYouMean(spec.ramp, [...Object.keys(ramps), ...Object.values(colorScaleNames()).flat()]);
377
+ 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"…'));
378
+ }
379
+ if (typeof spec.theme === 'string' && !themes[spec.theme]) {
380
+ const near = didYouMean(spec.theme, Object.keys(themes));
381
+ 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.'));
382
+ } else if (spec.theme && typeof spec.theme === 'object') {
383
+ for (const k of Object.keys(spec.theme)) {
384
+ if (THEME_KEYS.includes(k)) continue;
385
+ const near = didYouMean(k, THEME_KEYS);
386
+ 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(', ')}.`));
387
+ }
388
+ }
389
+
390
+ // 4. Formas: refLines y annotations van como lista.
391
+ for (const k of ['refLines', 'annotations']) {
392
+ const v = spec[k];
393
+ 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" }'}]`);
394
+ }
395
+
396
+ // 5. highlight / dashed / exclude que no nombran nada de los datos.
397
+ if (!data.length || MAPS.has(type)) return;
398
+ let names = null;
399
+ const has = (v) => {
400
+ if (!names) {
401
+ names = new Set(fields.map((x) => x.toLowerCase()));
402
+ 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()); }
403
+ 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());
404
+ for (const y of [].concat(spec.y || [])) names.add(String(y).toLowerCase());
405
+ }
406
+ return names.has(String(v).toLowerCase());
407
+ };
408
+ for (const key of ['highlight', 'exclude', 'dashed']) {
409
+ const v = spec[key];
410
+ if (v === undefined || v === null || typeof v === 'boolean' || (typeof v === 'object' && !Array.isArray(v))) continue;
411
+ // Fechas y "Otros" pueden no estar escritas igual en los datos: no se marcan.
412
+ 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);
413
+ if (!bad.length) continue;
414
+ 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(', ')}.`),
415
+ 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).'));
416
+ }
417
+ }
package/src/palette.js ADDED
@@ -0,0 +1,59 @@
1
+ // Validador de paletas categóricas: lo mismo que se usó para validar las
2
+ // paletas de fábrica, disponible para que un agente revise la suya.
3
+ // Umbrales: banda de luminosidad OKLCH, croma mínimo, separación ΔE (OKLab×100)
4
+ // entre vecinos bajo protanopía/deuteranopía (Machado 2009) y con visión normal.
5
+
6
+ const BAND = { light: [0.43, 0.77], dark: [0.48, 0.67] };
7
+ const CHROMA_FLOOR = 0.1;
8
+ const CVD_TARGET = 8, CVD_FLOOR = 6, NORMAL_FLOOR = 15;
9
+ const MACHADO = {
10
+ protan: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
11
+ deutan: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
12
+ };
13
+
14
+ const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
15
+ const rgbLin = (hex) => { const h = hex.replace('#', ''); const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(lin); };
16
+ function oklab([r, g, b]) {
17
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
18
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
19
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
20
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
21
+ }
22
+ const sim = (rgb, M) => M.map((row) => Math.max(0, Math.min(1, row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2])));
23
+ function dE(a, b, kind) {
24
+ let A = rgbLin(a), B = rgbLin(b);
25
+ if (kind) { A = sim(A, MACHADO[kind]); B = sim(B, MACHADO[kind]); }
26
+ const p = oklab(A), q = oklab(B);
27
+ return 100 * Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
28
+ }
29
+ const lum = (hex) => { const [r, g, b] = rgbLin(hex); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
30
+ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
31
+
32
+ /**
33
+ * Revisa una paleta categórica. Devuelve { ok, checks: [{ name, status, detail }] }.
34
+ * status: 'pass' | 'warn' | 'fail'. `pairs: 'all'` para dispersión y mapas.
35
+ */
36
+ export function validatePalette(colors, { mode = 'light', surface, pairs = 'adjacent', locale = 'es' } = {}) {
37
+ const en = String(locale).startsWith('en');
38
+ const pal = colors.map(String);
39
+ surface = surface || (mode === 'dark' ? '#0B0F2B' : '#FCFBF9');
40
+ const checks = [];
41
+ const [lo, hi] = BAND[mode] || BAND.light;
42
+ const Ls = pal.map((c) => oklab(rgbLin(c)));
43
+ const off = pal.filter((c, i) => Ls[i][0] < lo || Ls[i][0] > hi);
44
+ checks.push({ name: 'lightness', status: off.length ? 'fail' : 'pass', detail: off.length ? (en ? `Outside the lightness band ${lo}–${hi}: ` : `Fuera de la banda de luminosidad ${lo}–${hi}: `) + off.join(', ') : en ? 'All inside the lightness band.' : 'Todos dentro de la banda de luminosidad.' });
45
+ const gray = pal.filter((c, i) => Math.hypot(Ls[i][1], Ls[i][2]) < CHROMA_FLOOR);
46
+ checks.push({ name: 'chroma', status: gray.length ? 'fail' : 'pass', detail: gray.length ? (en ? 'Read as gray: ' : 'Se leen como gris: ') + gray.join(', ') : en ? 'All have enough chroma.' : 'Todos con color suficiente.' });
47
+ const list = pairs === 'all' ? pal.flatMap((_, i) => pal.slice(i + 1).map((__, k) => [i, i + 1 + k])) : pal.slice(1).map((_, i) => [i, i + 1]);
48
+ let worst = null, nworst = null;
49
+ for (const [i, j] of list) {
50
+ for (const k of ['protan', 'deutan']) { const d = dE(pal[i], pal[j], k); if (!worst || d < worst[0]) worst = [d, pal[i], pal[j], k]; }
51
+ const n = dE(pal[i], pal[j]);
52
+ if (!nworst || n < nworst[0]) nworst = [n, pal[i], pal[j]];
53
+ }
54
+ if (worst) checks.push({ name: 'cvd', status: worst[0] >= CVD_TARGET ? 'pass' : worst[0] >= CVD_FLOOR ? 'warn' : 'fail', detail: en ? `Worst colorblind pair (${worst[3]}): ${worst[1]} ↔ ${worst[2]}, ΔE ${worst[0].toFixed(1)} (target ≥ ${CVD_TARGET}).` : `Peor par para daltonismo (${worst[3]}): ${worst[1]} ↔ ${worst[2]}, ΔE ${worst[0].toFixed(1)} (meta ≥ ${CVD_TARGET}).` });
55
+ if (nworst) checks.push({ name: 'normal', status: nworst[0] >= NORMAL_FLOOR ? 'pass' : 'fail', detail: en ? `Worst normal-vision pair: ${nworst[1]} ↔ ${nworst[2]}, ΔE ${nworst[0].toFixed(1)} (minimum ${NORMAL_FLOOR}).` : `Peor par con visión normal: ${nworst[1]} ↔ ${nworst[2]}, ΔE ${nworst[0].toFixed(1)} (mínimo ${NORMAL_FLOOR}).` });
56
+ const low = pal.filter((c) => contrast(c, surface) < 3);
57
+ checks.push({ name: 'contrast', status: low.length ? 'warn' : 'pass', detail: low.length ? (en ? 'Below 3:1 against the surface (use visible labels): ' : 'Bajo 3:1 contra el fondo (usa etiquetas visibles): ') + low.join(', ') : en ? 'All ≥ 3:1 against the surface.' : 'Todos ≥ 3:1 contra el fondo.' });
58
+ return { ok: !checks.some((c) => c.status === 'fail'), checks };
59
+ }