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/runtime.js CHANGED
@@ -6,6 +6,8 @@ import { themeFor, FONT_CSS } from './theme.js';
6
6
  import { ensureData, fieldKind, uniq, num, PlotError } from './data.js';
7
7
  import { parseDate, formatDate } from './format.js';
8
8
  import { dateUnit } from './scale.js';
9
+ import { isGeoUrl } from './geo/registry.js';
10
+ import { words } from './i18n.js';
9
11
 
10
12
  let plotFn = null;
11
13
  let resolveFn = null;
@@ -26,24 +28,41 @@ function ensureAssets() {
26
28
  const st = document.createElement('style');
27
29
  st.id = 'pa-runtime-css';
28
30
  st.textContent = `
29
- .pa-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:4px 0 2px;font-size:12px;color:var(--pa-muted);user-select:none}
30
- .pa-play{flex:none;width:30px;height:30px;border-radius:50%;border:0;background:var(--pa-accent);color:var(--pa-surface);cursor:pointer;display:grid;place-items:center;padding:0}
31
- .pa-play:focus-visible{outline:2px solid var(--pa-accent);outline-offset:2px}
31
+ .pa-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;padding:8px 0 4px;font-size:12.5px;color:var(--pa-muted);user-select:none;font-variant-numeric:tabular-nums}
32
+ .pa-play{flex:none;width:28px;height:28px;border-radius:50%;border:1px solid var(--pa-line);background:transparent;color:var(--pa-ink);cursor:pointer;display:grid;place-items:center;padding:0 0 0 1px;transition:border-color .15s}
33
+ .pa-play[aria-pressed=true]{padding:0}
34
+ .pa-play:hover{border-color:var(--pa-ink)}
35
+ .pa-play:focus-visible,.pa-seg button:focus-visible{outline:2px solid var(--pa-accent);outline-offset:2px}
32
36
  .pa-play svg{display:block}
33
- .pa-track{position:relative;flex:1 1 220px;min-width:0;height:40px}
34
- .pa-track svg.pa-ov{position:absolute;left:0;top:0;width:100%;height:28px;overflow:visible}
35
- .pa-track input[type=range]{position:absolute;left:0;width:100%;top:6px;margin:0;height:22px;background:transparent;-webkit-appearance:none;appearance:none;pointer-events:none}
36
- .pa-track input[type=range]::-webkit-slider-runnable-track{background:transparent;height:22px}
37
- .pa-track input[type=range]::-moz-range-track{background:transparent;height:22px}
38
- .pa-track input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;pointer-events:auto;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--pa-accent);border:2px solid var(--pa-surface);box-shadow:0 1px 4px rgba(11,15,43,.3);cursor:grab}
39
- .pa-track input[type=range]::-moz-range-thumb{pointer-events:auto;width:12px;height:12px;border-radius:50%;background:var(--pa-accent);border:2px solid var(--pa-surface);box-shadow:0 1px 4px rgba(11,15,43,.3);cursor:grab}
40
- .pa-track input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--pa-accent);outline-offset:2px}
41
- .pa-ticks{position:absolute;left:0;right:0;top:28px;height:12px;font-size:10.5px;color:var(--pa-muted)}
42
- .pa-ticks span{position:absolute;transform:translateX(-50%);white-space:nowrap}
43
- .pa-now{flex:none;font-weight:600;color:var(--pa-ink);font-size:13px;min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
44
- .pa-chart [data-drill],.pa-chart [data-node]{cursor:pointer}
37
+ .pa-seg{display:flex;flex-wrap:wrap;gap:2px 4px}
38
+ .pa-seg button{font:inherit;font-size:12.5px;color:var(--pa-muted);background:none;border:0;border-radius:0;padding:5px 6px;cursor:pointer;box-shadow:inset 0 -1px 0 transparent;transition:color .15s}
39
+ .pa-seg button:hover{color:var(--pa-ink)}
40
+ .pa-seg button[aria-checked=true]{color:var(--pa-ink);font-weight:600;box-shadow:inset 0 -2px 0 var(--pa-accent)}
41
+ .pa-track{position:relative;flex:1 1 200px;min-width:0;height:34px}
42
+ .pa-track input[type=range]{position:absolute;left:0;width:100%;top:4px;margin:0;height:16px;-webkit-appearance:none;appearance:none;background:linear-gradient(to right,var(--pa-ink) var(--pa-p,100%),var(--pa-line) var(--pa-p,100%)) center/100% 2px no-repeat;cursor:pointer}
43
+ .pa-dual input[type=range]{background:none;pointer-events:none}
44
+ .pa-dual::before,.pa-dual::after{content:'';position:absolute;top:11px;height:2px;border-radius:1px}
45
+ .pa-dual::before{left:0;right:0;background:var(--pa-line)}
46
+ .pa-dual::after{left:var(--pa-a,0%);right:calc(100% - var(--pa-b,100%));background:var(--pa-ink)}
47
+ .pa-track input[type=range]::-webkit-slider-runnable-track{background:transparent;height:16px}
48
+ .pa-track input[type=range]::-moz-range-track{background:transparent;height:16px}
49
+ .pa-track input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;pointer-events:auto;width:12px;height:12px;margin-top:2px;border-radius:50%;background:var(--pa-ink);border:2px solid var(--pa-surface);box-shadow:0 0 0 1px var(--pa-ink);cursor:grab;position:relative;z-index:1}
50
+ .pa-track input[type=range]::-moz-range-thumb{pointer-events:auto;width:10px;height:10px;border-radius:50%;background:var(--pa-ink);border:2px solid var(--pa-surface);box-shadow:0 0 0 1px var(--pa-ink);cursor:grab}
51
+ .pa-track input[type=range]:focus-visible{outline:none}
52
+ .pa-track input[type=range]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 1px var(--pa-ink),0 0 0 4px color-mix(in srgb,var(--pa-accent) 35%,transparent)}
53
+ .pa-ends{position:absolute;left:0;right:0;top:22px;display:flex;justify-content:space-between;font-size:10.5px;color:var(--pa-muted);pointer-events:none}
54
+ .pa-now{flex:none;font-weight:600;color:var(--pa-ink);font-size:12.5px;min-width:4ch;text-align:right}
55
+ .pa-chart [data-drill],.pa-chart [data-node],.pa-chart [data-group]{cursor:pointer}
56
+ .pa-chart [data-group]:focus-visible,.pa-chart .pa-key:focus-visible{outline:2px solid var(--pa-accent,#3A47B8);outline-offset:1px}
57
+ .pa-live .pa-chev{opacity:1!important}
58
+ .pa-chart .pa-gh:hover .pa-hit{fill:var(--pa-tint,#ECEEF8);fill-opacity:.5}
45
59
  .pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
46
- @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}}`;
60
+ .pa-search{position:absolute;z-index:2;box-sizing:border-box;margin:0;font:inherit;font-size:13px;color:var(--pa-ink);background-color:var(--pa-surface);background-repeat:no-repeat;background-position:10px center;border:1px solid var(--pa-line);border-radius:8px;padding:0 10px 0 32px;outline:none;transition:border-color .15s,box-shadow .15s;-webkit-appearance:none;appearance:none}
61
+ .pa-search::placeholder{color:var(--pa-muted);opacity:1}
62
+ .pa-search:hover{border-color:var(--pa-muted)}
63
+ .pa-search:focus-visible,.pa-search:focus{border-color:var(--pa-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--pa-accent) 22%,transparent)}
64
+ .pa-chart .pa-th[data-sort]:focus-visible,.pa-chart .pa-pg:focus-visible{outline:2px solid var(--pa-accent);outline-offset:-2px}
65
+ @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}.pa-search{transition:none}}`;
47
66
  document.head.appendChild(st);
48
67
  }
49
68
 
@@ -51,9 +70,44 @@ function ensureAssets() {
51
70
  * Dibuja `spec` en un elemento (o selector) del navegador.
52
71
  * Devuelve { svg, spec, update(spec), destroy() }.
53
72
  */
73
+ const geoCache = new Map();
74
+ /** Descarga (una vez por URL) un GeoJSON o TopoJSON. */
75
+ export function loadGeo(url) {
76
+ if (!geoCache.has(url)) geoCache.set(url, fetch(url).then((r) => { if (!r.ok) throw new PlotError(`No pude descargar el mapa (${r.status}): ${url}`); return r.json(); }));
77
+ return geoCache.get(url);
78
+ }
79
+
80
+ /**
81
+ * `geo` como URL ("/mapas/comunas.geojson", "https://…/x.topojson"): se
82
+ * descarga y luego se dibuja. Devuelve el mismo objeto que render(), con
83
+ * `ready` (una promesa) mientras llega el archivo.
84
+ */
85
+ function renderRemote(host, spec, opts) {
86
+ let inner = null, pending = null, dead = false;
87
+ const handle = {
88
+ get svg() { return inner?.svg ?? null; },
89
+ get spec() { return inner?.spec ?? spec; },
90
+ update(s) { if (inner) return inner.update(s); pending = s; },
91
+ destroy() { dead = true; inner?.destroy(); },
92
+ };
93
+ host.setAttribute('aria-busy', 'true');
94
+ handle.ready = loadGeo(spec.geo).then((geo) => {
95
+ host.removeAttribute('aria-busy');
96
+ if (dead) return handle;
97
+ inner = render(host, { ...(pending || spec), geo }, opts);
98
+ return handle;
99
+ }, (e) => {
100
+ host.removeAttribute('aria-busy');
101
+ host.innerHTML = `<p style="font:13px system-ui;color:#C9531A">${String(e.message).replace(/</g, '&lt;')}</p>`;
102
+ throw e;
103
+ });
104
+ return handle;
105
+ }
106
+
54
107
  export function render(target, spec, opts = {}) {
55
108
  const host = typeof target === 'string' ? document.querySelector(target) : target;
56
109
  if (!host) throw new PlotError('No encontré el elemento destino.', 'render("#grafico", spec) o render(elemento, spec)');
110
+ if (spec && typeof spec.geo === 'string' && typeof fetch !== 'undefined' && isGeoUrl(spec.geo)) return renderRemote(host, spec, opts);
57
111
  ensureAssets();
58
112
  let state = resolveFn ? resolveFn(spec) : { ...spec };
59
113
  // Fuente propia (configure({ font }) o spec.font): se carga una sola vez.
@@ -69,17 +123,50 @@ export function render(target, spec, opts = {}) {
69
123
  let timer = null, ro = null, raf = 0;
70
124
 
71
125
  const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
126
+ // Tabla: caja de búsqueda (sobre 20 filas, o `search: true`), pegada a la franja que la tabla reserva.
127
+ let search = null, searchTimer = 0;
128
+ if (state.type === 'table' && state.search !== false) {
129
+ let n = 0;
130
+ try { n = ensureData(state).length; } catch { n = 0; }
131
+ if (state.search === true || n > 20) {
132
+ const th = themeFor(state);
133
+ const W = words(state.locale);
134
+ search = document.createElement('input');
135
+ search.type = 'search'; search.className = 'pa-search';
136
+ search.placeholder = W.search + '…'; search.setAttribute('aria-label', W.search);
137
+ search.autocomplete = 'off'; search.spellcheck = false;
138
+ if (state.query) search.value = state.query;
139
+ const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.25" fill="none" stroke="${th.muted}" stroke-width="1.5"/><path d="M9.2 9.2l3.3 3.3" stroke="${th.muted}" stroke-width="1.5" stroke-linecap="round"/></svg>`;
140
+ search.style.cssText = `--pa-accent:${th.categorical[0]};--pa-surface:${th.surface};--pa-ink:${th.ink};--pa-muted:${th.muted};--pa-line:${th.axis};font-family:${(th.font?.family || 'system-ui').replace(/"/g, "'")};background-image:url("data:image/svg+xml,${encodeURIComponent(icon)}")`;
141
+ search.addEventListener('input', () => {
142
+ clearTimeout(searchTimer);
143
+ searchTimer = setTimeout(() => { state = { ...state, query: search.value.trim() || undefined, page: 1 }; draw(); }, 120);
144
+ });
145
+ search.addEventListener('keydown', (e) => { if (e.key === 'Escape' && search.value) { e.stopPropagation(); search.value = ''; state = { ...state, query: undefined, page: 1 }; draw(); } });
146
+ if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
147
+ host.appendChild(search);
148
+ }
149
+ }
150
+ const placeSearch = () => {
151
+ if (!search) return;
152
+ const slot = chart.querySelector('.pa-search-slot');
153
+ if (!slot) { search.hidden = true; return; }
154
+ search.hidden = false;
155
+ const r = slot.getBoundingClientRect(), h = host.getBoundingClientRect();
156
+ search.style.left = r.left - h.left + 'px'; search.style.top = r.top - h.top + 'px';
157
+ search.style.width = r.width + 'px'; search.style.height = r.height + 'px';
158
+ };
72
159
  // Posiciones anteriores de los nodos (para animar el cambio de foco).
73
160
  const nodePos = () => {
74
161
  const m = new Map();
75
- for (const c of chart.querySelectorAll('[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
162
+ for (const c of chart.querySelectorAll('circle[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
76
163
  return m;
77
164
  };
78
165
  const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
79
166
  const animateFrom = (before) => {
80
167
  if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
81
168
  const ease = 'cubic-bezier(.2,.7,.2,1)';
82
- for (const c of chart.querySelectorAll('[data-node]')) {
169
+ for (const c of chart.querySelectorAll('circle[data-node]')) {
83
170
  const old = before.get(c.getAttribute('data-node'));
84
171
  if (!old) continue;
85
172
  const dx = old[0] - +c.getAttribute('cx'), dy = old[1] - +c.getAttribute('cy');
@@ -104,18 +191,53 @@ export function render(target, spec, opts = {}) {
104
191
  if (old && /^#/.test(now || '') && old.toLowerCase() !== now.toLowerCase()) n.animate([{ fill: old }, { fill: now }], { duration: 420, easing: 'ease-out' });
105
192
  }
106
193
  };
194
+ // Leyenda: en treemap y sunburst, clic = ver solo esa categoría en todo el
195
+ // plano (otro clic vuelve); en todos los gráficos, shift + clic la excluye
196
+ // (o la vuelve a sumar). Devuelve true si tomó el clic.
197
+ const onLegend = (key, e) => {
198
+ const v = key.getAttribute('data-value');
199
+ if (v === null || key.getAttribute('data-color') === 'dashed') return false;
200
+ const ex = [].concat(state.exclude ?? []).map(String);
201
+ if (e.shiftKey || key.classList.contains('pa-key-off')) {
202
+ const next = ex.includes(v) ? ex.filter((x) => x !== v) : [...ex, v];
203
+ // Colores fijos: los de la leyenda actual, para que nada cambie de color.
204
+ const colors = { ...(state.colors || {}) };
205
+ for (const k of chart.querySelectorAll('.pa-key[data-value]')) {
206
+ const c = k.getAttribute('data-color');
207
+ if (/^#/.test(c || '') && !k.classList.contains('pa-key-off') && !(k.getAttribute('data-value') in colors)) colors[k.getAttribute('data-value')] = c;
208
+ }
209
+ const f = Array.isArray(state.focus) && state.focus[0] === v ? undefined : state.focus;
210
+ state = { ...state, exclude: next.length ? next : undefined, colors, focus: f };
211
+ draw();
212
+ return true;
213
+ }
214
+ if (state.type === 'treemap' || state.type === 'sunburst') {
215
+ const cur = Array.isArray(state.focus) ? state.focus : [];
216
+ state = { ...state, focus: cur.length === 1 && cur[0] === v ? [] : [v] };
217
+ draw();
218
+ return true;
219
+ }
220
+ return false;
221
+ };
107
222
  const draw = () => {
108
223
  const before = nodePos();
109
224
  const fills = regionFills();
110
- chart.innerHTML = plotFn({ ...state, width: width() });
225
+ chart.innerHTML = plotFn({ ...state, width: width(), _search: !!search });
111
226
  animateFrom(before);
112
227
  tweenFills(fills);
113
228
  chart.__pa = false;
229
+ chart.__paLegend = onLegend;
114
230
  attach(chart);
231
+ const svgNode = chart.querySelector('svg');
232
+ if (svgNode) svgNode.classList.add('pa-live');
115
233
  // Red: clic en un nodo lo lleva al centro y destaca sus conexiones; otro clic (o Esc) vuelve.
116
234
  for (const n of chart.querySelectorAll('[data-node]')) {
117
- n.setAttribute('tabindex', '0');
118
- n.setAttribute('role', 'button');
235
+ // Solo los círculos son paradas de tabulación; las etiquetas también responden al clic.
236
+ if (n.tagName.toLowerCase() === 'circle') {
237
+ n.setAttribute('tabindex', '0');
238
+ n.setAttribute('role', 'button');
239
+ n.setAttribute('aria-label', (n.getAttribute('data-tip') || n.getAttribute('data-node')).split('\n')[0]);
240
+ }
119
241
  const id = n.getAttribute('data-node');
120
242
  const go = () => { state = { ...state, focus: String(state.focus) === id ? undefined : id }; draw(); };
121
243
  n.addEventListener('click', go);
@@ -128,57 +250,149 @@ export function render(target, spec, opts = {}) {
128
250
  n.addEventListener('click', go);
129
251
  n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
130
252
  }
253
+ // Gantt: clic en una fase la pliega en una barra-resumen; otro clic la despliega.
254
+ // Alt + clic pliega o despliega todas.
255
+ const groupsAll = [...chart.querySelectorAll('[data-group]')].map((n) => n.getAttribute('data-group'));
256
+ for (const n of chart.querySelectorAll('[data-group]')) {
257
+ if (!n.querySelector('.pa-chev')) continue; // collapsible: false
258
+ const name = n.getAttribute('data-group');
259
+ n.setAttribute('tabindex', '0');
260
+ n.setAttribute('role', 'button');
261
+ n.setAttribute('aria-expanded', n.getAttribute('data-collapsed') === 'true' ? 'false' : 'true');
262
+ const go = (e) => {
263
+ let cur = state.collapsed === true ? [...groupsAll] : [].concat(state.collapsed || []).map(String);
264
+ if (e && e.altKey) cur = cur.length === groupsAll.length ? [] : [...groupsAll];
265
+ else cur = cur.includes(name) ? cur.filter((g) => g !== name) : [...cur, name];
266
+ state = { ...state, collapsed: cur };
267
+ draw();
268
+ const again = [...chart.querySelectorAll('[data-group]')].find((m) => m.getAttribute('data-group') === name);
269
+ if (again && e && e.type === 'keydown') again.focus();
270
+ };
271
+ n.addEventListener('click', go);
272
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
273
+ }
274
+ // Tabla: paso de página y orden por columna (clic o Enter/Espacio; ←/→ en el paginador).
275
+ placeSearch();
276
+ const refocus = (sel) => { const m = chart.querySelector(sel); if (m) m.focus(); return !!m; };
277
+ // Las flechas ‹ › llevan data-arrow; tras el cambio se busca la misma flecha.
278
+ const refocusSame = (arrow, p) => (arrow ? refocus(`[data-arrow="${arrow}"][data-page]`) : refocus(`[data-page="${p}"]:not([data-arrow])`));
279
+ for (const n of chart.querySelectorAll('[data-page]')) {
280
+ const p = +n.getAttribute('data-page');
281
+ const arrow = n.getAttribute('data-arrow');
282
+ n.setAttribute('tabindex', '0');
283
+ n.setAttribute('role', 'button');
284
+ const go = (e) => {
285
+ state = { ...state, page: p };
286
+ draw();
287
+ // Con teclado el foco sigue en el mismo botón (o en la página actual si esa flecha se apagó).
288
+ if (e && e.type === 'keydown' && !refocusSame(arrow, p)) refocus('.pa-pg-on');
289
+ };
290
+ n.addEventListener('click', go);
291
+ n.addEventListener('keydown', (e) => {
292
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); return; }
293
+ const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
294
+ if (!d) return;
295
+ const pages = [...chart.querySelectorAll('[data-page]')];
296
+ const k = pages.indexOf(n) + d;
297
+ if (pages[k]) { e.preventDefault(); pages[k].focus(); }
298
+ });
299
+ }
300
+ for (const n of chart.querySelectorAll('.pa-th[data-sort]')) {
301
+ const f = n.getAttribute('data-sort');
302
+ n.setAttribute('tabindex', '0');
303
+ n.setAttribute('aria-label', `${words(state.locale).sort} ${n.textContent}`);
304
+ // Primer clic: el orden natural (cifras de mayor a menor, texto A→Z); segundo: al revés; tercero: el orden original.
305
+ const go = (e) => {
306
+ const cur = n.getAttribute('aria-sort');
307
+ const numeric = n.getAttribute('data-num') === '1';
308
+ const first = numeric ? 'desc' : 'asc';
309
+ const firstAria = first === 'desc' ? 'descending' : 'ascending';
310
+ let next;
311
+ if (cur === 'none' || !cur) next = { sortBy: f, sort: first };
312
+ else if (cur === firstAria) next = { sortBy: f, sort: first === 'desc' ? 'asc' : 'desc' };
313
+ else next = { sortBy: undefined, sort: undefined };
314
+ state = { ...state, ...next, sortOrder: undefined, page: 1 };
315
+ draw();
316
+ if (e && e.type === 'keydown') refocus(`.pa-th[data-sort="${CSS.escape ? CSS.escape(f) : f}"]`);
317
+ };
318
+ n.addEventListener('click', go);
319
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
320
+ }
131
321
  };
132
322
  const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
133
323
 
134
324
  const theme = themeFor(state);
135
325
  ctl.className = 'pa-ctl';
136
- ctl.style.cssText = `--pa-accent:${theme.categorical[0]};--pa-surface:${theme.surface};--pa-ink:${theme.ink};--pa-muted:${theme.muted};--pa-tint:${theme.tint};background:${theme.surface};padding-left:${state.padding ?? 24}px;padding-right:${state.padding ?? 24}px;padding-bottom:6px;font-family:${(theme.font?.family || 'system-ui').replace(/"/g, "'")}`;
326
+ ctl.style.cssText = `--pa-accent:${theme.categorical[0]};--pa-surface:${theme.surface};--pa-ink:${theme.ink};--pa-muted:${theme.muted};--pa-tint:${theme.tint};--pa-line:${theme.axis};background:${theme.surface};padding-left:${state.padding ?? 24}px;padding-right:${state.padding ?? 24}px;padding-bottom:6px;font-family:${(theme.font?.family || 'system-ui').replace(/"/g, "'")}`;
137
327
  ctl.hidden = true;
138
328
 
139
- if (opts.controls !== false && state.slider !== false) {
140
- if (state.time && !SNAP_EXCLUDE.has(state.type) && state.x !== state.time) buildPeriod();
329
+ // Controles de tiempo: SOLO si se piden (`slider: true`, "buttons" o "slider").
330
+ // Con pocos periodos van botones; con muchos, un slider mínimo.
331
+ const want = controlMode(state, opts);
332
+ if (want) {
333
+ if (state.time && !SNAP_EXCLUDE.has(state.type) && state.x !== state.time) buildPeriod(want);
141
334
  else if (RANGE_TYPES.has(state.type)) buildRange();
142
335
  }
143
336
 
144
- // ── Periodo único con "play" (d3plus time) ──────────────────────────────
145
- function buildPeriod() {
337
+ // ── Periodo único: botones o slider, con un play sobrio ─────────────────
338
+ function buildPeriod(mode) {
146
339
  const vals = timeValuesOf(state);
147
340
  if (vals.length < 2) return;
148
341
  let i = vals.findIndex((v) => String(v) === String(state.timeValue ?? vals[vals.length - 1]));
149
342
  if (i < 0) i = vals.length - 1;
150
343
  ctl.hidden = false;
151
- const play = button();
152
- const track = document.createElement('div'); track.className = 'pa-track';
153
- const ov = svgEl('svg', { class: 'pa-ov', viewBox: '0 0 100 28', preserveAspectRatio: 'none' });
154
- ov.appendChild(svgEl('rect', { x: 0, y: 13, width: 100, height: 3, rx: 1.5, fill: theme.tint2 || theme.tint }));
155
- const fill = svgEl('rect', { x: 0, y: 13, height: 3, rx: 1.5, fill: theme.categorical[0] });
156
- ov.appendChild(fill);
157
- track.appendChild(ov);
158
- const input = range(0, vals.length - 1, i, 'Periodo');
159
- track.appendChild(input);
160
- const now = document.createElement('span'); now.className = 'pa-now';
161
- if (state.play === false) ctl.append(track, now); else ctl.append(play, track, now);
162
- track.appendChild(ticks(vals.map(String), (k) => k / (vals.length - 1), null, track));
344
+ const labels = vals.map(String);
345
+ const buttons = mode === 'buttons' || (mode === 'auto' && vals.length <= 8 && labels.join('').length <= 64);
346
+ const play = state.play === false ? null : button();
347
+ if (play) ctl.append(play);
348
+ let setUI;
349
+ if (buttons) {
350
+ const group = document.createElement('div');
351
+ group.className = 'pa-seg'; group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-label', String(state.time));
352
+ const btns = labels.map((l, k) => {
353
+ const b = document.createElement('button');
354
+ b.type = 'button'; b.textContent = l; b.setAttribute('role', 'radio');
355
+ b.addEventListener('click', () => { stop(); set(k); });
356
+ b.addEventListener('keydown', (e) => {
357
+ const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
358
+ if (!d) return;
359
+ e.preventDefault(); stop();
360
+ const k2 = Math.max(0, Math.min(vals.length - 1, i + d)); set(k2); btns[k2].focus();
361
+ });
362
+ group.appendChild(b);
363
+ return b;
364
+ });
365
+ ctl.append(group);
366
+ setUI = (k) => btns.forEach((b, j) => { b.setAttribute('aria-checked', j === k ? 'true' : 'false'); b.tabIndex = j === k ? 0 : -1; });
367
+ } else {
368
+ const track = document.createElement('div'); track.className = 'pa-track';
369
+ const input = range(0, vals.length - 1, i, String(state.time));
370
+ input.style.setProperty('--pa-p', '100%');
371
+ const ends = document.createElement('div'); ends.className = 'pa-ends';
372
+ ends.innerHTML = '<span></span><span></span>';
373
+ ends.children[0].textContent = labels[0]; ends.children[1].textContent = labels[labels.length - 1];
374
+ track.append(input, ends);
375
+ const now = document.createElement('span'); now.className = 'pa-now';
376
+ ctl.append(track, now);
377
+ input.addEventListener('input', () => { stop(); set(+input.value); });
378
+ setUI = (k) => { input.value = k; input.style.setProperty('--pa-p', (100 * k) / (vals.length - 1) + '%'); now.textContent = labels[k]; };
379
+ }
163
380
  const set = (k) => {
164
- i = k; input.value = k;
165
- fill.setAttribute('width', (100 * k) / (vals.length - 1));
166
- now.textContent = String(vals[k]);
381
+ i = k; setUI(k);
167
382
  state = { ...state, timeValue: vals[k] };
168
383
  schedule();
169
384
  };
170
- input.addEventListener('input', () => { stop(); set(+input.value); });
171
- play.addEventListener('click', () => {
385
+ if (play) play.addEventListener('click', () => {
172
386
  if (timer) return stop();
173
387
  if (i >= vals.length - 1) set(0);
174
388
  setIcon(play, 'pause');
175
- timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1000);
389
+ timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1200);
176
390
  });
177
- function stop() { clearInterval(timer); timer = null; setIcon(play, 'play'); }
178
- set(i);
391
+ function stop() { clearInterval(timer); timer = null; if (play) setIcon(play, 'play'); }
392
+ setUI(i);
179
393
  }
180
394
 
181
- // ── Rango con vista general (series de tiempo) ──────────────────────────
395
+ // ── Rango (series de tiempo): dos tiradores sobre una línea fina ────────
182
396
  function buildRange() {
183
397
  const data = ensureData(state);
184
398
  const xf = state.x;
@@ -188,75 +402,45 @@ export function render(target, spec, opts = {}) {
188
402
  const raw = uniq(data.map((r) => r[xf]));
189
403
  const val = (v) => (kind === 'date' ? parseDate(v) : num(v));
190
404
  const xs = raw.map((v) => ({ v, t: val(v) })).filter((p) => Number.isFinite(p.t)).sort((a, b) => a.t - b.t);
191
- if (xs.length < (state.sliderMin ?? 6)) return;
405
+ if (xs.length < 3) return;
192
406
  ctl.hidden = false;
193
- // Vista general: suma de todas las series por x.
194
- const ys = Array.isArray(state.y) ? state.y : [state.y];
195
- const tot = new Map(xs.map((p) => [p.t, 0]));
196
- for (const r of data) { const t = val(r[xf]); if (tot.has(t)) tot.set(t, tot.get(t) + ys.reduce((a, f) => a + (Number.isFinite(num(r[f])) ? num(r[f]) : 0), 0)); }
197
- const tv = xs.map((p) => tot.get(p.t));
198
- const mn = Math.min(...tv), mx = Math.max(...tv);
199
- const t0 = xs[0].t, t1 = xs[xs.length - 1].t;
200
- const px = (t) => (100 * (t - t0)) / (t1 - t0 || 1);
201
- const py = (v) => 26 - (22 * (v - mn)) / (mx - mn || 1);
202
- const track = document.createElement('div'); track.className = 'pa-track';
203
- const ov = svgEl('svg', { class: 'pa-ov', viewBox: '0 0 100 28', preserveAspectRatio: 'none' });
204
- const d = xs.map((p, k) => `${k ? 'L' : 'M'}${px(p.t).toFixed(2)},${py(tv[k]).toFixed(2)}`).join('');
205
- ov.appendChild(svgEl('path', { d: d + `L100,28L0,28Z`, fill: theme.muted, 'fill-opacity': 0.12 }));
206
- ov.appendChild(svgEl('path', { d, fill: 'none', stroke: theme.muted, 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }));
207
- const sel = svgEl('rect', { y: 0, height: 28, rx: 3, fill: theme.categorical[0], 'fill-opacity': 0.08 });
208
- ov.appendChild(sel);
209
- track.appendChild(ov);
210
407
  const fmtX = (p) => (kind === 'date' ? formatDate(p.t, dateUnit(xs.map((q) => q.t)), state.locale || 'es') : String(p.v));
211
408
  let lo = 0, hi = xs.length - 1;
212
409
  if (state.xRange) {
213
410
  const a = xs.findIndex((p) => p.t >= val(state.xRange[0])), b = xs.map((p) => p.t <= val(state.xRange[1])).lastIndexOf(true);
214
411
  if (a >= 0) lo = a; if (b >= 0) hi = b;
215
412
  }
413
+ const track = document.createElement('div'); track.className = 'pa-track pa-dual';
216
414
  const a = range(0, xs.length - 1, lo, 'Desde'), b = range(0, xs.length - 1, hi, 'Hasta');
217
- // Con fechas, las marcas del slider son años (o el primer valor de cada año).
218
- const yearOf = (p) => new Date(p.t).getUTCFullYear();
219
- const tickLabels = kind === 'date' ? xs.map((p) => String(yearOf(p))) : xs.map(fmtX);
220
- const keep = kind === 'date' ? (k) => k === 0 || yearOf(xs[k]) !== yearOf(xs[k - 1]) : null;
221
- track.append(a, b);
415
+ const ends = document.createElement('div'); ends.className = 'pa-ends';
416
+ ends.innerHTML = '<span></span><span></span>';
417
+ ends.children[0].textContent = fmtX(xs[0]); ends.children[1].textContent = fmtX(xs[xs.length - 1]);
418
+ track.append(a, b, ends);
222
419
  const now = document.createElement('span'); now.className = 'pa-now';
223
- now.textContent = `${fmtX(xs[0])} – ${fmtX(xs[xs.length - 1])}`;
224
420
  ctl.append(track, now);
225
- track.appendChild(ticks(tickLabels, (k) => px(xs[k].t) / 100, keep, track));
421
+ const n = xs.length - 1;
226
422
  const set = () => {
227
423
  let i0 = +a.value, i1 = +b.value;
228
- if (i1 - i0 < 1) { if (document.activeElement === a) a.value = i0 = Math.max(0, i1 - 1); else b.value = i1 = Math.min(xs.length - 1, i0 + 1); }
229
- sel.setAttribute('x', px(xs[i0].t)); sel.setAttribute('width', Math.max(0.5, px(xs[i1].t) - px(xs[i0].t)));
424
+ if (i1 - i0 < 1) { if (document.activeElement === a) a.value = i0 = Math.max(0, i1 - 1); else b.value = i1 = Math.min(n, i0 + 1); }
425
+ track.style.setProperty('--pa-a', (100 * i0) / n + '%');
426
+ track.style.setProperty('--pa-b', (100 * i1) / n + '%');
230
427
  now.textContent = `${fmtX(xs[i0])} – ${fmtX(xs[i1])}`;
231
- state = { ...state, xRange: i0 === 0 && i1 === xs.length - 1 ? undefined : [xs[i0].v, xs[i1].v] };
428
+ state = { ...state, xRange: i0 === 0 && i1 === n ? undefined : [xs[i0].v, xs[i1].v] };
232
429
  schedule();
233
430
  };
234
431
  a.addEventListener('input', set); b.addEventListener('input', set);
235
432
  set();
236
433
  }
237
434
 
238
- function ticks(labels, pos, keep, trackEl) {
239
- const box = document.createElement('div'); box.className = 'pa-ticks';
240
- const n = labels.length;
241
- const trackW = Math.max(120, (trackEl && trackEl.clientWidth) || (host.clientWidth || 600) - 180);
242
- const lw = Math.max(...labels.map((l) => String(l).length)) * 6.2 + 14;
243
- const every = Math.max(1, Math.ceil((n * lw) / trackW));
244
- let lastPx = -Infinity;
245
- labels.forEach((l, k) => {
246
- if (keep ? !keep(k) : k % every && k !== n - 1) return;
247
- const px = pos(k) * trackW;
248
- if (px - lastPx < lw && k !== n - 1) return;
249
- if (k === n - 1 && px - lastPx < lw) return;
250
- lastPx = px;
251
- const s = document.createElement('span'); s.textContent = l; s.style.left = (pos(k) * 100).toFixed(2) + '%';
252
- if (k === 0) s.style.transform = 'none';
253
- if (k === n - 1) s.style.transform = 'translateX(-100%)';
254
- box.appendChild(s);
255
- });
256
- return box;
257
- }
258
-
259
- host.addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); } });
435
+ // El oyente se guarda para quitarlo en update()/destroy(): si no, cada redibujo (filtros de un dashboard) suma uno más.
436
+ const onEsc = (e) => {
437
+ if (e.key !== 'Escape') return;
438
+ if (state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); return; }
439
+ // Treemap/sunburst: Esc sube un nivel; en la raíz, vuelve a sumar lo excluido.
440
+ if (Array.isArray(state.focus) && state.focus.length) { state = { ...state, focus: state.focus.slice(0, -1) }; draw(); return; }
441
+ if (state.exclude && [].concat(state.exclude).length) { state = { ...state, exclude: undefined }; draw(); }
442
+ };
443
+ host.addEventListener('keydown', onEsc);
260
444
 
261
445
  draw();
262
446
  if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
@@ -267,11 +451,26 @@ export function render(target, spec, opts = {}) {
267
451
  return {
268
452
  get svg() { return chart.querySelector('svg'); },
269
453
  get spec() { return state; },
270
- update(next) { clearInterval(timer); ro && ro.disconnect(); return render(host, next, opts); },
271
- destroy() { clearInterval(timer); ro && ro.disconnect(); host.innerHTML = ''; },
454
+ update(next) { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); return render(host, next, opts); },
455
+ destroy() { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); host.innerHTML = ''; },
272
456
  };
273
457
  }
274
458
 
459
+ /**
460
+ * ¿Qué control de tiempo dibujar? Ninguno por defecto: el gráfico muestra el
461
+ * último periodo (o `timeValue`). `slider: true` (o `controls: true`) elige
462
+ * botones o slider según cuántos periodos haya; "buttons" y "slider" fuerzan
463
+ * uno. `render(el, spec, { controls: false })` los apaga siempre.
464
+ */
465
+ export function controlMode(s, opts = {}) {
466
+ if (opts.controls === false) return null;
467
+ const v = s.slider ?? s.controls ?? opts.controls;
468
+ if (v === true || v === 'auto') return 'auto';
469
+ if (v === 'buttons' || v === 'button' || v === 'botones') return 'buttons';
470
+ if (v === 'slider' || v === 'range') return 'slider';
471
+ return null;
472
+ }
473
+
275
474
  function timeValuesOf(s) {
276
475
  const data = ensureData(s);
277
476
  const k = fieldKind(data, s.time);
@@ -298,7 +497,8 @@ function button() {
298
497
  }
299
498
  function setIcon(b, kind) {
300
499
  b.setAttribute('aria-label', kind === 'play' ? 'Reproducir' : 'Pausar');
500
+ b.setAttribute('aria-pressed', kind === 'play' ? 'false' : 'true');
301
501
  b.innerHTML = kind === 'play'
302
- ? '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 1.5v9l7.5-4.5z" fill="currentColor"/></svg>'
303
- : '<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2.5" y="1.5" width="2.5" height="9" rx="1" fill="currentColor"/><rect x="7" y="1.5" width="2.5" height="9" rx="1" fill="currentColor"/></svg>';
502
+ ? '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2.6 1.4v7.2L8.6 5z" fill="currentColor" stroke="currentColor" stroke-width=".8" stroke-linejoin="round"/></svg>'
503
+ : '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M3 1.6v6.8M7 1.6v6.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
304
504
  }