plotagents 0.0.0-stage → 0.5.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 (60) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -2
  4. package/bin/plotagents.js +49 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +7954 -0
  7. package/docs/api.md +154 -0
  8. package/docs/charts.md +218 -0
  9. package/docs/getting-started.md +138 -0
  10. package/docs/rendering.md +47 -0
  11. package/docs/themes.md +86 -0
  12. package/index.d.ts +213 -0
  13. package/package.json +74 -4
  14. package/schema.json +895 -0
  15. package/src/axes.js +144 -0
  16. package/src/charts/bar.js +339 -0
  17. package/src/charts/categorical.js +471 -0
  18. package/src/charts/chord.js +218 -0
  19. package/src/charts/choropleth.js +559 -0
  20. package/src/charts/dist.js +513 -0
  21. package/src/charts/donut.js +232 -0
  22. package/src/charts/flowchart.js +407 -0
  23. package/src/charts/heatmap.js +76 -0
  24. package/src/charts/kpi.js +97 -0
  25. package/src/charts/line.js +419 -0
  26. package/src/charts/network.js +344 -0
  27. package/src/charts/pyramid.js +177 -0
  28. package/src/charts/radar.js +152 -0
  29. package/src/charts/regplot.js +364 -0
  30. package/src/charts/sankey.js +133 -0
  31. package/src/charts/scatter.js +136 -0
  32. package/src/charts/sunburst.js +135 -0
  33. package/src/charts/treemap.js +194 -0
  34. package/src/context.js +10 -0
  35. package/src/dashed.js +90 -0
  36. package/src/data.js +137 -0
  37. package/src/facet.js +84 -0
  38. package/src/format.js +171 -0
  39. package/src/frame.js +157 -0
  40. package/src/geo/chile.js +7 -0
  41. package/src/geo/data/chile-regiones.js +12 -0
  42. package/src/geo/data/usa-estados.js +12 -0
  43. package/src/geo/data/world.js +12 -0
  44. package/src/geo/projection.js +152 -0
  45. package/src/geo/registry.js +31 -0
  46. package/src/geo/usa.js +8 -0
  47. package/src/geo/world.js +7 -0
  48. package/src/hierarchy.js +139 -0
  49. package/src/index.js +271 -0
  50. package/src/infer.js +49 -0
  51. package/src/interact.js +362 -0
  52. package/src/lint.js +137 -0
  53. package/src/palette.js +59 -0
  54. package/src/runtime.js +304 -0
  55. package/src/scale.js +171 -0
  56. package/src/schema.js +181 -0
  57. package/src/stats.js +246 -0
  58. package/src/svg.js +85 -0
  59. package/src/text.js +61 -0
  60. package/src/theme.js +224 -0
package/src/runtime.js ADDED
@@ -0,0 +1,304 @@
1
+ // Navegador: render() con ancho adaptable, tooltips, slider temporal
2
+ // (periodo con "play" o rango con vista general) y navegación por niveles
3
+ // en treemap/sunburst. Todo se vuelve a dibujar con plot(): sin estado oculto.
4
+ import { attach } from './interact.js';
5
+ import { themeFor, FONT_CSS } from './theme.js';
6
+ import { ensureData, fieldKind, uniq, num, PlotError } from './data.js';
7
+ import { parseDate, formatDate } from './format.js';
8
+ import { dateUnit } from './scale.js';
9
+
10
+ let plotFn = null;
11
+ let resolveFn = null;
12
+ /** index.js registra plot/resolve aquí (evita una importación circular). */
13
+ export function _bind(plot, resolve) { plotFn = plot; resolveFn = resolve; }
14
+
15
+ const RANGE_TYPES = new Set(['line', 'area']);
16
+ const SNAP_EXCLUDE = new Set(['line', 'area', 'ecdf']);
17
+
18
+ function ensureAssets() {
19
+ if (typeof document === 'undefined') return;
20
+ if (!document.getElementById('pa-font')) {
21
+ const l = document.createElement('link');
22
+ l.id = 'pa-font'; l.rel = 'stylesheet'; l.href = FONT_CSS;
23
+ document.head.appendChild(l);
24
+ }
25
+ if (document.getElementById('pa-runtime-css')) return;
26
+ const st = document.createElement('style');
27
+ st.id = 'pa-runtime-css';
28
+ 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}
32
+ .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}
45
+ .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}}`;
47
+ document.head.appendChild(st);
48
+ }
49
+
50
+ /**
51
+ * Dibuja `spec` en un elemento (o selector) del navegador.
52
+ * Devuelve { svg, spec, update(spec), destroy() }.
53
+ */
54
+ export function render(target, spec, opts = {}) {
55
+ const host = typeof target === 'string' ? document.querySelector(target) : target;
56
+ if (!host) throw new PlotError('No encontré el elemento destino.', 'render("#grafico", spec) o render(elemento, spec)');
57
+ ensureAssets();
58
+ let state = resolveFn ? resolveFn(spec) : { ...spec };
59
+ // Fuente propia (configure({ font }) o spec.font): se carga una sola vez.
60
+ const fontUrl = themeFor(state).font?.import;
61
+ if (fontUrl && typeof document !== 'undefined' && !document.querySelector(`link[href="${fontUrl}"]`)) {
62
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = fontUrl; document.head.appendChild(l);
63
+ }
64
+ host.innerHTML = '';
65
+ const chart = document.createElement('div');
66
+ const ctl = document.createElement('div');
67
+ host.appendChild(chart);
68
+ host.appendChild(ctl);
69
+ let timer = null, ro = null, raf = 0;
70
+
71
+ const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
72
+ // Posiciones anteriores de los nodos (para animar el cambio de foco).
73
+ const nodePos = () => {
74
+ 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')]);
76
+ return m;
77
+ };
78
+ const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
79
+ const animateFrom = (before) => {
80
+ if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
81
+ const ease = 'cubic-bezier(.2,.7,.2,1)';
82
+ for (const c of chart.querySelectorAll('[data-node]')) {
83
+ const old = before.get(c.getAttribute('data-node'));
84
+ if (!old) continue;
85
+ const dx = old[0] - +c.getAttribute('cx'), dy = old[1] - +c.getAttribute('cy');
86
+ c.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: 'translate(0,0)' }], { duration: 650, easing: ease });
87
+ }
88
+ for (const sel of ['.pa-links', '.pa-labels']) {
89
+ const n = chart.querySelector(sel);
90
+ if (n) n.animate([{ opacity: 0 }, { opacity: 0 }, { opacity: 1 }], { duration: 750, easing: 'ease-out' });
91
+ }
92
+ };
93
+ // Colores anteriores de las regiones (mapas): al mover el slider, el color
94
+ // de cada región transita al nuevo en vez de saltar.
95
+ const regionFills = () => {
96
+ const m = new Map();
97
+ for (const n of chart.querySelectorAll('.pa-regions [data-k]')) { const f = n.getAttribute('fill'); if (/^#/.test(f || '')) m.set(n.getAttribute('data-k'), f); }
98
+ return m;
99
+ };
100
+ const tweenFills = (before) => {
101
+ if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
102
+ for (const n of chart.querySelectorAll('.pa-regions [data-k]')) {
103
+ const old = before.get(n.getAttribute('data-k')), now = n.getAttribute('fill');
104
+ if (old && /^#/.test(now || '') && old.toLowerCase() !== now.toLowerCase()) n.animate([{ fill: old }, { fill: now }], { duration: 420, easing: 'ease-out' });
105
+ }
106
+ };
107
+ const draw = () => {
108
+ const before = nodePos();
109
+ const fills = regionFills();
110
+ chart.innerHTML = plotFn({ ...state, width: width() });
111
+ animateFrom(before);
112
+ tweenFills(fills);
113
+ chart.__pa = false;
114
+ attach(chart);
115
+ // Red: clic en un nodo lo lleva al centro y destaca sus conexiones; otro clic (o Esc) vuelve.
116
+ for (const n of chart.querySelectorAll('[data-node]')) {
117
+ n.setAttribute('tabindex', '0');
118
+ n.setAttribute('role', 'button');
119
+ const id = n.getAttribute('data-node');
120
+ const go = () => { state = { ...state, focus: String(state.focus) === id ? undefined : id }; draw(); };
121
+ n.addEventListener('click', go);
122
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
123
+ }
124
+ for (const n of chart.querySelectorAll('[data-drill]')) {
125
+ n.setAttribute('tabindex', '0');
126
+ n.setAttribute('role', 'button');
127
+ const go = () => { state = { ...state, focus: JSON.parse(n.getAttribute('data-drill')) }; draw(); };
128
+ n.addEventListener('click', go);
129
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
130
+ }
131
+ };
132
+ const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
133
+
134
+ const theme = themeFor(state);
135
+ 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, "'")}`;
137
+ ctl.hidden = true;
138
+
139
+ if (opts.controls !== false && state.slider !== false) {
140
+ if (state.time && !SNAP_EXCLUDE.has(state.type) && state.x !== state.time) buildPeriod();
141
+ else if (RANGE_TYPES.has(state.type)) buildRange();
142
+ }
143
+
144
+ // ── Periodo único con "play" (d3plus time) ──────────────────────────────
145
+ function buildPeriod() {
146
+ const vals = timeValuesOf(state);
147
+ if (vals.length < 2) return;
148
+ let i = vals.findIndex((v) => String(v) === String(state.timeValue ?? vals[vals.length - 1]));
149
+ if (i < 0) i = vals.length - 1;
150
+ 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));
163
+ const set = (k) => {
164
+ i = k; input.value = k;
165
+ fill.setAttribute('width', (100 * k) / (vals.length - 1));
166
+ now.textContent = String(vals[k]);
167
+ state = { ...state, timeValue: vals[k] };
168
+ schedule();
169
+ };
170
+ input.addEventListener('input', () => { stop(); set(+input.value); });
171
+ play.addEventListener('click', () => {
172
+ if (timer) return stop();
173
+ if (i >= vals.length - 1) set(0);
174
+ setIcon(play, 'pause');
175
+ timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1000);
176
+ });
177
+ function stop() { clearInterval(timer); timer = null; setIcon(play, 'play'); }
178
+ set(i);
179
+ }
180
+
181
+ // ── Rango con vista general (series de tiempo) ──────────────────────────
182
+ function buildRange() {
183
+ const data = ensureData(state);
184
+ const xf = state.x;
185
+ if (!xf || !data.length || !(xf in data[0])) return;
186
+ const kind = fieldKind(data, xf);
187
+ if (!['date', 'year', 'number'].includes(kind)) return;
188
+ const raw = uniq(data.map((r) => r[xf]));
189
+ const val = (v) => (kind === 'date' ? parseDate(v) : num(v));
190
+ 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;
192
+ 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
+ const fmtX = (p) => (kind === 'date' ? formatDate(p.t, dateUnit(xs.map((q) => q.t)), state.locale || 'es') : String(p.v));
211
+ let lo = 0, hi = xs.length - 1;
212
+ if (state.xRange) {
213
+ const a = xs.findIndex((p) => p.t >= val(state.xRange[0])), b = xs.map((p) => p.t <= val(state.xRange[1])).lastIndexOf(true);
214
+ if (a >= 0) lo = a; if (b >= 0) hi = b;
215
+ }
216
+ 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);
222
+ const now = document.createElement('span'); now.className = 'pa-now';
223
+ now.textContent = `${fmtX(xs[0])} – ${fmtX(xs[xs.length - 1])}`;
224
+ ctl.append(track, now);
225
+ track.appendChild(ticks(tickLabels, (k) => px(xs[k].t) / 100, keep, track));
226
+ const set = () => {
227
+ 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)));
230
+ 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] };
232
+ schedule();
233
+ };
234
+ a.addEventListener('input', set); b.addEventListener('input', set);
235
+ set();
236
+ }
237
+
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(); } });
260
+
261
+ draw();
262
+ if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
263
+ let last = host.clientWidth;
264
+ ro = new ResizeObserver(() => { if (Math.abs(host.clientWidth - last) > 8) { last = host.clientWidth; schedule(); } });
265
+ ro.observe(host);
266
+ }
267
+ return {
268
+ get svg() { return chart.querySelector('svg'); },
269
+ 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 = ''; },
272
+ };
273
+ }
274
+
275
+ function timeValuesOf(s) {
276
+ const data = ensureData(s);
277
+ const k = fieldKind(data, s.time);
278
+ return uniq(data.map((r) => r[s.time]).filter((v) => v !== null && v !== undefined && v !== ''))
279
+ .sort((a, b) => (k === 'date' ? parseDate(a) - parseDate(b) : k === 'year' || k === 'number' ? num(a) - num(b) : 0));
280
+ }
281
+
282
+ function svgEl(tag, attrs) {
283
+ const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
284
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
285
+ return n;
286
+ }
287
+ function range(min, max, value, label) {
288
+ const i = document.createElement('input');
289
+ i.type = 'range'; i.min = min; i.max = max; i.step = 1; i.value = value;
290
+ i.setAttribute('aria-label', label);
291
+ return i;
292
+ }
293
+ function button() {
294
+ const b = document.createElement('button');
295
+ b.type = 'button'; b.className = 'pa-play';
296
+ setIcon(b, 'play');
297
+ return b;
298
+ }
299
+ function setIcon(b, kind) {
300
+ b.setAttribute('aria-label', kind === 'play' ? 'Reproducir' : 'Pausar');
301
+ 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>';
304
+ }
package/src/scale.js ADDED
@@ -0,0 +1,171 @@
1
+ // Escalas: lineal, logarítmica, de bandas y de tiempo (sin dependencias).
2
+ import { formatDate } from './format.js';
3
+
4
+ export function niceStep(span, count = 5) {
5
+ if (!(span > 0)) return 1;
6
+ const raw = span / Math.max(1, count);
7
+ const mag = 10 ** Math.floor(Math.log10(raw));
8
+ const n = raw / mag;
9
+ const f = n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10;
10
+ return f * mag;
11
+ }
12
+
13
+ /** Dominio "bonito": extremos redondeados al paso de las marcas. */
14
+ export function niceDomain(min, max, count = 5) {
15
+ if (min === max) {
16
+ if (min === 0) return { min: 0, max: 1, step: 0.2 };
17
+ const pad = Math.abs(min) * 0.1;
18
+ min -= pad;
19
+ max += pad;
20
+ }
21
+ let step = niceStep(max - min, count);
22
+ let lo = Math.floor(min / step + 1e-9) * step;
23
+ let hi = Math.ceil(max / step - 1e-9) * step;
24
+ step = niceStep(hi - lo, count);
25
+ lo = Math.floor(min / step + 1e-9) * step;
26
+ hi = Math.ceil(max / step - 1e-9) * step;
27
+ return { min: lo, max: hi, step };
28
+ }
29
+
30
+ export function linear(d0, d1, r0, r1) {
31
+ const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);
32
+ const s = (v) => r0 + (v - d0) * k;
33
+ s.invert = (p) => (k === 0 ? d0 : d0 + (p - r0) / k);
34
+ s.domain = [d0, d1];
35
+ s.range = [r0, r1];
36
+ s.ticks = (step) => {
37
+ 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));
41
+ return out;
42
+ };
43
+ return s;
44
+ }
45
+
46
+ export function log(d0, d1, r0, r1) {
47
+ const lo = Math.log10(Math.max(d0, 1e-12));
48
+ const hi = Math.log10(Math.max(d1, 1e-12));
49
+ const inner = linear(lo, hi, r0, r1);
50
+ const s = (v) => inner(Math.log10(Math.max(v, 1e-12)));
51
+ s.invert = (p) => 10 ** inner.invert(p);
52
+ s.domain = [d0, d1];
53
+ s.range = [r0, r1];
54
+ // Marcas 1-2-5 cuando hay pocas décadas; solo potencias de 10 si hay muchas.
55
+ s.ticks = (max = 7) => {
56
+ const cap = Number.isInteger(max) && max >= 2 ? max : 7;
57
+ const gen = (mult) => {
58
+ const out = [];
59
+ for (let e = Math.floor(lo) - 1; e <= Math.ceil(hi); e++)
60
+ for (const k of mult) {
61
+ const v = +(k * 10 ** e).toPrecision(12);
62
+ if (v >= d0 * 0.999 && v <= d1 * 1.001) out.push(v);
63
+ }
64
+ return out;
65
+ };
66
+ for (const mult of [[1, 2, 5], [1, 3], [1]]) {
67
+ const t = gen(mult);
68
+ if (t.length <= cap) { const dense = gen([1, 2, 3, 5]); return t.length < 4 && dense.length <= cap ? dense : t; }
69
+ }
70
+ const t = gen([1]);
71
+ const every = Math.ceil(t.length / cap);
72
+ return t.filter((_, i) => i % every === 0);
73
+ };
74
+ return s;
75
+ }
76
+
77
+ /** Dominio log ajustado a la secuencia 1-2-5 (sin décadas vacías). */
78
+ export function logDomain(min, max) {
79
+ const steps = [1, 2, 5, 10];
80
+ 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
+ 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; };
82
+ const lo = down(Math.max(min, 1e-12)), hi = up(Math.max(max, 1e-12));
83
+ return { min: lo, max: hi === lo ? lo * 10 : hi };
84
+ }
85
+
86
+ /** Escala de bandas para categorías. */
87
+ export function band(domain, r0, r1, { inner = 0.3, outer = 0.15 } = {}) {
88
+ const n = domain.length;
89
+ const step = (r1 - r0) / Math.max(1, n - inner + outer * 2);
90
+ const bw = step * (1 - inner);
91
+ const start = r0 + step * outer;
92
+ const idx = new Map(domain.map((d, i) => [d, i]));
93
+ const s = (v) => start + (idx.get(v) ?? 0) * step;
94
+ s.bandwidth = bw;
95
+ s.step = step;
96
+ s.domain = domain;
97
+ s.center = (v) => s(v) + bw / 2;
98
+ return s;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Tiempo
103
+
104
+ const DAY = 864e5;
105
+ const UNITS = [
106
+ ['day', 1, DAY],
107
+ ['day', 7, 7 * DAY],
108
+ ['month', 1, 30.44 * DAY],
109
+ ['month', 3, 91.3 * DAY],
110
+ ['month', 6, 182.6 * DAY],
111
+ ['year', 1, 365.25 * DAY],
112
+ ['year', 2, 730.5 * DAY],
113
+ ['year', 5, 1826 * DAY],
114
+ ['year', 10, 3652 * DAY],
115
+ ['year', 25, 9131 * DAY],
116
+ ['year', 50, 18262 * DAY],
117
+ ];
118
+
119
+ /** Marcas de tiempo legibles: elige la unidad que da ≤ `count` marcas. */
120
+ export function timeTicks(t0, t1, count = 6, locale = 'es') {
121
+ const span = Math.max(1, t1 - t0);
122
+ let [unit, every] = UNITS.find(([, , ms]) => span / ms <= count) || UNITS[UNITS.length - 1];
123
+ const out = [];
124
+ const d = new Date(t0);
125
+ let cur;
126
+ if (unit === 'year') {
127
+ const y = Math.ceil(d.getUTCFullYear() / every) * every;
128
+ cur = Date.UTC(y, 0, 1);
129
+ if (cur < t0) cur = Date.UTC(y + every, 0, 1);
130
+ while (cur <= t1) {
131
+ out.push(cur);
132
+ cur = Date.UTC(new Date(cur).getUTCFullYear() + every, 0, 1);
133
+ }
134
+ } else if (unit === 'month') {
135
+ let y = d.getUTCFullYear(), m = Math.ceil(d.getUTCMonth() / every) * every;
136
+ cur = Date.UTC(y, m, 1);
137
+ if (cur < t0) cur = Date.UTC(y, m + every, 1);
138
+ while (cur <= t1) {
139
+ out.push(cur);
140
+ const c = new Date(cur);
141
+ cur = Date.UTC(c.getUTCFullYear(), c.getUTCMonth() + every, 1);
142
+ }
143
+ } else {
144
+ cur = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
145
+ if (cur < t0) cur += DAY;
146
+ while (cur <= t1) {
147
+ out.push(cur);
148
+ cur += every * DAY;
149
+ }
150
+ }
151
+ const label = (t, i) => {
152
+ const dt = new Date(t);
153
+ if (unit === 'year') return formatDate(t, 'year', locale);
154
+ if (unit === 'month') {
155
+ const first = i === 0 || dt.getUTCMonth() < every;
156
+ if (every === 3) return formatDate(t, 'quarter', locale, first);
157
+ return formatDate(t, 'month', locale, first);
158
+ }
159
+ const prev = i > 0 ? new Date(out[i - 1]) : null;
160
+ return formatDate(t, 'day', locale, !prev || prev.getUTCFullYear() !== dt.getUTCFullYear());
161
+ };
162
+ return { ticks: out.map((t, i) => ({ value: t, label: label(t, i) })), unit, every };
163
+ }
164
+
165
+ /** Unidad natural de una serie de fechas (para tooltips). */
166
+ export function dateUnit(times) {
167
+ const ds = times.map((t) => new Date(t));
168
+ if (ds.every((d) => d.getUTCMonth() === 0 && d.getUTCDate() === 1)) return 'year';
169
+ if (ds.every((d) => d.getUTCDate() === 1)) return 'month';
170
+ return 'day';
171
+ }