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
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 });