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/runtime.js CHANGED
@@ -6,6 +6,7 @@ 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';
9
10
 
10
11
  let plotFn = null;
11
12
  let resolveFn = null;
@@ -26,22 +27,34 @@ function ensureAssets() {
26
27
  const st = document.createElement('style');
27
28
  st.id = 'pa-runtime-css';
28
29
  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}
30
+ .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}
31
+ .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}
32
+ .pa-play[aria-pressed=true]{padding:0}
33
+ .pa-play:hover{border-color:var(--pa-ink)}
34
+ .pa-play:focus-visible,.pa-seg button:focus-visible{outline:2px solid var(--pa-accent);outline-offset:2px}
32
35
  .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}
36
+ .pa-seg{display:flex;flex-wrap:wrap;gap:2px 4px}
37
+ .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}
38
+ .pa-seg button:hover{color:var(--pa-ink)}
39
+ .pa-seg button[aria-checked=true]{color:var(--pa-ink);font-weight:600;box-shadow:inset 0 -2px 0 var(--pa-accent)}
40
+ .pa-track{position:relative;flex:1 1 200px;min-width:0;height:34px}
41
+ .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}
42
+ .pa-dual input[type=range]{background:none;pointer-events:none}
43
+ .pa-dual::before,.pa-dual::after{content:'';position:absolute;top:11px;height:2px;border-radius:1px}
44
+ .pa-dual::before{left:0;right:0;background:var(--pa-line)}
45
+ .pa-dual::after{left:var(--pa-a,0%);right:calc(100% - var(--pa-b,100%));background:var(--pa-ink)}
46
+ .pa-track input[type=range]::-webkit-slider-runnable-track{background:transparent;height:16px}
47
+ .pa-track input[type=range]::-moz-range-track{background:transparent;height:16px}
48
+ .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}
49
+ .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}
50
+ .pa-track input[type=range]:focus-visible{outline:none}
51
+ .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)}
52
+ .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}
53
+ .pa-now{flex:none;font-weight:600;color:var(--pa-ink);font-size:12.5px;min-width:4ch;text-align:right}
54
+ .pa-chart [data-drill],.pa-chart [data-node],.pa-chart [data-group]{cursor:pointer}
55
+ .pa-chart [data-group]:focus-visible,.pa-chart .pa-key:focus-visible{outline:2px solid var(--pa-accent,#3A47B8);outline-offset:1px}
56
+ .pa-live .pa-chev{opacity:1!important}
57
+ .pa-chart .pa-gh:hover .pa-hit{fill:var(--pa-tint,#ECEEF8);fill-opacity:.5}
45
58
  .pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
46
59
  @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}}`;
47
60
  document.head.appendChild(st);
@@ -51,9 +64,44 @@ function ensureAssets() {
51
64
  * Dibuja `spec` en un elemento (o selector) del navegador.
52
65
  * Devuelve { svg, spec, update(spec), destroy() }.
53
66
  */
67
+ const geoCache = new Map();
68
+ /** Descarga (una vez por URL) un GeoJSON o TopoJSON. */
69
+ export function loadGeo(url) {
70
+ 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(); }));
71
+ return geoCache.get(url);
72
+ }
73
+
74
+ /**
75
+ * `geo` como URL ("/mapas/comunas.geojson", "https://…/x.topojson"): se
76
+ * descarga y luego se dibuja. Devuelve el mismo objeto que render(), con
77
+ * `ready` (una promesa) mientras llega el archivo.
78
+ */
79
+ function renderRemote(host, spec, opts) {
80
+ let inner = null, pending = null, dead = false;
81
+ const handle = {
82
+ get svg() { return inner?.svg ?? null; },
83
+ get spec() { return inner?.spec ?? spec; },
84
+ update(s) { if (inner) return inner.update(s); pending = s; },
85
+ destroy() { dead = true; inner?.destroy(); },
86
+ };
87
+ host.setAttribute('aria-busy', 'true');
88
+ handle.ready = loadGeo(spec.geo).then((geo) => {
89
+ host.removeAttribute('aria-busy');
90
+ if (dead) return handle;
91
+ inner = render(host, { ...(pending || spec), geo }, opts);
92
+ return handle;
93
+ }, (e) => {
94
+ host.removeAttribute('aria-busy');
95
+ host.innerHTML = `<p style="font:13px system-ui;color:#C9531A">${String(e.message).replace(/</g, '&lt;')}</p>`;
96
+ throw e;
97
+ });
98
+ return handle;
99
+ }
100
+
54
101
  export function render(target, spec, opts = {}) {
55
102
  const host = typeof target === 'string' ? document.querySelector(target) : target;
56
103
  if (!host) throw new PlotError('No encontré el elemento destino.', 'render("#grafico", spec) o render(elemento, spec)');
104
+ if (spec && typeof spec.geo === 'string' && typeof fetch !== 'undefined' && isGeoUrl(spec.geo)) return renderRemote(host, spec, opts);
57
105
  ensureAssets();
58
106
  let state = resolveFn ? resolveFn(spec) : { ...spec };
59
107
  // Fuente propia (configure({ font }) o spec.font): se carga una sola vez.
@@ -72,14 +120,14 @@ export function render(target, spec, opts = {}) {
72
120
  // Posiciones anteriores de los nodos (para animar el cambio de foco).
73
121
  const nodePos = () => {
74
122
  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')]);
123
+ for (const c of chart.querySelectorAll('circle[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
76
124
  return m;
77
125
  };
78
126
  const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
79
127
  const animateFrom = (before) => {
80
128
  if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
81
129
  const ease = 'cubic-bezier(.2,.7,.2,1)';
82
- for (const c of chart.querySelectorAll('[data-node]')) {
130
+ for (const c of chart.querySelectorAll('circle[data-node]')) {
83
131
  const old = before.get(c.getAttribute('data-node'));
84
132
  if (!old) continue;
85
133
  const dx = old[0] - +c.getAttribute('cx'), dy = old[1] - +c.getAttribute('cy');
@@ -104,6 +152,34 @@ export function render(target, spec, opts = {}) {
104
152
  if (old && /^#/.test(now || '') && old.toLowerCase() !== now.toLowerCase()) n.animate([{ fill: old }, { fill: now }], { duration: 420, easing: 'ease-out' });
105
153
  }
106
154
  };
155
+ // Leyenda: en treemap y sunburst, clic = ver solo esa categoría en todo el
156
+ // plano (otro clic vuelve); en todos los gráficos, shift + clic la excluye
157
+ // (o la vuelve a sumar). Devuelve true si tomó el clic.
158
+ const onLegend = (key, e) => {
159
+ const v = key.getAttribute('data-value');
160
+ if (v === null || key.getAttribute('data-color') === 'dashed') return false;
161
+ const ex = [].concat(state.exclude ?? []).map(String);
162
+ if (e.shiftKey || key.classList.contains('pa-key-off')) {
163
+ const next = ex.includes(v) ? ex.filter((x) => x !== v) : [...ex, v];
164
+ // Colores fijos: los de la leyenda actual, para que nada cambie de color.
165
+ const colors = { ...(state.colors || {}) };
166
+ for (const k of chart.querySelectorAll('.pa-key[data-value]')) {
167
+ const c = k.getAttribute('data-color');
168
+ if (/^#/.test(c || '') && !k.classList.contains('pa-key-off') && !(k.getAttribute('data-value') in colors)) colors[k.getAttribute('data-value')] = c;
169
+ }
170
+ const f = Array.isArray(state.focus) && state.focus[0] === v ? undefined : state.focus;
171
+ state = { ...state, exclude: next.length ? next : undefined, colors, focus: f };
172
+ draw();
173
+ return true;
174
+ }
175
+ if (state.type === 'treemap' || state.type === 'sunburst') {
176
+ const cur = Array.isArray(state.focus) ? state.focus : [];
177
+ state = { ...state, focus: cur.length === 1 && cur[0] === v ? [] : [v] };
178
+ draw();
179
+ return true;
180
+ }
181
+ return false;
182
+ };
107
183
  const draw = () => {
108
184
  const before = nodePos();
109
185
  const fills = regionFills();
@@ -111,11 +187,18 @@ export function render(target, spec, opts = {}) {
111
187
  animateFrom(before);
112
188
  tweenFills(fills);
113
189
  chart.__pa = false;
190
+ chart.__paLegend = onLegend;
114
191
  attach(chart);
192
+ const svgNode = chart.querySelector('svg');
193
+ if (svgNode) svgNode.classList.add('pa-live');
115
194
  // Red: clic en un nodo lo lleva al centro y destaca sus conexiones; otro clic (o Esc) vuelve.
116
195
  for (const n of chart.querySelectorAll('[data-node]')) {
117
- n.setAttribute('tabindex', '0');
118
- n.setAttribute('role', 'button');
196
+ // Solo los círculos son paradas de tabulación; las etiquetas también responden al clic.
197
+ if (n.tagName.toLowerCase() === 'circle') {
198
+ n.setAttribute('tabindex', '0');
199
+ n.setAttribute('role', 'button');
200
+ n.setAttribute('aria-label', (n.getAttribute('data-tip') || n.getAttribute('data-node')).split('\n')[0]);
201
+ }
119
202
  const id = n.getAttribute('data-node');
120
203
  const go = () => { state = { ...state, focus: String(state.focus) === id ? undefined : id }; draw(); };
121
204
  n.addEventListener('click', go);
@@ -128,57 +211,102 @@ export function render(target, spec, opts = {}) {
128
211
  n.addEventListener('click', go);
129
212
  n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
130
213
  }
214
+ // Gantt: clic en una fase la pliega en una barra-resumen; otro clic la despliega.
215
+ // Alt + clic pliega o despliega todas.
216
+ const groupsAll = [...chart.querySelectorAll('[data-group]')].map((n) => n.getAttribute('data-group'));
217
+ for (const n of chart.querySelectorAll('[data-group]')) {
218
+ if (!n.querySelector('.pa-chev')) continue; // collapsible: false
219
+ const name = n.getAttribute('data-group');
220
+ n.setAttribute('tabindex', '0');
221
+ n.setAttribute('role', 'button');
222
+ n.setAttribute('aria-expanded', n.getAttribute('data-collapsed') === 'true' ? 'false' : 'true');
223
+ const go = (e) => {
224
+ let cur = state.collapsed === true ? [...groupsAll] : [].concat(state.collapsed || []).map(String);
225
+ if (e && e.altKey) cur = cur.length === groupsAll.length ? [] : [...groupsAll];
226
+ else cur = cur.includes(name) ? cur.filter((g) => g !== name) : [...cur, name];
227
+ state = { ...state, collapsed: cur };
228
+ draw();
229
+ const again = [...chart.querySelectorAll('[data-group]')].find((m) => m.getAttribute('data-group') === name);
230
+ if (again && e && e.type === 'keydown') again.focus();
231
+ };
232
+ n.addEventListener('click', go);
233
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
234
+ }
131
235
  };
132
236
  const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
133
237
 
134
238
  const theme = themeFor(state);
135
239
  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, "'")}`;
240
+ 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
241
  ctl.hidden = true;
138
242
 
139
- if (opts.controls !== false && state.slider !== false) {
140
- if (state.time && !SNAP_EXCLUDE.has(state.type) && state.x !== state.time) buildPeriod();
243
+ // Controles de tiempo: SOLO si se piden (`slider: true`, "buttons" o "slider").
244
+ // Con pocos periodos van botones; con muchos, un slider mínimo.
245
+ const want = controlMode(state, opts);
246
+ if (want) {
247
+ if (state.time && !SNAP_EXCLUDE.has(state.type) && state.x !== state.time) buildPeriod(want);
141
248
  else if (RANGE_TYPES.has(state.type)) buildRange();
142
249
  }
143
250
 
144
- // ── Periodo único con "play" (d3plus time) ──────────────────────────────
145
- function buildPeriod() {
251
+ // ── Periodo único: botones o slider, con un play sobrio ─────────────────
252
+ function buildPeriod(mode) {
146
253
  const vals = timeValuesOf(state);
147
254
  if (vals.length < 2) return;
148
255
  let i = vals.findIndex((v) => String(v) === String(state.timeValue ?? vals[vals.length - 1]));
149
256
  if (i < 0) i = vals.length - 1;
150
257
  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));
258
+ const labels = vals.map(String);
259
+ const buttons = mode === 'buttons' || (mode === 'auto' && vals.length <= 8 && labels.join('').length <= 64);
260
+ const play = state.play === false ? null : button();
261
+ if (play) ctl.append(play);
262
+ let setUI;
263
+ if (buttons) {
264
+ const group = document.createElement('div');
265
+ group.className = 'pa-seg'; group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-label', String(state.time));
266
+ const btns = labels.map((l, k) => {
267
+ const b = document.createElement('button');
268
+ b.type = 'button'; b.textContent = l; b.setAttribute('role', 'radio');
269
+ b.addEventListener('click', () => { stop(); set(k); });
270
+ b.addEventListener('keydown', (e) => {
271
+ const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
272
+ if (!d) return;
273
+ e.preventDefault(); stop();
274
+ const k2 = Math.max(0, Math.min(vals.length - 1, i + d)); set(k2); btns[k2].focus();
275
+ });
276
+ group.appendChild(b);
277
+ return b;
278
+ });
279
+ ctl.append(group);
280
+ setUI = (k) => btns.forEach((b, j) => { b.setAttribute('aria-checked', j === k ? 'true' : 'false'); b.tabIndex = j === k ? 0 : -1; });
281
+ } else {
282
+ const track = document.createElement('div'); track.className = 'pa-track';
283
+ const input = range(0, vals.length - 1, i, String(state.time));
284
+ input.style.setProperty('--pa-p', '100%');
285
+ const ends = document.createElement('div'); ends.className = 'pa-ends';
286
+ ends.innerHTML = '<span></span><span></span>';
287
+ ends.children[0].textContent = labels[0]; ends.children[1].textContent = labels[labels.length - 1];
288
+ track.append(input, ends);
289
+ const now = document.createElement('span'); now.className = 'pa-now';
290
+ ctl.append(track, now);
291
+ input.addEventListener('input', () => { stop(); set(+input.value); });
292
+ setUI = (k) => { input.value = k; input.style.setProperty('--pa-p', (100 * k) / (vals.length - 1) + '%'); now.textContent = labels[k]; };
293
+ }
163
294
  const set = (k) => {
164
- i = k; input.value = k;
165
- fill.setAttribute('width', (100 * k) / (vals.length - 1));
166
- now.textContent = String(vals[k]);
295
+ i = k; setUI(k);
167
296
  state = { ...state, timeValue: vals[k] };
168
297
  schedule();
169
298
  };
170
- input.addEventListener('input', () => { stop(); set(+input.value); });
171
- play.addEventListener('click', () => {
299
+ if (play) play.addEventListener('click', () => {
172
300
  if (timer) return stop();
173
301
  if (i >= vals.length - 1) set(0);
174
302
  setIcon(play, 'pause');
175
- timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1000);
303
+ timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1200);
176
304
  });
177
- function stop() { clearInterval(timer); timer = null; setIcon(play, 'play'); }
178
- set(i);
305
+ function stop() { clearInterval(timer); timer = null; if (play) setIcon(play, 'play'); }
306
+ setUI(i);
179
307
  }
180
308
 
181
- // ── Rango con vista general (series de tiempo) ──────────────────────────
309
+ // ── Rango (series de tiempo): dos tiradores sobre una línea fina ────────
182
310
  function buildRange() {
183
311
  const data = ensureData(state);
184
312
  const xf = state.x;
@@ -188,75 +316,43 @@ export function render(target, spec, opts = {}) {
188
316
  const raw = uniq(data.map((r) => r[xf]));
189
317
  const val = (v) => (kind === 'date' ? parseDate(v) : num(v));
190
318
  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;
319
+ if (xs.length < 3) return;
192
320
  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
321
  const fmtX = (p) => (kind === 'date' ? formatDate(p.t, dateUnit(xs.map((q) => q.t)), state.locale || 'es') : String(p.v));
211
322
  let lo = 0, hi = xs.length - 1;
212
323
  if (state.xRange) {
213
324
  const a = xs.findIndex((p) => p.t >= val(state.xRange[0])), b = xs.map((p) => p.t <= val(state.xRange[1])).lastIndexOf(true);
214
325
  if (a >= 0) lo = a; if (b >= 0) hi = b;
215
326
  }
327
+ const track = document.createElement('div'); track.className = 'pa-track pa-dual';
216
328
  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);
329
+ const ends = document.createElement('div'); ends.className = 'pa-ends';
330
+ ends.innerHTML = '<span></span><span></span>';
331
+ ends.children[0].textContent = fmtX(xs[0]); ends.children[1].textContent = fmtX(xs[xs.length - 1]);
332
+ track.append(a, b, ends);
222
333
  const now = document.createElement('span'); now.className = 'pa-now';
223
- now.textContent = `${fmtX(xs[0])} – ${fmtX(xs[xs.length - 1])}`;
224
334
  ctl.append(track, now);
225
- track.appendChild(ticks(tickLabels, (k) => px(xs[k].t) / 100, keep, track));
335
+ const n = xs.length - 1;
226
336
  const set = () => {
227
337
  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)));
338
+ 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); }
339
+ track.style.setProperty('--pa-a', (100 * i0) / n + '%');
340
+ track.style.setProperty('--pa-b', (100 * i1) / n + '%');
230
341
  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] };
342
+ state = { ...state, xRange: i0 === 0 && i1 === n ? undefined : [xs[i0].v, xs[i1].v] };
232
343
  schedule();
233
344
  };
234
345
  a.addEventListener('input', set); b.addEventListener('input', set);
235
346
  set();
236
347
  }
237
348
 
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(); } });
349
+ host.addEventListener('keydown', (e) => {
350
+ if (e.key !== 'Escape') return;
351
+ if (state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); return; }
352
+ // Treemap/sunburst: Esc sube un nivel; en la raíz, vuelve a sumar lo excluido.
353
+ if (Array.isArray(state.focus) && state.focus.length) { state = { ...state, focus: state.focus.slice(0, -1) }; draw(); return; }
354
+ if (state.exclude && [].concat(state.exclude).length) { state = { ...state, exclude: undefined }; draw(); }
355
+ });
260
356
 
261
357
  draw();
262
358
  if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
@@ -272,6 +368,21 @@ export function render(target, spec, opts = {}) {
272
368
  };
273
369
  }
274
370
 
371
+ /**
372
+ * ¿Qué control de tiempo dibujar? Ninguno por defecto: el gráfico muestra el
373
+ * último periodo (o `timeValue`). `slider: true` (o `controls: true`) elige
374
+ * botones o slider según cuántos periodos haya; "buttons" y "slider" fuerzan
375
+ * uno. `render(el, spec, { controls: false })` los apaga siempre.
376
+ */
377
+ export function controlMode(s, opts = {}) {
378
+ if (opts.controls === false) return null;
379
+ const v = s.slider ?? s.controls ?? opts.controls;
380
+ if (v === true || v === 'auto') return 'auto';
381
+ if (v === 'buttons' || v === 'button' || v === 'botones') return 'buttons';
382
+ if (v === 'slider' || v === 'range') return 'slider';
383
+ return null;
384
+ }
385
+
275
386
  function timeValuesOf(s) {
276
387
  const data = ensureData(s);
277
388
  const k = fieldKind(data, s.time);
@@ -298,7 +409,8 @@ function button() {
298
409
  }
299
410
  function setIcon(b, kind) {
300
411
  b.setAttribute('aria-label', kind === 'play' ? 'Reproducir' : 'Pausar');
412
+ b.setAttribute('aria-pressed', kind === 'play' ? 'false' : 'true');
301
413
  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>';
414
+ ? '<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>'
415
+ : '<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
416
  }
package/src/scale.js CHANGED
@@ -10,18 +10,33 @@ export function niceStep(span, count = 5) {
10
10
  return f * mag;
11
11
  }
12
12
 
13
- /** Dominio "bonito": extremos redondeados al paso de las marcas. */
14
- export function niceDomain(min, max, count = 5) {
13
+ /** Paso entero (1, 2, 5, 10, 20, 25…): nunca < 1 ni 2,5. */
14
+ export const intStep = (step) => (!(step >= 1) ? 1 : Math.abs(step - 2.5) < 1e-9 ? 2 : step);
15
+
16
+ /**
17
+ * Dominio "bonito": extremos redondeados al paso de las marcas.
18
+ * `integer: true` (valores enteros, como conteos) garantiza paso ≥ 1 y sin 2,5
19
+ * (como `minInterval: 1` de ECharts): nunca marcas en 0,5.
20
+ */
21
+ export function niceDomain(min, max, count = 5, { integer = false } = {}) {
22
+ const st = (span) => (integer ? intStep(niceStep(span, count)) : niceStep(span, count));
23
+ // Sin valores finitos (todo nulo o texto): dominio [0, 1] en vez de un bucle infinito.
24
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
25
+ if (Number.isFinite(min)) max = min;
26
+ else if (Number.isFinite(max)) min = max;
27
+ else return integer ? { min: 0, max: 1, step: 1 } : { min: 0, max: 1, step: 0.2 };
28
+ }
29
+ if (min > max) [min, max] = [max, min];
15
30
  if (min === max) {
16
- if (min === 0) return { min: 0, max: 1, step: 0.2 };
17
- const pad = Math.abs(min) * 0.1;
31
+ if (min === 0) return integer ? { min: 0, max: 1, step: 1 } : { min: 0, max: 1, step: 0.2 };
32
+ const pad = integer ? 0.5 : Math.abs(min) * 0.1;
18
33
  min -= pad;
19
34
  max += pad;
20
35
  }
21
- let step = niceStep(max - min, count);
36
+ let step = st(max - min);
22
37
  let lo = Math.floor(min / step + 1e-9) * step;
23
38
  let hi = Math.ceil(max / step - 1e-9) * step;
24
- step = niceStep(hi - lo, count);
39
+ step = st(hi - lo);
25
40
  lo = Math.floor(min / step + 1e-9) * step;
26
41
  hi = Math.ceil(max / step - 1e-9) * step;
27
42
  return { min: lo, max: hi, step };
@@ -35,9 +50,14 @@ export function linear(d0, d1, r0, r1) {
35
50
  s.range = [r0, r1];
36
51
  s.ticks = (step) => {
37
52
  const out = [];
38
- const st = step || niceStep(d1 - d0, 5);
39
- const start = Math.ceil(Math.min(d0, d1) / st - 1e-9) * st;
40
- for (let v = start; v <= Math.max(d0, d1) + st * 1e-9; v += st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(12));
53
+ if (!Number.isFinite(d0) || !Number.isFinite(d1)) return out;
54
+ let st = step > 0 && Number.isFinite(step) ? step : niceStep(Math.abs(d1 - d0), 5);
55
+ const lo = Math.min(d0, d1), hi = Math.max(d0, d1);
56
+ // Nunca más de ~1000 marcas (paso diminuto frente al dominio, o paso que no avanza).
57
+ if ((hi - lo) / st > 1000) st = niceStep(hi - lo, 5);
58
+ const start = Math.ceil(lo / st - 1e-9) * st;
59
+ if (start + st === start) return [lo];
60
+ for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(12));
41
61
  return out;
42
62
  };
43
63
  return s;
@@ -53,6 +73,7 @@ export function log(d0, d1, r0, r1) {
53
73
  s.range = [r0, r1];
54
74
  // Marcas 1-2-5 cuando hay pocas décadas; solo potencias de 10 si hay muchas.
55
75
  s.ticks = (max = 7) => {
76
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [];
56
77
  const cap = Number.isInteger(max) && max >= 2 ? max : 7;
57
78
  const gen = (mult) => {
58
79
  const out = [];
@@ -71,6 +92,15 @@ export function log(d0, d1, r0, r1) {
71
92
  const every = Math.ceil(t.length / cap);
72
93
  return t.filter((_, i) => i % every === 0);
73
94
  };
95
+ // Rango estrecho (120–180 → solo "100, 200"): marcas lineales dentro del dominio.
96
+ const logTicks = s.ticks;
97
+ s.ticks = (max = 7) => {
98
+ const t = logTicks(max);
99
+ if (t.length >= 3 || !(d1 > d0)) return t;
100
+ const st = niceStep(d1 - d0, 5);
101
+ const lin = linear(d0, d1, 0, 1).ticks(st).filter((v) => v > 0);
102
+ return lin.length > t.length ? lin : t;
103
+ };
74
104
  return s;
75
105
  }
76
106
 
@@ -79,6 +109,8 @@ export function logDomain(min, max) {
79
109
  const steps = [1, 2, 5, 10];
80
110
  const down = (v) => { const e = Math.floor(Math.log10(v)); const m = v / 10 ** e; const k = [...steps].reverse().find((s) => s <= m + 1e-9) || 1; return k * 10 ** e; };
81
111
  const up = (v) => { const e = Math.floor(Math.log10(v)); const m = v / 10 ** e; const k = steps.find((s) => s >= m - 1e-9) || 10; return k * 10 ** e; };
112
+ if (!Number.isFinite(min) || !(min > 0)) min = Number.isFinite(max) && max > 0 ? max / 10 : 1;
113
+ if (!Number.isFinite(max) || !(max > 0)) max = min * 10;
82
114
  const lo = down(Math.max(min, 1e-12)), hi = up(Math.max(max, 1e-12));
83
115
  return { min: lo, max: hi === lo ? lo * 10 : hi };
84
116
  }
@@ -101,9 +133,19 @@ export function band(domain, r0, r1, { inner = 0.3, outer = 0.15 } = {}) {
101
133
  // ---------------------------------------------------------------------------
102
134
  // Tiempo
103
135
 
104
- const DAY = 864e5;
136
+ const MIN = 6e4, HOUR = 36e5, DAY = 864e5;
105
137
  const UNITS = [
138
+ ['minute', 1, MIN],
139
+ ['minute', 5, 5 * MIN],
140
+ ['minute', 15, 15 * MIN],
141
+ ['minute', 30, 30 * MIN],
142
+ ['hour', 1, HOUR],
143
+ ['hour', 3, 3 * HOUR],
144
+ ['hour', 6, 6 * HOUR],
145
+ ['hour', 12, 12 * HOUR],
106
146
  ['day', 1, DAY],
147
+ ['day', 2, 2 * DAY],
148
+ ['day', 3, 3 * DAY],
107
149
  ['day', 7, 7 * DAY],
108
150
  ['month', 1, 30.44 * DAY],
109
151
  ['month', 3, 91.3 * DAY],
@@ -114,10 +156,14 @@ const UNITS = [
114
156
  ['year', 10, 3652 * DAY],
115
157
  ['year', 25, 9131 * DAY],
116
158
  ['year', 50, 18262 * DAY],
159
+ ['year', 100, 36525 * DAY],
160
+ ['year', 250, 91312 * DAY],
161
+ ['year', 500, 182625 * DAY],
117
162
  ];
118
163
 
119
164
  /** Marcas de tiempo legibles: elige la unidad que da ≤ `count` marcas. */
120
165
  export function timeTicks(t0, t1, count = 6, locale = 'es') {
166
+ if (!Number.isFinite(t0) || !Number.isFinite(t1)) return { ticks: [], unit: 'day', every: 1 };
121
167
  const span = Math.max(1, t1 - t0);
122
168
  let [unit, every] = UNITS.find(([, , ms]) => span / ms <= count) || UNITS[UNITS.length - 1];
123
169
  const out = [];
@@ -140,6 +186,14 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
140
186
  const c = new Date(cur);
141
187
  cur = Date.UTC(c.getUTCFullYear(), c.getUTCMonth() + every, 1);
142
188
  }
189
+ } else if (unit === 'hour' || unit === 'minute') {
190
+ // Múltiplos exactos del paso en UTC (las horas 1/3/6/12 dividen el día).
191
+ const ms = every * (unit === 'hour' ? HOUR : MIN);
192
+ cur = Math.ceil(t0 / ms - 1e-9) * ms;
193
+ while (cur <= t1) {
194
+ out.push(cur);
195
+ cur += ms;
196
+ }
143
197
  } else {
144
198
  cur = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
145
199
  if (cur < t0) cur += DAY;
@@ -148,9 +202,18 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
148
202
  cur += every * DAY;
149
203
  }
150
204
  }
205
+ const dayOf = (t) => Math.floor(t / DAY);
151
206
  const label = (t, i) => {
152
207
  const dt = new Date(t);
153
208
  if (unit === 'year') return formatDate(t, 'year', locale);
209
+ // Hora "HH:mm"; la fecha solo cuando cambia el día (a medianoche, solo la fecha).
210
+ if (unit === 'hour' || unit === 'minute') {
211
+ const newDay = i === 0 || dayOf(out[i - 1]) !== dayOf(t);
212
+ if (!newDay) return formatDate(t, unit, locale, false);
213
+ const prevY = i > 0 ? new Date(out[i - 1]).getUTCFullYear() : null;
214
+ const date = formatDate(t, 'day', locale, i > 0 && prevY !== dt.getUTCFullYear());
215
+ return t % DAY === 0 && i > 0 ? date : `${date} ${formatDate(t, unit, locale, false)}`;
216
+ }
154
217
  if (unit === 'month') {
155
218
  const first = i === 0 || dt.getUTCMonth() < every;
156
219
  if (every === 3) return formatDate(t, 'quarter', locale, first);
@@ -165,7 +228,14 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
165
228
  /** Unidad natural de una serie de fechas (para tooltips). */
166
229
  export function dateUnit(times) {
167
230
  const ds = times.map((t) => new Date(t));
231
+ if (ds.some((d) => d.getUTCSeconds() || d.getUTCMinutes())) return 'minute';
232
+ if (ds.some((d) => d.getUTCHours())) return 'hour';
168
233
  if (ds.every((d) => d.getUTCMonth() === 0 && d.getUTCDate() === 1)) return 'year';
169
234
  if (ds.every((d) => d.getUTCDate() === 1)) return 'month';
170
235
  return 'day';
171
236
  }
237
+
238
+ /** Mitad de la unidad natural (para centrar una sola fecha en el eje). */
239
+ export function halfUnit(unit) {
240
+ return { minute: MIN / 2, hour: HOUR / 2, day: DAY / 2, month: 15 * DAY, year: 182.6 * DAY }[unit] ?? DAY / 2;
241
+ }