plotagents 0.5.0 → 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 (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
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,8 +20,11 @@ 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';
@@ -35,11 +38,13 @@ import { facet } from './facet.js';
35
38
  import { parseDate } from './format.js';
36
39
  import { inferType, TYPES, ALIASES, normalizeType } from './infer.js';
37
40
  import { lint, _setProbe } from './lint.js';
38
- import { schema, toolDefinition } from './schema.js';
41
+ import { schema, toolDefinition, dashboardSchema } from './schema.js';
39
42
  import { attach, attachSource } from './interact.js';
40
- import { render, _bind } from './runtime.js';
43
+ import { render, _bind, loadGeo } from './runtime.js';
44
+ import { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows, _bindDashboard } from './dashboard.js';
41
45
  import { makeFormat, formatNumber, formatCompact } from './format.js';
42
46
  import { esc } from './svg.js';
47
+ import { resolveColorScale, colorScaleNames, interpolate as interpolateColor, COLORMAPS, QUALITATIVE } from './colorscales.js';
43
48
 
44
49
  const RENDERERS = {
45
50
  bar: (s, t) => bar(s, t),
@@ -72,8 +77,11 @@ const RENDERERS = {
72
77
  chord: (s, t) => chord(s, t),
73
78
  pyramid: (s, t) => pyramid(s, t),
74
79
  flowchart: (s, t) => flowchart(s, t),
80
+ gantt: (s, t) => gantt(s, t),
81
+ wordcloud: (s, t) => wordcloud(s, t),
75
82
  choropleth: (s, t) => choropleth(s, t),
76
83
  symbolmap: (s, t) => symbolMap(s, t),
84
+ pointmap: (s, t) => pointmap(s, t),
77
85
  };
78
86
 
79
87
 
@@ -82,6 +90,8 @@ const RENDERERS = {
82
90
  export function resolve(spec = {}) {
83
91
  if (!spec || typeof spec !== 'object') throw new PlotError('El spec debe ser un objeto.', 'plot({ data: [...], x: "campo", y: "campo" })');
84
92
  const s = withDefaults(spec);
93
+ // refLines / annotations: un objeto suelto vale como lista de uno.
94
+ for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
85
95
  if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
86
96
  let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
87
97
  if (type) {
@@ -90,11 +100,21 @@ export function resolve(spec = {}) {
90
100
  for (const [k, v] of Object.entries(extra)) if (s[k] === undefined) s[k] = v;
91
101
  }
92
102
  if (!type) {
93
- const data = s.items ? [] : ensureData(s);
103
+ const data = s.items || (!s.data && (typeof s.text === 'string' || Array.isArray(s.text))) ? [] : ensureData(s);
94
104
  type = inferType(s, data);
95
105
  }
96
106
  if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
97
107
  s.type = type;
108
+ // Colormap divergente (RdBu, coolwarm…): centrada en 0 si los datos lo cruzan; si no, en el medio.
109
+ const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
110
+ if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
111
+ const vf = typeof s.colorScale === 'object' && s.colorScale.mid !== undefined ? null : s.value;
112
+ if (typeof s.colorScale === 'object' && s.colorScale?.mid !== undefined) s.midpoint = +s.colorScale.mid;
113
+ else if (vf && Array.isArray(s.data) && s.data.length && typeof s.data[0] === 'object') {
114
+ const vs = s.data.map((r) => num(r[vf])).filter(Number.isFinite);
115
+ 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; }
116
+ } else s.diverging = true;
117
+ }
98
118
  return s;
99
119
  }
100
120
 
@@ -105,6 +125,7 @@ export function resolve(spec = {}) {
105
125
  export function plot(spec) {
106
126
  let s = resolve(spec);
107
127
  s = applyTime(s);
128
+ s = applyExclude(s);
108
129
  const theme = themeFor(s);
109
130
  const textScale = Math.max(0.5, Math.min(4, +(s.textScale ?? s.fontScale ?? s.labelScale ?? 1) || 1));
110
131
  return withContext({ textScale }, () => {
@@ -131,7 +152,12 @@ export function configure(options) {
131
152
 
132
153
  function withDefaults(spec) {
133
154
  const s = { ...DEFAULTS, ...spec };
134
- if (DEFAULTS.theme && typeof DEFAULTS.theme === 'object' && spec.theme && typeof spec.theme === 'object') s.theme = { ...DEFAULTS.theme, ...spec.theme };
155
+ // Colores fijados por entidad: solo colores CSS válidos (viajan a atributos y al tooltip).
156
+ if (s.colors && typeof s.colors === 'object') s.colors = Object.fromEntries(Object.entries(s.colors).filter(([, c]) => isColor(c)));
157
+ // Tema global + tema del spec: el del spec se fusiona en profundidad sobre el global.
158
+ if (DEFAULTS.theme && spec.theme && typeof spec.theme === 'object' && spec.theme.extends === undefined) {
159
+ s.theme = typeof DEFAULTS.theme === 'string' ? { ...spec.theme, extends: DEFAULTS.theme } : deepMerge(DEFAULTS.theme, spec.theme);
160
+ }
135
161
  // `font`: atajo de theme.font (familia + URL para cargarla).
136
162
  const f = fontSpec(s.font);
137
163
  if (f) {
@@ -173,6 +199,37 @@ function applyTime(s) {
173
199
  return { ...s, data, colors: stableColors(s, all), subtitle: s.subtitle ? `${s.subtitle} · ${label}` : label, _time: { field: s.time, value: cur, values: vals }, _prev };
174
200
  }
175
201
 
202
+ /** Campo que define las entradas de la leyenda (series, primer nivel, grupo…). */
203
+ function legendField(s) {
204
+ if (Array.isArray(s.path) && s.path.length) return s.path[0];
205
+ 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);
206
+ }
207
+
208
+ /**
209
+ * `exclude: ["Maule"]` (o shift + clic en la leyenda): quita esas entradas y
210
+ * recalcula ejes, participaciones y layout con lo que queda. Los colores no
211
+ * se mueven y las entradas excluidas siguen en la leyenda, apagadas.
212
+ */
213
+ function applyExclude(s) {
214
+ const ex = s.exclude == null || s.exclude === false ? [] : [].concat(s.exclude).map(String);
215
+ if (!ex.length) return s;
216
+ // Tabla ancha: y: ["ingresos", "costos"] → se excluyen columnas.
217
+ if (Array.isArray(s.y) && !s.series && !s.hue) {
218
+ const keep = s.y.filter((k) => !ex.includes(String(k)));
219
+ if (!keep.length || keep.length === s.y.length) return s;
220
+ const cmap = colorMap(s.y, themeFor(s), s.colors);
221
+ return { ...s, y: keep, colors: { ...Object.fromEntries(cmap), ...(s.colors || {}) }, _excluded: s.y.filter((k) => ex.includes(String(k))) };
222
+ }
223
+ const f = legendField(s);
224
+ if (!f || s.items) return s;
225
+ const all = ensureData(s);
226
+ if (!all.length || !(f in all[0])) return s;
227
+ const keep = all.filter((r) => !ex.includes(String(r[f])));
228
+ if (!keep.length || keep.length === all.length) return s;
229
+ const present = new Set(all.map((r) => String(r[f])));
230
+ return { ...s, data: keep, colors: { ...stableColors(s, all), ...(s.colors || {}) }, _excluded: ex.filter((v) => present.has(v)) };
231
+ }
232
+
176
233
  /**
177
234
  * Colores fijos por entidad a lo largo de TODOS los periodos: al mover el
178
235
  * slider, "Minería" sigue siendo índigo aunque cambie su tamaño o su orden.
@@ -200,7 +257,8 @@ export function safePlot(spec) {
200
257
  const report = lint(spec || {});
201
258
  try {
202
259
  const svg = plot(spec);
203
- return { ok: true, svg, type: report.type, description: describeSvg(svg), errors: [], warnings: report.warnings, suggestions: report.suggestions };
260
+ // Se dibujó (con valores de respaldo), pero los errores de lint igual se devuelven: ok = sin errores.
261
+ return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
204
262
  } catch (e) {
205
263
  const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
206
264
  return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
@@ -224,9 +282,11 @@ function describeSvg(svg) {
224
282
  */
225
283
  export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
226
284
  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; } });
285
+ const themesOf = list.map((s) => themeFor(withDefaults(s)));
286
+ const dark = themesOf.every((t) => t.mode === 'dark');
287
+ // Fondo de la página = fondo del tema (también uno global de configure()).
288
+ const bg = themesOf.every((t) => t.surface === themesOf[0].surface) ? themesOf[0].surface : dark ? '#0B0F2B' : '#FCFBF9';
289
+ const needsGeo = list.some((s) => { try { return ['choropleth', 'symbolmap', 'pointmap'].includes(resolve(s).type); } catch { return false; } });
230
290
  const bundle = bundleSource() && (needsGeo ? bundleSource() + '\n' + (bundleSource('plotagents-geo.js') || '') : bundleSource());
231
291
  const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
232
292
  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');
@@ -263,9 +323,14 @@ export function bundleSource(file = 'plotagents.js') {
263
323
  }
264
324
 
265
325
  _bind(plot, resolve);
326
+ _bindDashboard({ plot, resolve, lint, bundleSource, resolveDefaults: withDefaults });
266
327
  _setProbe((s) => plot(s));
267
328
  export { render };
268
329
 
269
- export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo };
330
+ export { countWords, dashboardSchema };
331
+ export { resolveColorScale, colorScaleNames, interpolateColor, COLORMAPS, QUALITATIVE };
332
+ export { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows };
333
+ export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo, toFeatureCollection, loadGeo };
334
+ export { registerTheme, withDefaults as resolveDefaults, embedTiles, TILE_PROVIDERS };
270
335
  export { lint, schema, toolDefinition, themes, palettes, ramps, validatePalette, getTheme, themeFor, attach, PlotError, TYPES, squarify, makeFormat, formatNumber, formatCompact };
271
336
  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'];
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,13 @@ 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',
16
19
  tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
17
20
  };
18
21
 
@@ -27,6 +30,10 @@ export function normalizeType(t) {
27
30
  export function inferType(spec, data = []) {
28
31
  if (spec.items) return 'kpi';
29
32
  if (spec.geo) return 'choropleth';
33
+ if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
34
+ { 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'; }
35
+ 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';
36
+ if (spec.start !== undefined && (spec.end !== undefined || spec.duration !== undefined) && typeof spec.start === 'string') return 'gantt';
30
37
  if (spec.nodes && spec.nodes.some?.((n) => n && n.type)) return 'flowchart';
31
38
  if (spec.nodes) return 'network';
32
39
  if (spec.matrix) return 'chord';
@@ -36,6 +43,8 @@ export function inferType(spec, data = []) {
36
43
  const fields = fieldsOf(data);
37
44
  if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
38
45
  const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
46
+ // Dos fechas por fila (inicio y fin) y un nombre: un cronograma.
47
+ if (fields.filter((f) => kinds[f] === 'date').length >= 2 && fields.some((f) => kinds[f] === 'category') && !spec.y && !spec.series) return 'gantt';
39
48
  const x = spec.x ?? fields[0];
40
49
  const xk = kinds[x];
41
50
  if (xk === 'date' || xk === 'year') return 'line';
package/src/interact.js CHANGED
@@ -12,12 +12,18 @@ export function attach(root) {
12
12
  const dark = svg.getAttribute('data-pa-mode') === 'dark';
13
13
  const tipEl = document.createElement('div');
14
14
  tipEl.setAttribute('role', 'tooltip');
15
+ tipEl.id = 'pa-tip-' + Math.random().toString(36).slice(2, 8);
16
+ // Tooltip como tarjeta: fondo del tema (o `theme.tooltip`), cabecera del color de la marca.
17
+ const custom = (svg.getAttribute('data-pa-tip') || '').split(',');
18
+ const tipBg = custom[0] || (dark ? '#151A3D' : '#FFFFFF');
19
+ const tipInk = custom[1] || (dark ? '#F2F3FA' : '#141A3F');
15
20
  tipEl.style.cssText = [
16
21
  'position:absolute', 'pointer-events:none', 'z-index:10', 'opacity:0', 'transition:opacity .12s',
17
22
  'padding:8px 10px', 'border-radius:8px', 'font:12.5px/1.45 ' + (svg.getAttribute('font-family') || 'system-ui'),
18
- 'white-space:nowrap', 'max-width:280px',
19
- dark ? 'background:#F2F3FA;color:#141A3F' : 'background:#141A3F;color:#FCFBF9',
20
- 'box-shadow:0 6px 24px rgba(11,15,43,.18)',
23
+ 'width:max-content', 'max-width:280px', 'overflow:hidden', 'overflow-wrap:anywhere', 'font-variant-numeric:tabular-nums',
24
+ `background:${tipBg};color:${tipInk}`,
25
+ dark ? 'border:1px solid rgba(242,243,250,.14)' : 'border:1px solid rgba(20,26,63,.10)',
26
+ 'box-shadow:0 8px 28px rgba(11,15,43,' + (dark ? '.45' : '.16') + ')',
21
27
  ].join(';');
22
28
  root.appendChild(tipEl);
23
29
  const NS = 'http://www.w3.org/2000/svg';
@@ -27,25 +33,77 @@ export function attach(root) {
27
33
  let on = null;
28
34
  let pinned = null;
29
35
 
30
- const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
31
- const show = (html, ev) => {
36
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
37
+ // Colores que llegan desde atributos de datos: solo hex o rgb() (nada que rompa el HTML).
38
+ const col = (c) => (/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\))$/i.test(String(c || '').trim()) ? String(c).trim() : '#888888');
39
+ // Posición: junto a la marca (no al cursor), nunca sobre el título ni la
40
+ // bajada, y dentro del gráfico. Prueba arriba, derecha, izquierda y abajo
41
+ // (en barras horizontales, derecha primero) y conserva el lado anterior
42
+ // mientras siga sirviendo, para que no salte.
43
+ let lastSide = null;
44
+ const headerBottom = (r) => {
45
+ const t = +svg.getAttribute('data-pa-top');
46
+ const m = svg.getScreenCTM && svg.getScreenCTM();
47
+ return t && m ? t * m.d + m.f - r.top : 0;
48
+ };
49
+ const show = (html, ev, anchor, order) => {
32
50
  tipEl.innerHTML = html;
33
51
  tipEl.style.opacity = '1';
34
52
  const r = root.getBoundingClientRect();
35
- let x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 14;
36
- const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
37
- if (x + w > r.width) x = ev.clientX - r.left - w - 14;
38
- if (y + h > r.height) y = Math.max(0, ev.clientY - r.top - h - 14);
53
+ const w = tipEl.offsetWidth, h = tipEl.offsetHeight, G = 10, M = 4;
54
+ const cx = ev.clientX - r.left, cy = ev.clientY - r.top;
55
+ let a = null;
56
+ if (anchor && anchor.l !== undefined) a = anchor;
57
+ else if (anchor && anchor.getBoundingClientRect) {
58
+ const b = anchor.getBoundingClientRect(), sb = svg.getBoundingClientRect();
59
+ // Marcas grandes (celdas de treemap, regiones, sectores): mejor el puntero.
60
+ if (b.width > 0 && b.width * b.height < 0.2 * sb.width * sb.height) a = { l: b.left - r.left, r: b.right - r.left, t: b.top - r.top, b: b.bottom - r.top };
61
+ }
62
+ if (!a) a = { l: cx - 8, r: cx + 8, t: cy - 8, b: cy + 8 };
63
+ const minY = Math.min(Math.max(M, headerBottom(r) + M), Math.max(M, r.height - h - M));
64
+ const maxY = Math.max(minY, r.height - h - M), maxX = Math.max(M, r.width - w - M);
65
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
66
+ const hx = clamp(cx, a.l, a.r) - w / 2, vy = clamp(cy, a.t, a.b) - h / 2;
67
+ const C = {
68
+ top: [clamp(hx, M, maxX), a.t - G - h, 'y'],
69
+ bottom: [clamp(hx, M, maxX), a.b + G, 'y'],
70
+ right: [a.r + G, clamp(vy, minY, maxY), 'x'],
71
+ left: [a.l - G - w, clamp(vy, minY, maxY), 'x'],
72
+ };
73
+ const ok = ([x, y]) => x >= M - 0.5 && x <= maxX + 0.5 && y >= minY - 0.5 && y <= maxY + 0.5;
74
+ const sides = order || (a.r - a.l > a.b - a.t && a.r - a.l > 40 ? ['right', 'top', 'left', 'bottom'] : ['top', 'right', 'left', 'bottom']);
75
+ const pick = [lastSide, ...sides].find((sd) => sd && C[sd] && ok(C[sd]));
76
+ let x, y;
77
+ if (pick) { [x, y] = C[pick]; lastSide = pick; }
78
+ else { x = clamp(cx + 14, M, maxX); y = clamp(cy + 14, minY, maxY); lastSide = null; }
39
79
  tipEl.style.left = x + 'px';
40
80
  tipEl.style.top = y + 'px';
41
81
  };
42
82
  const type = svg.getAttribute('data-pa-type') || '';
43
- const isMap = type === 'choropleth' || type === 'symbolmap';
83
+ const isMap = type === 'choropleth' || type === 'symbolmap' || type === 'pointmap';
44
84
  const zoomG = svg.querySelector('.pa-zoom');
45
85
  const mapOverlay = document.createElementNS(NS, 'g');
46
86
  mapOverlay.setAttribute('pointer-events', 'none');
47
87
  if (zoomG) zoomG.appendChild(mapOverlay);
88
+ // Red: al pasar sobre un nodo se encienden sus vecinos y sus aristas (como
89
+ // emphasis.focus: 'adjacency' de ECharts); el resto se apaga.
90
+ let extraOn = [];
91
+ const adjacency = (node) => {
92
+ extraOn.forEach((n) => n.classList.remove('pa-on'));
93
+ extraOn = [];
94
+ const id = node && node.getAttribute && node.getAttribute('data-node');
95
+ const net = id !== null && id !== undefined && node.closest && node.closest('.pa-net');
96
+ if (!net) return;
97
+ const near = new Set([id]);
98
+ for (const e of net.querySelectorAll('.pa-links [data-s]')) {
99
+ const a = e.getAttribute('data-s'), b = e.getAttribute('data-t');
100
+ if (a === id || b === id) { near.add(a); near.add(b); e.classList.add('pa-on'); extraOn.push(e); }
101
+ }
102
+ for (const c of net.querySelectorAll('.pa-nodes [data-node]')) if (near.has(c.getAttribute('data-node')) && c !== node) { c.classList.add('pa-on'); extraOn.push(c); }
103
+ };
48
104
  const hide = () => {
105
+ extraOn.forEach((n) => n.classList.remove('pa-on'));
106
+ extraOn = [];
49
107
  tipEl.style.opacity = '0';
50
108
  if (on) on.classList.remove('pa-on');
51
109
  on = null;
@@ -53,19 +111,27 @@ export function attach(root) {
53
111
  mapOverlay.innerHTML = '';
54
112
  if (!pinned) svg.classList.remove('pa-focus');
55
113
  };
56
- // El título del tooltip hereda el color de la categoría, aclarado u
57
- // oscurecido hasta leerse bien sobre el fondo del tooltip; el resto, neutro.
58
- const tipBg = dark ? '#F2F3FA' : '#141A3F';
59
- const rgb = (h) => { h = String(h).replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); const n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
114
+ // Cabecera del tooltip con el color de la marca de fondo y texto blanco u
115
+ // oscuro según el contraste (≥ 4,5:1; si ninguno llega, se ajusta el fondo).
116
+ const rgb = (h) => { h = String(h).replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); const n = parseInt(h.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
60
117
  const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
61
118
  const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
62
119
  const hex = (a) => '#' + a.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
63
- const onTip = (c) => {
64
- if (!/^#[0-9a-f]{3,6}$/i.test(c || '')) return null;
65
- const to = dark ? [20, 26, 63] : [255, 255, 255];
66
- const A = rgb(c);
67
- for (let t = 0; t <= 1.001; t += 0.05) { const m = hex(A.map((v, i) => v + (to[i] - v) * t)); if (ratio(m, tipBg) >= 4.5) return m; }
68
- return null;
120
+ const toHex = (c) => {
121
+ c = String(c || '').trim();
122
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c)) return c;
123
+ const m = /^rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/i.exec(c);
124
+ return m ? hex([+m[1], +m[2], +m[3]]) : null;
125
+ };
126
+ const headerInk = (c) => {
127
+ let bg = toHex(c);
128
+ if (!bg) return null;
129
+ const DARK = '#141A3F', LIGHT = '#FFFFFF';
130
+ const fg = ratio(LIGHT, bg) >= ratio(DARK, bg) ? LIGHT : DARK;
131
+ const to = fg === LIGHT ? [0, 0, 0] : [255, 255, 255];
132
+ const A = rgb(bg);
133
+ for (let t = 0.05; ratio(fg, bg) < 4.5 && t <= 1.001; t += 0.05) bg = hex(A.map((v, i) => v + (to[i] - v) * t));
134
+ return { bg, fg };
69
135
  };
70
136
  const markColor = (node) => {
71
137
  if (!node) return null;
@@ -83,10 +149,17 @@ export function attach(root) {
83
149
  return null;
84
150
  };
85
151
  const linesHtml = (txt, color) => {
86
- const c = onTip(color);
87
- return String(txt).split('\n').map((l, i) => (i === 0
88
- ? `<div style="font-weight:600${c ? ';color:' + c : ''}">${c ? `<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};margin-right:6px;vertical-align:1px"></span>` : ''}${esc(l)}</div>`
89
- : `<div style="opacity:.86">${esc(l)}</div>`)).join('');
152
+ const hd = headerInk(color);
153
+ return String(txt).split('\n').map((l, i) => {
154
+ if (i === 0) return hd
155
+ ? `<div style="margin:-8px -10px 6px;padding:6px 10px;background:${hd.bg};color:${hd.fg};font-weight:600">${esc(l)}</div>`
156
+ : `<div style="font-weight:600;margin-bottom:2px">${esc(l)}</div>`;
157
+ // "Etiqueta: valor" → etiqueta a la izquierda y valor en negrita a la derecha.
158
+ const m = /^(.{1,40}?):\s+(.+)$/.exec(l);
159
+ return m
160
+ ? `<div style="display:flex;gap:12px;justify-content:space-between"><span style="opacity:.78">${esc(m[1])}</span><b style="font-weight:600">${esc(m[2])}</b></div>`
161
+ : `<div style="opacity:.86">${esc(l)}</div>`;
162
+ }).join('');
90
163
  };
91
164
 
92
165
  const toSvg = (ev) => {
@@ -105,10 +178,12 @@ export function attach(root) {
105
178
  for (const d of data) if (Math.abs(d.x - p.x) < Math.abs(best.x - p.x)) best = d;
106
179
  const y0 = +t.getAttribute('data-y0'), y1 = +t.getAttribute('data-y1');
107
180
  overlay.innerHTML =
108
- `<line x1="${best.x}" x2="${best.x}" y1="${y0}" y2="${y1}" stroke="${dark ? '#8A90B4' : '#7C819C'}" stroke-width="1"/>` +
109
- best.v.map((v) => `<circle cx="${best.x}" cy="${v[2]}" r="4.5" fill="${v[1]}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`).join('');
110
- const rows = [...best.v].sort((a, b) => a[2] - b[2]).map((v) => `<div style="display:flex;gap:8px;align-items:center"><span style="width:8px;height:8px;border-radius:50%;background:${v[1]}"></span><span style="opacity:.86;flex:1">${esc(v[0])}</span><b style="font-weight:600;margin-left:12px">${esc(v[3])}</b></div>`).join('');
111
- show(`<div style="font-weight:600;margin-bottom:2px">${esc(best.t)}</div>${rows}`, ev);
181
+ `<line x1="${+best.x}" x2="${+best.x}" y1="${y0}" y2="${y1}" stroke="${dark ? '#8A90B4' : '#7C819C'}" stroke-width="1"/>` +
182
+ best.v.map((v) => `<circle cx="${+best.x}" cy="${+v[2]}" r="4.5" fill="${col(v[1])}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`).join('');
183
+ const rows = [...best.v].sort((a, b) => a[2] - b[2]).map((v) => `<div style="display:flex;gap:8px;align-items:center"><span style="width:8px;height:8px;border-radius:50%;background:${col(v[1])}"></span><span style="opacity:.86;flex:1">${esc(v[0])}</span><b style="font-weight:600;margin-left:12px">${esc(v[3])}</b></div>`).join('');
184
+ const m = svg.getScreenCTM(), rr = root.getBoundingClientRect();
185
+ const lx = m ? best.x * m.a + m.e - rr.left : ev.clientX - rr.left, ly = ev.clientY - rr.top;
186
+ show(`<div style="font-weight:600;margin-bottom:4px">${esc(best.t)}</div>${rows}`, ev, { l: lx - 6, r: lx + 6, t: ly - 4, b: ly + 4 }, ['right', 'left', 'top', 'bottom']);
112
187
  return;
113
188
  }
114
189
  if (t && t.classList && t.classList.contains('pa-near')) {
@@ -118,8 +193,8 @@ export function attach(root) {
118
193
  let best = null, bd = 196;
119
194
  for (const q of pts) { const d = (q[0] - p.x) ** 2 + (q[1] - p.y) ** 2; if (d < bd) { bd = d; best = q; } }
120
195
  if (!best) { hide(); return; }
121
- overlay.innerHTML = `<circle cx="${best[0]}" cy="${best[1]}" r="6" fill="${best[3]}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`;
122
- if (best[2]) show(linesHtml(best[2], best[3]), ev); else tipEl.style.opacity = '0';
196
+ overlay.innerHTML = `<circle cx="${+best[0]}" cy="${+best[1]}" r="6" fill="${col(best[3])}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`;
197
+ if (best[2]) { const m = svg.getScreenCTM(), rr = root.getBoundingClientRect(); const px = best[0] * m.a + m.e - rr.left, py = best[1] * m.d + m.f - rr.top; show(linesHtml(best[2], best[3]), ev, { l: px - 6, r: px + 6, t: py - 6, b: py + 6 }); } else tipEl.style.opacity = '0';
123
198
  return;
124
199
  }
125
200
  const tg = t && t.closest ? t.closest('[data-tip]') : null;
@@ -131,13 +206,55 @@ export function attach(root) {
131
206
  if (isMap) {
132
207
  // Mapas: sin atenuar el resto; un contorno marcado sobre la región (o su círculo).
133
208
  mapOverlay.innerHTML = '';
134
- if (on && on.tagName === 'path') mapOverlay.innerHTML = `<path d="${on.getAttribute('d')}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`;
209
+ if (on && on.tagName === 'path') mapOverlay.innerHTML = `<path d="${esc(on.getAttribute('d'))}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`;
135
210
  else if (on && on.tagName === 'circle') mapOverlay.innerHTML = `<circle cx="${on.getAttribute('cx')}" cy="${on.getAttribute('cy')}" r="${on.getAttribute('r')}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" vector-effect="non-scaling-stroke"/>`;
136
- } else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); } else svg.classList.remove('pa-focus');
211
+ } else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); adjacency(on); } else svg.classList.remove('pa-focus');
137
212
  }
138
- show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev);
213
+ show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev, mark || tg);
139
214
  });
140
- svg.addEventListener('pointerleave', hide);
215
+ // Táctil: un toque muestra el tooltip y lo deja abierto hasta otro toque fuera.
216
+ let touchTip = false;
217
+ svg.addEventListener('pointerdown', (ev) => {
218
+ if (ev.pointerType !== 'touch') return;
219
+ const tg = ev.target && ev.target.closest ? ev.target.closest('[data-tip]') : null;
220
+ if (!tg) { touchTip = false; hide(); return; }
221
+ touchTip = true;
222
+ const mark = tg.classList.contains('pa-m') ? tg : null;
223
+ show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev, mark || tg);
224
+ });
225
+ svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'touch' && touchTip) return; hide(); });
226
+
227
+ // Teclado: Tab entra al gráfico; flechas recorren las marcas con datos,
228
+ // Home/End van al extremo y Esc cierra. El tooltip se ancla a la marca.
229
+ const lang = svg.getAttribute('data-pa-lang') === 'en';
230
+ svg.setAttribute('role', 'graphics-document');
231
+ svg.setAttribute('aria-roledescription', lang ? 'chart' : 'gráfico');
232
+ if (!svg.hasAttribute('tabindex')) svg.setAttribute('tabindex', '0');
233
+ const live = document.createElement('div');
234
+ live.setAttribute('aria-live', 'polite');
235
+ live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap';
236
+ root.appendChild(live);
237
+ let kIdx = -1;
238
+ const kMarks = () => [...svg.querySelectorAll('.pa-body [data-tip]')].filter((n) => !n.parentElement.closest('[data-tip]') && n.getAttribute('data-tip'));
239
+ svg.addEventListener('keydown', (ev) => {
240
+ if (ev.target !== svg) return;
241
+ if (ev.key === 'Escape') { kIdx = -1; hide(); return; }
242
+ const d = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : ev.key === 'Home' ? -1e9 : ev.key === 'End' ? 1e9 : 0;
243
+ if (!d) return;
244
+ const ms = kMarks();
245
+ if (!ms.length) return;
246
+ ev.preventDefault();
247
+ kIdx = Math.max(0, Math.min(ms.length - 1, kIdx < 0 ? (d > 0 ? 0 : ms.length - 1) : kIdx + d));
248
+ const tg = ms[kIdx];
249
+ const b = tg.getBoundingClientRect();
250
+ const mark = tg.classList.contains('pa-m') ? tg : tg.querySelector('.pa-m');
251
+ if (on) on.classList.remove('pa-on');
252
+ on = mark;
253
+ if (!isMap && on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); }
254
+ show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), { clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }, tg);
255
+ live.textContent = tg.getAttribute('data-tip').replace(/\n/g, '. ');
256
+ });
257
+ svg.addEventListener('blur', () => { kIdx = -1; hide(); });
141
258
 
142
259
  // Leyenda conectada: pasar sobre una clave destaca sus marcas (mismo color);
143
260
  // un clic la deja fija, otro clic la suelta.
@@ -173,9 +290,13 @@ export function attach(root) {
173
290
  k.setAttribute('role', 'button');
174
291
  k.addEventListener('pointerenter', () => { if (!pinned) focusColor(k.getAttribute('data-color')); });
175
292
  k.addEventListener('pointerleave', () => { if (!pinned) focusColor(null); });
176
- const toggle = () => { pinned = pinned === k ? null : k; focusColor(pinned ? k.getAttribute('data-color') : null); };
293
+ const toggle = (e) => {
294
+ // render() puede tomar el clic (zoom en treemap, shift + clic = excluir).
295
+ if (root.__paLegend && root.__paLegend(k, e || {})) return;
296
+ pinned = pinned === k ? null : k; focusColor(pinned ? k.getAttribute('data-color') : null);
297
+ };
177
298
  k.addEventListener('click', toggle);
178
- k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
299
+ k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } });
179
300
  }
180
301
 
181
302
  if (zoomG) mapZoom(root, svg, zoomG, { dark, hide });