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.
Files changed (67) hide show
  1. package/AGENTS.md +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -1,31 +1,110 @@
1
1
  // Registro de geometrías. El núcleo no trae mapas pesados: se registran al
2
2
  // importar 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o se pasa un
3
3
  // GeoJSON propio en spec.geo.
4
+ //
5
+ // `geo` acepta, además de un nombre registrado:
6
+ // · un GeoJSON FeatureCollection, un Feature, un arreglo de Features o una geometría suelta
7
+ // · un TopoJSON (Topology); `geoObject` elige el objeto (por defecto, el primero)
8
+ // · en el navegador, una URL (render() la descarga; ver runtime.js)
4
9
  import { PlotError } from '../data.js';
5
10
 
6
11
  const REG = new Map();
7
12
 
8
- export function registerGeo(name, fc, aliases = []) {
9
- if (!fc || fc.type !== 'FeatureCollection') throw new PlotError(`La geometría "${name}" debe ser un GeoJSON FeatureCollection.`);
13
+ export function registerGeo(name, geo, aliases = []) {
14
+ const fc = toFeatureCollection(geo);
10
15
  for (const n of [name, ...aliases]) REG.set(String(n).toLowerCase(), fc);
11
16
  return fc;
12
17
  }
13
18
 
14
- export function getGeo(geo) {
15
- if (geo && typeof geo === 'object') {
16
- if (geo.type === 'FeatureCollection') return geo;
17
- if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
18
- throw new PlotError('`geo` debe ser un nombre registrado o un GeoJSON FeatureCollection.');
19
+ /** ¿Parece una URL o una ruta a un archivo .json / .geojson / .topojson? */
20
+ export const isGeoUrl = (s) => typeof s === 'string' && (/^(https?:)?\/\//i.test(s) || /^(\.{0,2}\/|data:)/.test(s) || /\.(geo|topo)?json(\?.*)?$/i.test(s));
21
+
22
+ export function getGeo(geo, objectName) {
23
+ if (geo && typeof geo === 'object') return toFeatureCollection(geo, objectName);
24
+ if (isGeoUrl(geo)) {
25
+ throw new PlotError(
26
+ `"${geo}" es una URL o un archivo: plot() es sincrónico y no descarga nada.`,
27
+ "En Node, lee el archivo y pásalo como objeto: geo: JSON.parse(fs.readFileSync('mapa.geojson', 'utf8')). En el navegador, render(el, spec) sí acepta la URL."
28
+ );
19
29
  }
20
30
  const fc = REG.get(String(geo || '').toLowerCase());
21
31
  if (!fc) {
22
32
  const known = [...new Set(REG.keys())];
23
33
  throw new PlotError(
24
34
  `No hay una geometría registrada con el nombre "${geo}".`,
25
- known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
35
+ known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON o TopoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
26
36
  );
27
37
  }
28
38
  return fc;
29
39
  }
30
40
 
31
41
  export const registeredGeo = () => [...new Set(REG.keys())];
42
+
43
+ const GEOMS = new Set(['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']);
44
+ const cache = new WeakMap();
45
+
46
+ /** Cualquier forma de GeoJSON / TopoJSON → FeatureCollection. */
47
+ export function toFeatureCollection(geo, objectName) {
48
+ if (!geo || typeof geo !== 'object') throw new PlotError('`geo` debe ser un nombre registrado, un GeoJSON o un TopoJSON.', 'geo: "chile" | "usa" | "world" | { type: "FeatureCollection", features: [...] }');
49
+ if (Array.isArray(geo)) return { type: 'FeatureCollection', features: geo.map(asFeature) };
50
+ if (geo.type === 'FeatureCollection') {
51
+ if (!Array.isArray(geo.features)) throw new PlotError('El FeatureCollection no tiene `features`.');
52
+ return geo.features.every((f) => f && f.type === 'Feature') ? geo : { ...geo, features: geo.features.map(asFeature) };
53
+ }
54
+ if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
55
+ if (GEOMS.has(geo.type)) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: geo }] };
56
+ if (geo.type === 'Topology') {
57
+ const key = objectName ?? '';
58
+ const memo = cache.get(geo) || {};
59
+ if (!memo[key]) { memo[key] = topoFeatures(geo, objectName); cache.set(geo, memo); }
60
+ return memo[key];
61
+ }
62
+ throw new PlotError(`No reconozco este \`geo\` (type: "${geo.type}").`, 'Usa un GeoJSON (FeatureCollection, Feature o geometría) o un TopoJSON (type: "Topology").');
63
+ }
64
+
65
+ function asFeature(f) {
66
+ if (f && f.type === 'Feature') return f;
67
+ if (f && GEOMS.has(f.type)) return { type: 'Feature', properties: {}, geometry: f };
68
+ throw new PlotError('Cada elemento de `geo` debe ser un Feature de GeoJSON.');
69
+ }
70
+
71
+ // ── TopoJSON (decodificador mínimo, sin dependencias) ──────────────────────
72
+ function topoFeatures(topo, objectName) {
73
+ const names = Object.keys(topo.objects || {});
74
+ if (!names.length) throw new PlotError('El TopoJSON no tiene objetos.');
75
+ const name = objectName ?? names[0];
76
+ const obj = topo.objects[name];
77
+ if (!obj) throw new PlotError(`El TopoJSON no tiene el objeto "${name}".`, `Objetos disponibles: ${names.join(', ')}. Indícalo con \`geoObject\`.`);
78
+ const tf = topo.transform;
79
+ // Arcos absolutos (deshace la cuantización y la codificación delta).
80
+ const arcs = (topo.arcs || []).map((arc) => {
81
+ if (!tf) return arc.map((p) => p.slice(0, 2));
82
+ let x = 0, y = 0;
83
+ return arc.map((p) => { x += p[0]; y += p[1]; return [x * tf.scale[0] + tf.translate[0], y * tf.scale[1] + tf.translate[1]]; });
84
+ });
85
+ const point = (p) => (tf ? [p[0] * tf.scale[0] + tf.translate[0], p[1] * tf.scale[1] + tf.translate[1]] : p.slice(0, 2));
86
+ const line = (ids) => {
87
+ const out = [];
88
+ for (const i of ids) {
89
+ const a = i < 0 ? arcs[~i].slice().reverse() : arcs[i];
90
+ if (!a) continue;
91
+ for (let k = out.length ? 1 : 0; k < a.length; k++) out.push(a[k]);
92
+ }
93
+ return out;
94
+ };
95
+ const ring = (ids) => { const r = line(ids); if (r.length && (r[0][0] !== r[r.length - 1][0] || r[0][1] !== r[r.length - 1][1])) r.push(r[0]); return r; };
96
+ const geom = (o) => {
97
+ switch (o.type) {
98
+ case 'Point': return { type: 'Point', coordinates: point(o.coordinates) };
99
+ case 'MultiPoint': return { type: 'MultiPoint', coordinates: o.coordinates.map(point) };
100
+ case 'LineString': return { type: 'LineString', coordinates: line(o.arcs) };
101
+ case 'MultiLineString': return { type: 'MultiLineString', coordinates: o.arcs.map(line) };
102
+ case 'Polygon': return { type: 'Polygon', coordinates: o.arcs.map(ring) };
103
+ case 'MultiPolygon': return { type: 'MultiPolygon', coordinates: o.arcs.map((p) => p.map(ring)) };
104
+ default: return null;
105
+ }
106
+ };
107
+ const feat = (o) => ({ type: 'Feature', id: o.id, properties: { ...(o.properties || {}), ...(o.id !== undefined && (o.properties || {}).id === undefined ? { id: o.id } : {}) }, geometry: geom(o) });
108
+ const list = obj.type === 'GeometryCollection' ? obj.geometries.map(feat) : [feat(obj)];
109
+ return { type: 'FeatureCollection', features: list.filter((f) => f.geometry) };
110
+ }
package/src/hierarchy.js CHANGED
@@ -66,6 +66,12 @@ export function findNode(root, focus = []) {
66
66
  return node;
67
67
  }
68
68
 
69
+ /** Nodo de foco para dibujar: una hoja no se puede "abrir" (quedaría vacío), se usa su padre. */
70
+ export function focusNode(root, focus = []) {
71
+ const n = findNode(root, focus);
72
+ return !n.children.length && n.parent ? n.parent : n;
73
+ }
74
+
69
75
  export const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
70
76
  export const descendants = (n) => [n, ...n.children.flatMap(descendants)];
71
77
 
package/src/i18n.js ADDED
@@ -0,0 +1,107 @@
1
+ // Idioma: reconocimiento a partir del texto del spec y las pocas palabras que
2
+ // la biblioteca escribe por su cuenta (fuente, total, buscar…). Lo demás
3
+ // (paginación, flechas, ✓) se dibuja con símbolos que no dependen del idioma.
4
+ //
5
+ // detectLocale(['Ventas por región, 2025', 'Biobío']) → 'es'
6
+ // detectLocale(['Sales grew 12% in the South']) → 'en'
7
+ // locale: 'auto' en cualquier spec (y por defecto en `table`) lo usa.
8
+
9
+ export const LANGS = ['es', 'en', 'pt', 'fr', 'de'];
10
+
11
+ const WORDS = {
12
+ es: { source: 'Fuente: ', total: 'Total', search: 'Buscar', none: 'Sin resultados', clear: 'Borrar', page: 'Página', prev: 'Anterior', next: 'Siguiente', sort: 'Ordenar por', yes: 'Sí', no: 'No', rows: 'filas', table: 'Tabla', of: 'de' },
13
+ en: { source: 'Source: ', total: 'Total', search: 'Search', none: 'No results', clear: 'Clear', page: 'Page', prev: 'Previous', next: 'Next', sort: 'Sort by', yes: 'Yes', no: 'No', rows: 'rows', table: 'Table', of: 'of' },
14
+ pt: { source: 'Fonte: ', total: 'Total', search: 'Buscar', none: 'Sem resultados', clear: 'Limpar', page: 'Página', prev: 'Anterior', next: 'Próxima', sort: 'Ordenar por', yes: 'Sim', no: 'Não', rows: 'linhas', table: 'Tabela', of: 'de' },
15
+ fr: { source: 'Source : ', total: 'Total', search: 'Rechercher', none: 'Aucun résultat', clear: 'Effacer', page: 'Page', prev: 'Précédente', next: 'Suivante', sort: 'Trier par', yes: 'Oui', no: 'Non', rows: 'lignes', table: 'Tableau', of: 'sur' },
16
+ de: { source: 'Quelle: ', total: 'Gesamt', search: 'Suchen', none: 'Keine Treffer', clear: 'Löschen', page: 'Seite', prev: 'Zurück', next: 'Weiter', sort: 'Sortieren nach', yes: 'Ja', no: 'Nein', rows: 'Zeilen', table: 'Tabelle', of: 'von' },
17
+ };
18
+
19
+ /** Código de idioma soportado ('es', 'en', 'pt', 'fr', 'de') a partir de cualquier etiqueta ('pt-BR', 'en_US'…). */
20
+ export function langOf(locale) {
21
+ const k = String(locale || 'es').toLowerCase().slice(0, 2);
22
+ return LANGS.includes(k) ? k : 'es';
23
+ }
24
+
25
+ /** Palabras de interfaz en el idioma del gráfico. */
26
+ export const words = (locale) => WORDS[langOf(locale)];
27
+
28
+ // Palabras funcionales frecuentes de cada idioma. Una palabra compartida
29
+ // (de, la, que…) reparte su peso entre los idiomas que la usan.
30
+ const STOP = {
31
+ es: 'de la el los las y en por con para del que una un se al más es son sus su según entre como año años sin hasta desde ventas región regiones total mil millones porcentaje hogares personas este esta sobre cada ha han fue lidera mayor menor tasa crecimiento meta comuna comunas mes meses semana proyecto estado avance',
32
+ en: 'the of and in by for with to is are from on at than more its as per was were this that which share sales region regions total thousand million percent households people over each has have had leads largest smallest rate growth target month months week project status progress revenue year years',
33
+ pt: 'de da do das dos e em por com para que uma um ao mais é são sua seu entre como ano anos não até desde pelo pela no na nos nas vendas região total mil milhões taxa crescimento meta mês meses projeto estado cada foi tem',
34
+ fr: 'de la le les et en par pour avec du des que une un au aux plus est sont sa son ses entre comme année années sans jusqu depuis sur dans ventes région total mille millions taux croissance objectif mois semaine projet état chaque été',
35
+ de: 'der die das und in von mit für zu ist sind den dem des ein eine einer im am mehr als nach bei auf aus nicht jahr jahre umsatz region gesamt tausend millionen anteil wachstum ziel monat monate woche projekt status jede wurde hat',
36
+ };
37
+ const INDEX = new Map();
38
+ for (const [lang, list] of Object.entries(STOP)) for (const w of list.split(' ')) { if (!INDEX.has(w)) INDEX.set(w, []); INDEX.get(w).push(lang); }
39
+
40
+ // Letras que delatan un idioma (se suman a las palabras).
41
+ const CHARS = [
42
+ [/[ñ¿¡]/g, { es: 3 }],
43
+ [/[ãõ]/g, { pt: 3 }],
44
+ [/ç/g, { pt: 1.5, fr: 1.5 }],
45
+ [/[èêëàâùûœî]/g, { fr: 2 }],
46
+ [/[äöüß]/g, { de: 3 }],
47
+ [/[áíóú]/g, { es: 1, pt: 1 }],
48
+ [/é/g, { es: 0.5, pt: 0.5, fr: 0.8 }],
49
+ ];
50
+
51
+ /**
52
+ * Idioma más probable de unos textos (títulos, encabezados, categorías), o
53
+ * null si no hay señal suficiente. Determinista y sin diccionarios grandes.
54
+ */
55
+ export function detectLocale(texts) {
56
+ const s = [].concat(texts || []).filter((t) => typeof t === 'string' && t).join(' \n ').toLowerCase().slice(0, 6000);
57
+ if (!s) return null;
58
+ const score = { es: 0, en: 0, pt: 0, fr: 0, de: 0 };
59
+ for (const tok of s.split(/[^\p{L}]+/u)) {
60
+ const langs = tok && INDEX.get(tok);
61
+ if (langs) for (const l of langs) score[l] += 1 / langs.length;
62
+ }
63
+ for (const [re, w] of CHARS) {
64
+ const n = (s.match(re) || []).length;
65
+ if (n) for (const [l, v] of Object.entries(w)) score[l] += Math.min(6, n) * v;
66
+ }
67
+ const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
68
+ const [[best, a], [, b]] = ranked;
69
+ if (a < 1 || a < b * 1.25 + 0.25) return null;
70
+ return best;
71
+ }
72
+
73
+ /** Idioma del documento en el navegador (`<html lang>` o el del sistema), o null. */
74
+ export function browserLocale() {
75
+ // Solo en un navegador: Node 21+ también define `navigator`, y el SVG del servidor no debe depender de él.
76
+ if (typeof document === 'undefined') return null;
77
+ try {
78
+ const l = (document.documentElement && document.documentElement.lang) || (typeof navigator !== 'undefined' && navigator.language) || '';
79
+ const k = String(l).toLowerCase().slice(0, 2);
80
+ return LANGS.includes(k) ? k : null;
81
+ } catch { return null; }
82
+ }
83
+
84
+ /**
85
+ * Textos de un spec que delatan su idioma: título, bajada, fuente, nombres de
86
+ * columna y algunas categorías.
87
+ */
88
+ export function specTexts(spec, data) {
89
+ const out = [spec.title, spec.subtitle, spec.source, spec.dashedLabel];
90
+ for (const c of [].concat(Array.isArray(spec.columns) ? spec.columns : [])) {
91
+ if (typeof c === 'string') out.push(c);
92
+ else if (c && typeof c === 'object') out.push(c.label, c.field);
93
+ }
94
+ if (Array.isArray(data)) {
95
+ const rows = data.slice(0, 40);
96
+ const keys = new Set();
97
+ for (const r of rows) if (r && typeof r === 'object') for (const [k, v] of Object.entries(r)) { keys.add(k); if (typeof v === 'string' && v.length > 2 && !/^[\d\s.,%$€£-]+$/.test(v)) out.push(v); }
98
+ // Nombres de campo con guiones bajos o camelCase: "ventas_region" → "ventas region".
99
+ for (const k of keys) out.push(String(k).replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'));
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Resuelve `locale: "auto"`: idioma del texto, luego el del navegador, luego español. */
105
+ export function autoLocale(spec, data) {
106
+ return detectLocale(specTexts(spec, data)) || browserLocale() || 'es';
107
+ }
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // import { plot } from 'plotagents';
4
4
  // const svg = plot({ data, x: 'region', y: 'ventas', title: 'Biobío lidera las ventas' });
5
5
  //
6
- import { getTheme, themeFor, themes, palettes, ramps, colorMap, fontSpec, FONT_CSS } from './theme.js';
6
+ import { getTheme, themeFor, themes, palettes, ramps, colorMap, fontSpec, deepMerge, registerTheme, isColor, FONT_CSS } from './theme.js';
7
7
  import { withContext } from './context.js';
8
8
  import { validatePalette } from './palette.js';
9
9
  import { bar } from './charts/bar.js';
@@ -20,26 +20,34 @@ import { network } from './charts/network.js';
20
20
  import { chord } from './charts/chord.js';
21
21
  import { pyramid } from './charts/pyramid.js';
22
22
  import { flowchart } from './charts/flowchart.js';
23
+ import { gantt } from './charts/gantt.js';
24
+ import { wordcloud, countWords } from './charts/wordcloud.js';
23
25
  import { choropleth, symbolMap } from './charts/choropleth.js';
24
- import { registerGeo, getGeo, registeredGeo } from './geo/registry.js';
26
+ import { pointmap, embedTiles, TILE_PROVIDERS } from './charts/pointmap.js';
27
+ import { registerGeo, getGeo, registeredGeo, toFeatureCollection } from './geo/registry.js';
25
28
  // Mapas incluidos (Chile por regiones, estados de EE. UU. y países del mundo): listos sin importar nada más.
26
29
  // El bundle del navegador los separa en dist/plotagents-geo.js.
27
30
  import './geo/chile.js';
28
31
  import './geo/world.js';
29
32
  import './geo/usa.js';
30
33
  import { heatmap } from './charts/heatmap.js';
34
+ import { corr, pairplot, correlations } from './charts/corr.js';
31
35
  import { sankey } from './charts/sankey.js';
32
36
  import { kpi } from './charts/kpi.js';
37
+ import { table } from './charts/table.js';
38
+ import { autoLocale, detectLocale } from './i18n.js';
33
39
  import { ensureData, PlotError, fieldKind, uniq, num } from './data.js';
34
40
  import { facet } from './facet.js';
35
41
  import { parseDate } from './format.js';
36
42
  import { inferType, TYPES, ALIASES, normalizeType } from './infer.js';
37
43
  import { lint, _setProbe } from './lint.js';
38
- import { schema, toolDefinition } from './schema.js';
44
+ import { schema, toolDefinition, dashboardSchema } from './schema.js';
39
45
  import { attach, attachSource } from './interact.js';
40
- import { render, _bind } from './runtime.js';
46
+ import { render, _bind, loadGeo } from './runtime.js';
47
+ import { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows, _bindDashboard } from './dashboard.js';
41
48
  import { makeFormat, formatNumber, formatCompact } from './format.js';
42
49
  import { esc } from './svg.js';
50
+ import { resolveColorScale, colorScaleNames, interpolate as interpolateColor, COLORMAPS, QUALITATIVE } from './colorscales.js';
43
51
 
44
52
  const RENDERERS = {
45
53
  bar: (s, t) => bar(s, t),
@@ -54,8 +62,11 @@ const RENDERERS = {
54
62
  donut: (s, t) => donut(s, t),
55
63
  pie: (s, t) => pie(s, t),
56
64
  heatmap: (s, t) => heatmap(s, t),
65
+ corr: (s, t) => corr(s, t),
66
+ pairplot: (s, t) => pairplot(s, t),
57
67
  sankey: (s, t) => sankey(s, t),
58
68
  kpi: (s, t) => kpi(s, t),
69
+ table: (s, t) => table(s, t),
59
70
  hist: (s, t) => hist(s, t),
60
71
  kde: (s, t) => kde(s, t),
61
72
  ecdf: (s, t) => ecdf(s, t),
@@ -72,8 +83,11 @@ const RENDERERS = {
72
83
  chord: (s, t) => chord(s, t),
73
84
  pyramid: (s, t) => pyramid(s, t),
74
85
  flowchart: (s, t) => flowchart(s, t),
86
+ gantt: (s, t) => gantt(s, t),
87
+ wordcloud: (s, t) => wordcloud(s, t),
75
88
  choropleth: (s, t) => choropleth(s, t),
76
89
  symbolmap: (s, t) => symbolMap(s, t),
90
+ pointmap: (s, t) => pointmap(s, t),
77
91
  };
78
92
 
79
93
 
@@ -82,19 +96,35 @@ const RENDERERS = {
82
96
  export function resolve(spec = {}) {
83
97
  if (!spec || typeof spec !== 'object') throw new PlotError('El spec debe ser un objeto.', 'plot({ data: [...], x: "campo", y: "campo" })');
84
98
  const s = withDefaults(spec);
99
+ // refLines / annotations: un objeto suelto vale como lista de uno.
100
+ for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
85
101
  if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
86
- let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
102
+ let type = s.type && s.type !== 'auto' ? String(s.type).trim().toLowerCase() : null;
87
103
  if (type) {
88
104
  const [t, extra] = normalizeType(type);
89
105
  type = t;
90
106
  for (const [k, v] of Object.entries(extra)) if (s[k] === undefined) s[k] = v;
91
107
  }
92
108
  if (!type) {
93
- const data = s.items ? [] : ensureData(s);
109
+ const data = s.items || (!s.data && (typeof s.text === 'string' || Array.isArray(s.text))) ? [] : ensureData(s);
94
110
  type = inferType(s, data);
95
111
  }
96
112
  if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
97
113
  s.type = type;
114
+ // Idioma: `locale: "auto"` (y las tablas sin `locale`) lo reconocen del texto del spec.
115
+ if (s.locale === 'auto' || (s.locale === undefined && type === 'table')) {
116
+ s.locale = autoLocale(s, Array.isArray(s.data) ? s.data : []);
117
+ }
118
+ // Colormap divergente (RdBu, coolwarm…): centrada en 0 si los datos lo cruzan; si no, en el medio.
119
+ const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
120
+ if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
121
+ const vf = typeof s.colorScale === 'object' && s.colorScale.mid !== undefined ? null : s.value;
122
+ if (typeof s.colorScale === 'object' && s.colorScale?.mid !== undefined) s.midpoint = +s.colorScale.mid;
123
+ else if (vf && Array.isArray(s.data) && s.data.length && typeof s.data[0] === 'object') {
124
+ const vs = s.data.map((r) => num(r[vf])).filter(Number.isFinite);
125
+ if (vs.length) { let lo = Infinity, hi = -Infinity; for (const v of vs) { if (v < lo) lo = v; if (v > hi) hi = v; } s.midpoint = lo < 0 && hi > 0 ? 0 : (lo + hi) / 2; }
126
+ } else s.diverging = true;
127
+ }
98
128
  return s;
99
129
  }
100
130
 
@@ -105,6 +135,7 @@ export function resolve(spec = {}) {
105
135
  export function plot(spec) {
106
136
  let s = resolve(spec);
107
137
  s = applyTime(s);
138
+ s = applyExclude(s);
108
139
  const theme = themeFor(s);
109
140
  const textScale = Math.max(0.5, Math.min(4, +(s.textScale ?? s.fontScale ?? s.labelScale ?? 1) || 1));
110
141
  return withContext({ textScale }, () => {
@@ -131,7 +162,12 @@ export function configure(options) {
131
162
 
132
163
  function withDefaults(spec) {
133
164
  const s = { ...DEFAULTS, ...spec };
134
- if (DEFAULTS.theme && typeof DEFAULTS.theme === 'object' && spec.theme && typeof spec.theme === 'object') s.theme = { ...DEFAULTS.theme, ...spec.theme };
165
+ // Colores fijados por entidad: solo colores CSS válidos (viajan a atributos y al tooltip).
166
+ if (s.colors && typeof s.colors === 'object') s.colors = Object.fromEntries(Object.entries(s.colors).filter(([, c]) => isColor(c)));
167
+ // Tema global + tema del spec: el del spec se fusiona en profundidad sobre el global.
168
+ if (DEFAULTS.theme && spec.theme && typeof spec.theme === 'object' && spec.theme.extends === undefined) {
169
+ s.theme = typeof DEFAULTS.theme === 'string' ? { ...spec.theme, extends: DEFAULTS.theme } : deepMerge(DEFAULTS.theme, spec.theme);
170
+ }
135
171
  // `font`: atajo de theme.font (familia + URL para cargarla).
136
172
  const f = fontSpec(s.font);
137
173
  if (f) {
@@ -173,6 +209,37 @@ function applyTime(s) {
173
209
  return { ...s, data, colors: stableColors(s, all), subtitle: s.subtitle ? `${s.subtitle} · ${label}` : label, _time: { field: s.time, value: cur, values: vals }, _prev };
174
210
  }
175
211
 
212
+ /** Campo que define las entradas de la leyenda (series, primer nivel, grupo…). */
213
+ function legendField(s) {
214
+ if (Array.isArray(s.path) && s.path.length) return s.path[0];
215
+ return s.series ?? s.hue ?? s.group ?? (['donut', 'pie'].includes(s.type) ? s.label ?? s.x : null) ?? (s.type === 'treemap' || s.type === 'sunburst' ? s.label ?? s.x : null);
216
+ }
217
+
218
+ /**
219
+ * `exclude: ["Maule"]` (o shift + clic en la leyenda): quita esas entradas y
220
+ * recalcula ejes, participaciones y layout con lo que queda. Los colores no
221
+ * se mueven y las entradas excluidas siguen en la leyenda, apagadas.
222
+ */
223
+ function applyExclude(s) {
224
+ const ex = s.exclude == null || s.exclude === false ? [] : [].concat(s.exclude).map(String);
225
+ if (!ex.length) return s;
226
+ // Tabla ancha: y: ["ingresos", "costos"] → se excluyen columnas.
227
+ if (Array.isArray(s.y) && !s.series && !s.hue) {
228
+ const keep = s.y.filter((k) => !ex.includes(String(k)));
229
+ if (!keep.length || keep.length === s.y.length) return s;
230
+ const cmap = colorMap(s.y, themeFor(s), s.colors);
231
+ return { ...s, y: keep, colors: { ...Object.fromEntries(cmap), ...(s.colors || {}) }, _excluded: s.y.filter((k) => ex.includes(String(k))) };
232
+ }
233
+ const f = legendField(s);
234
+ if (!f || s.items) return s;
235
+ const all = ensureData(s);
236
+ if (!all.length || !(f in all[0])) return s;
237
+ const keep = all.filter((r) => !ex.includes(String(r[f])));
238
+ if (!keep.length || keep.length === all.length) return s;
239
+ const present = new Set(all.map((r) => String(r[f])));
240
+ return { ...s, data: keep, colors: { ...stableColors(s, all), ...(s.colors || {}) }, _excluded: ex.filter((v) => present.has(v)) };
241
+ }
242
+
176
243
  /**
177
244
  * Colores fijos por entidad a lo largo de TODOS los periodos: al mover el
178
245
  * slider, "Minería" sigue siendo índigo aunque cambie su tamaño o su orden.
@@ -200,9 +267,10 @@ export function safePlot(spec) {
200
267
  const report = lint(spec || {});
201
268
  try {
202
269
  const svg = plot(spec);
203
- return { ok: true, svg, type: report.type, description: describeSvg(svg), errors: [], warnings: report.warnings, suggestions: report.suggestions };
270
+ // Se dibujó (con valores de respaldo), pero los errores de lint igual se devuelven: ok = sin errores.
271
+ return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
204
272
  } catch (e) {
205
- const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
273
+ const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa los campos del spec contra el schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }];
206
274
  return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
207
275
  }
208
276
  }
@@ -224,9 +292,11 @@ function describeSvg(svg) {
224
292
  */
225
293
  export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
226
294
  const list = Array.isArray(specs) ? specs : [specs];
227
- const dark = list.every((s) => themeFor(s).mode === 'dark');
228
- const bg = dark ? '#0B0F2B' : '#FCFBF9';
229
- const needsGeo = list.some((s) => { try { return ['choropleth', 'symbolmap'].includes(resolve(s).type); } catch { return false; } });
295
+ const themesOf = list.map((s) => themeFor(withDefaults(s)));
296
+ const dark = themesOf.every((t) => t.mode === 'dark');
297
+ // Fondo de la página = fondo del tema (también uno global de configure()).
298
+ const bg = themesOf.every((t) => t.surface === themesOf[0].surface) ? themesOf[0].surface : dark ? '#0B0F2B' : '#FCFBF9';
299
+ const needsGeo = list.some((s) => { try { return ['choropleth', 'symbolmap', 'pointmap'].includes(resolve(s).type); } catch { return false; } });
230
300
  const bundle = bundleSource() && (needsGeo ? bundleSource() + '\n' + (bundleSource('plotagents-geo.js') || '') : bundleSource());
231
301
  const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
232
302
  const charts = list.map((s, i) => `<figure class="pa" id="pa-${i}">${plot(s)}</figure>` + (bundle ? `<script type="application/json" id="pa-spec-${i}">${safeJSON(s)}</script>` : '')).join('\n');
@@ -239,7 +309,7 @@ export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 }
239
309
  ${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
240
310
  <style>
241
311
  :root{color-scheme:${dark ? 'dark' : 'light'}}
242
- body{margin:0;background:${bg};font-family:${esc(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
312
+ body{margin:0;background:${cssText(bg)};font-family:${cssText(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
243
313
  main{max-width:${columns > 1 ? 1500 : 820}px;margin:0 auto;padding:24px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,${columns > 1 ? 560 : 320}px),1fr));gap:24px}
244
314
  figure.pa{margin:0;min-width:0}
245
315
  figure.pa svg{width:100%;height:auto;display:block}
@@ -251,6 +321,9 @@ ${boot}
251
321
  </body></html>`;
252
322
  }
253
323
 
324
+ /** Texto dentro de <style>: las entidades HTML no se decodifican ahí (esc() rompería las comillas), así que se quitan los caracteres que cierran la regla o la etiqueta. */
325
+ const cssText = (v) => String(v ?? '').replace(/[<>{};\\&]/g, '');
326
+
254
327
  /** Código de la biblioteca en un archivo (para incrustar en HTML). */
255
328
  export function bundleSource(file = 'plotagents.js') {
256
329
  try {
@@ -263,9 +336,14 @@ export function bundleSource(file = 'plotagents.js') {
263
336
  }
264
337
 
265
338
  _bind(plot, resolve);
339
+ _bindDashboard({ plot, resolve, lint, bundleSource, resolveDefaults: withDefaults });
266
340
  _setProbe((s) => plot(s));
267
341
  export { render };
268
342
 
269
- export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo };
343
+ export { countWords, dashboardSchema, detectLocale, correlations };
344
+ export { resolveColorScale, colorScaleNames, interpolateColor, COLORMAPS, QUALITATIVE };
345
+ export { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows };
346
+ export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo, toFeatureCollection, loadGeo };
347
+ export { registerTheme, withDefaults as resolveDefaults, embedTiles, TILE_PROVIDERS };
270
348
  export { lint, schema, toolDefinition, themes, palettes, ramps, validatePalette, getTheme, themeFor, attach, PlotError, TYPES, squarify, makeFormat, formatNumber, formatCompact };
271
349
  export default plot;
package/src/infer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Elige el tipo de gráfico cuando el agente no lo indica (type: "auto").
2
2
  import { fieldsOf, fieldKind } from './data.js';
3
3
 
4
- export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'choropleth', 'symbolmap'];
4
+ export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap', 'table', 'corr', 'pairplot'];
5
5
 
6
6
  // Nombres de seaborn, d3plus y otros: el agente puede usar el que conozca.
7
7
  export const ALIASES = {
@@ -9,10 +9,18 @@ export const ALIASES = {
9
9
  scatterplot: 'scatter', histplot: 'hist', histogram: 'hist', kdeplot: 'kde', density: 'kde', ecdfplot: 'ecdf',
10
10
  lmplot: 'regplot', reg: 'regplot', regression: 'regplot', joint: 'jointplot', boxplot: 'box', violinplot: 'violin',
11
11
  stripplot: 'strip', swarmplot: 'swarm', beeswarm: 'swarm', pointplot: 'point', spider: 'radar', doughnut: 'donut',
12
+ pointmap: 'pointmap', dotmap: 'pointmap', 'dot-map': 'pointmap', tilemap: 'pointmap', basemap: 'pointmap', 'mapa-puntos': 'pointmap', 'mapa-de-puntos': 'pointmap', latlon: 'pointmap',
12
13
  graph: 'network', force: 'network', red: 'network', 'chord-diagram': 'chord', cuerdas: 'chord',
13
14
  populationpyramid: 'pyramid', population: 'pyramid', piramide: 'pyramid', 'pirámide': 'pyramid',
14
15
  flow: 'flowchart', diagram: 'flowchart', process: 'flowchart', diagrama: 'flowchart',
15
16
  map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
17
+ words: 'wordcloud', 'word-cloud': 'wordcloud', wordclouds: 'wordcloud', tagcloud: 'wordcloud', 'tag-cloud': 'wordcloud', nube: 'wordcloud', 'nube-de-palabras': 'wordcloud', cloud: 'wordcloud',
18
+ timeline: 'gantt', roadmap: 'gantt', schedule: 'gantt', cronograma: 'gantt', 'carta-gantt': 'gantt', ganttchart: 'gantt', 'gantt-chart': 'gantt', planning: 'gantt',
19
+ tabla: 'table', tables: 'table', datatable: 'table', 'data-table': 'table', grid: 'table', tableau: 'table', tabelle: 'table', tabela: 'table', spreadsheet: 'table', planilla: 'table',
20
+ correlation: 'corr', correlations: 'corr', corrplot: 'corr', corrmatrix: 'corr', 'corr-matrix': 'corr', 'correlation-matrix': 'corr', correlogram: 'corr', correlograma: 'corr',
21
+ correlacion: 'corr', 'correlación': 'corr', 'matriz-correlacion': 'corr', 'matriz-de-correlacion': 'corr', 'matriz-de-correlación': 'corr',
22
+ clustermap: ['corr', { cluster: true }], 'cluster-map': ['corr', { cluster: true }],
23
+ pairs: 'pairplot', pairgrid: 'pairplot', splom: 'pairplot', scattermatrix: 'pairplot', 'scatter-matrix': 'pairplot', 'scatterplot-matrix': 'pairplot', 'matriz-de-dispersion': 'pairplot', 'matriz-de-dispersión': 'pairplot',
16
24
  tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
17
25
  };
18
26
 
@@ -26,16 +34,25 @@ export function normalizeType(t) {
26
34
 
27
35
  export function inferType(spec, data = []) {
28
36
  if (spec.items) return 'kpi';
37
+ if (Array.isArray(spec.columns) || spec.pageSize !== undefined) return 'table';
29
38
  if (spec.geo) return 'choropleth';
39
+ if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
40
+ { const f = fieldsOf(data); if (f.some((k) => /^(lat|latitude|latitud)$/i.test(k)) && f.some((k) => /^(lon|lng|long|longitude|longitud)$/i.test(k))) return 'pointmap'; }
41
+ if ((typeof spec.text === 'string' && !(data[0] && spec.text in data[0])) || (Array.isArray(spec.text) && spec.text.length && typeof spec.text[0] === 'string') || spec.textField) return 'wordcloud';
42
+ if (spec.start !== undefined && (spec.end !== undefined || spec.duration !== undefined) && typeof spec.start === 'string') return 'gantt';
30
43
  if (spec.nodes && spec.nodes.some?.((n) => n && n.type)) return 'flowchart';
31
44
  if (spec.nodes) return 'network';
32
45
  if (spec.matrix) return 'chord';
33
46
  if (spec.from || spec.to) return 'sankey';
34
47
  if (spec.path || spec.group) return 'treemap';
48
+ if (spec.triangle !== undefined || spec.cluster !== undefined || spec.method !== undefined) return 'corr';
49
+ if (spec.diag !== undefined || spec.corner !== undefined || spec.upper !== undefined) return 'pairplot';
35
50
  if (spec.size) return 'bubble';
36
51
  const fields = fieldsOf(data);
37
52
  if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
38
53
  const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
54
+ // Dos fechas por fila (inicio y fin) y un nombre: un cronograma.
55
+ if (fields.filter((f) => kinds[f] === 'date').length >= 2 && fields.some((f) => kinds[f] === 'category') && !spec.y && !spec.series) return 'gantt';
39
56
  const x = spec.x ?? fields[0];
40
57
  const xk = kinds[x];
41
58
  if (xk === 'date' || xk === 'year') return 'line';