plotagents 0.0.0-stage → 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.
- package/AGENTS.md +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
import { isGeoUrl } from './geo/registry.js';
|
|
10
|
+
|
|
11
|
+
let plotFn = null;
|
|
12
|
+
let resolveFn = null;
|
|
13
|
+
/** index.js registra plot/resolve aquí (evita una importación circular). */
|
|
14
|
+
export function _bind(plot, resolve) { plotFn = plot; resolveFn = resolve; }
|
|
15
|
+
|
|
16
|
+
const RANGE_TYPES = new Set(['line', 'area']);
|
|
17
|
+
const SNAP_EXCLUDE = new Set(['line', 'area', 'ecdf']);
|
|
18
|
+
|
|
19
|
+
function ensureAssets() {
|
|
20
|
+
if (typeof document === 'undefined') return;
|
|
21
|
+
if (!document.getElementById('pa-font')) {
|
|
22
|
+
const l = document.createElement('link');
|
|
23
|
+
l.id = 'pa-font'; l.rel = 'stylesheet'; l.href = FONT_CSS;
|
|
24
|
+
document.head.appendChild(l);
|
|
25
|
+
}
|
|
26
|
+
if (document.getElementById('pa-runtime-css')) return;
|
|
27
|
+
const st = document.createElement('style');
|
|
28
|
+
st.id = 'pa-runtime-css';
|
|
29
|
+
st.textContent = `
|
|
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}
|
|
35
|
+
.pa-play svg{display:block}
|
|
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}
|
|
58
|
+
.pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
|
|
59
|
+
@media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}}`;
|
|
60
|
+
document.head.appendChild(st);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dibuja `spec` en un elemento (o selector) del navegador.
|
|
65
|
+
* Devuelve { svg, spec, update(spec), destroy() }.
|
|
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, '<')}</p>`;
|
|
96
|
+
throw e;
|
|
97
|
+
});
|
|
98
|
+
return handle;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function render(target, spec, opts = {}) {
|
|
102
|
+
const host = typeof target === 'string' ? document.querySelector(target) : target;
|
|
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);
|
|
105
|
+
ensureAssets();
|
|
106
|
+
let state = resolveFn ? resolveFn(spec) : { ...spec };
|
|
107
|
+
// Fuente propia (configure({ font }) o spec.font): se carga una sola vez.
|
|
108
|
+
const fontUrl = themeFor(state).font?.import;
|
|
109
|
+
if (fontUrl && typeof document !== 'undefined' && !document.querySelector(`link[href="${fontUrl}"]`)) {
|
|
110
|
+
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = fontUrl; document.head.appendChild(l);
|
|
111
|
+
}
|
|
112
|
+
host.innerHTML = '';
|
|
113
|
+
const chart = document.createElement('div');
|
|
114
|
+
const ctl = document.createElement('div');
|
|
115
|
+
host.appendChild(chart);
|
|
116
|
+
host.appendChild(ctl);
|
|
117
|
+
let timer = null, ro = null, raf = 0;
|
|
118
|
+
|
|
119
|
+
const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
|
|
120
|
+
// Posiciones anteriores de los nodos (para animar el cambio de foco).
|
|
121
|
+
const nodePos = () => {
|
|
122
|
+
const m = new Map();
|
|
123
|
+
for (const c of chart.querySelectorAll('circle[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
|
|
124
|
+
return m;
|
|
125
|
+
};
|
|
126
|
+
const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
127
|
+
const animateFrom = (before) => {
|
|
128
|
+
if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
|
|
129
|
+
const ease = 'cubic-bezier(.2,.7,.2,1)';
|
|
130
|
+
for (const c of chart.querySelectorAll('circle[data-node]')) {
|
|
131
|
+
const old = before.get(c.getAttribute('data-node'));
|
|
132
|
+
if (!old) continue;
|
|
133
|
+
const dx = old[0] - +c.getAttribute('cx'), dy = old[1] - +c.getAttribute('cy');
|
|
134
|
+
c.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: 'translate(0,0)' }], { duration: 650, easing: ease });
|
|
135
|
+
}
|
|
136
|
+
for (const sel of ['.pa-links', '.pa-labels']) {
|
|
137
|
+
const n = chart.querySelector(sel);
|
|
138
|
+
if (n) n.animate([{ opacity: 0 }, { opacity: 0 }, { opacity: 1 }], { duration: 750, easing: 'ease-out' });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Colores anteriores de las regiones (mapas): al mover el slider, el color
|
|
142
|
+
// de cada región transita al nuevo en vez de saltar.
|
|
143
|
+
const regionFills = () => {
|
|
144
|
+
const m = new Map();
|
|
145
|
+
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); }
|
|
146
|
+
return m;
|
|
147
|
+
};
|
|
148
|
+
const tweenFills = (before) => {
|
|
149
|
+
if (reduced || !before.size || typeof Element === 'undefined' || !Element.prototype.animate) return;
|
|
150
|
+
for (const n of chart.querySelectorAll('.pa-regions [data-k]')) {
|
|
151
|
+
const old = before.get(n.getAttribute('data-k')), now = n.getAttribute('fill');
|
|
152
|
+
if (old && /^#/.test(now || '') && old.toLowerCase() !== now.toLowerCase()) n.animate([{ fill: old }, { fill: now }], { duration: 420, easing: 'ease-out' });
|
|
153
|
+
}
|
|
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
|
+
};
|
|
183
|
+
const draw = () => {
|
|
184
|
+
const before = nodePos();
|
|
185
|
+
const fills = regionFills();
|
|
186
|
+
chart.innerHTML = plotFn({ ...state, width: width() });
|
|
187
|
+
animateFrom(before);
|
|
188
|
+
tweenFills(fills);
|
|
189
|
+
chart.__pa = false;
|
|
190
|
+
chart.__paLegend = onLegend;
|
|
191
|
+
attach(chart);
|
|
192
|
+
const svgNode = chart.querySelector('svg');
|
|
193
|
+
if (svgNode) svgNode.classList.add('pa-live');
|
|
194
|
+
// Red: clic en un nodo lo lleva al centro y destaca sus conexiones; otro clic (o Esc) vuelve.
|
|
195
|
+
for (const n of chart.querySelectorAll('[data-node]')) {
|
|
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
|
+
}
|
|
202
|
+
const id = n.getAttribute('data-node');
|
|
203
|
+
const go = () => { state = { ...state, focus: String(state.focus) === id ? undefined : id }; draw(); };
|
|
204
|
+
n.addEventListener('click', go);
|
|
205
|
+
n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
|
206
|
+
}
|
|
207
|
+
for (const n of chart.querySelectorAll('[data-drill]')) {
|
|
208
|
+
n.setAttribute('tabindex', '0');
|
|
209
|
+
n.setAttribute('role', 'button');
|
|
210
|
+
const go = () => { state = { ...state, focus: JSON.parse(n.getAttribute('data-drill')) }; draw(); };
|
|
211
|
+
n.addEventListener('click', go);
|
|
212
|
+
n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
|
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
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
|
|
237
|
+
|
|
238
|
+
const theme = themeFor(state);
|
|
239
|
+
ctl.className = 'pa-ctl';
|
|
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, "'")}`;
|
|
241
|
+
ctl.hidden = true;
|
|
242
|
+
|
|
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);
|
|
248
|
+
else if (RANGE_TYPES.has(state.type)) buildRange();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Periodo único: botones o slider, con un play sobrio ─────────────────
|
|
252
|
+
function buildPeriod(mode) {
|
|
253
|
+
const vals = timeValuesOf(state);
|
|
254
|
+
if (vals.length < 2) return;
|
|
255
|
+
let i = vals.findIndex((v) => String(v) === String(state.timeValue ?? vals[vals.length - 1]));
|
|
256
|
+
if (i < 0) i = vals.length - 1;
|
|
257
|
+
ctl.hidden = false;
|
|
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
|
+
}
|
|
294
|
+
const set = (k) => {
|
|
295
|
+
i = k; setUI(k);
|
|
296
|
+
state = { ...state, timeValue: vals[k] };
|
|
297
|
+
schedule();
|
|
298
|
+
};
|
|
299
|
+
if (play) play.addEventListener('click', () => {
|
|
300
|
+
if (timer) return stop();
|
|
301
|
+
if (i >= vals.length - 1) set(0);
|
|
302
|
+
setIcon(play, 'pause');
|
|
303
|
+
timer = setInterval(() => { if (i >= vals.length - 1) return stop(); set(i + 1); }, state.playInterval ?? 1200);
|
|
304
|
+
});
|
|
305
|
+
function stop() { clearInterval(timer); timer = null; if (play) setIcon(play, 'play'); }
|
|
306
|
+
setUI(i);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Rango (series de tiempo): dos tiradores sobre una línea fina ────────
|
|
310
|
+
function buildRange() {
|
|
311
|
+
const data = ensureData(state);
|
|
312
|
+
const xf = state.x;
|
|
313
|
+
if (!xf || !data.length || !(xf in data[0])) return;
|
|
314
|
+
const kind = fieldKind(data, xf);
|
|
315
|
+
if (!['date', 'year', 'number'].includes(kind)) return;
|
|
316
|
+
const raw = uniq(data.map((r) => r[xf]));
|
|
317
|
+
const val = (v) => (kind === 'date' ? parseDate(v) : num(v));
|
|
318
|
+
const xs = raw.map((v) => ({ v, t: val(v) })).filter((p) => Number.isFinite(p.t)).sort((a, b) => a.t - b.t);
|
|
319
|
+
if (xs.length < 3) return;
|
|
320
|
+
ctl.hidden = false;
|
|
321
|
+
const fmtX = (p) => (kind === 'date' ? formatDate(p.t, dateUnit(xs.map((q) => q.t)), state.locale || 'es') : String(p.v));
|
|
322
|
+
let lo = 0, hi = xs.length - 1;
|
|
323
|
+
if (state.xRange) {
|
|
324
|
+
const a = xs.findIndex((p) => p.t >= val(state.xRange[0])), b = xs.map((p) => p.t <= val(state.xRange[1])).lastIndexOf(true);
|
|
325
|
+
if (a >= 0) lo = a; if (b >= 0) hi = b;
|
|
326
|
+
}
|
|
327
|
+
const track = document.createElement('div'); track.className = 'pa-track pa-dual';
|
|
328
|
+
const a = range(0, xs.length - 1, lo, 'Desde'), b = range(0, xs.length - 1, hi, 'Hasta');
|
|
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);
|
|
333
|
+
const now = document.createElement('span'); now.className = 'pa-now';
|
|
334
|
+
ctl.append(track, now);
|
|
335
|
+
const n = xs.length - 1;
|
|
336
|
+
const set = () => {
|
|
337
|
+
let i0 = +a.value, i1 = +b.value;
|
|
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 + '%');
|
|
341
|
+
now.textContent = `${fmtX(xs[i0])} – ${fmtX(xs[i1])}`;
|
|
342
|
+
state = { ...state, xRange: i0 === 0 && i1 === n ? undefined : [xs[i0].v, xs[i1].v] };
|
|
343
|
+
schedule();
|
|
344
|
+
};
|
|
345
|
+
a.addEventListener('input', set); b.addEventListener('input', set);
|
|
346
|
+
set();
|
|
347
|
+
}
|
|
348
|
+
|
|
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
|
+
});
|
|
356
|
+
|
|
357
|
+
draw();
|
|
358
|
+
if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
|
|
359
|
+
let last = host.clientWidth;
|
|
360
|
+
ro = new ResizeObserver(() => { if (Math.abs(host.clientWidth - last) > 8) { last = host.clientWidth; schedule(); } });
|
|
361
|
+
ro.observe(host);
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
get svg() { return chart.querySelector('svg'); },
|
|
365
|
+
get spec() { return state; },
|
|
366
|
+
update(next) { clearInterval(timer); ro && ro.disconnect(); return render(host, next, opts); },
|
|
367
|
+
destroy() { clearInterval(timer); ro && ro.disconnect(); host.innerHTML = ''; },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
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
|
+
|
|
386
|
+
function timeValuesOf(s) {
|
|
387
|
+
const data = ensureData(s);
|
|
388
|
+
const k = fieldKind(data, s.time);
|
|
389
|
+
return uniq(data.map((r) => r[s.time]).filter((v) => v !== null && v !== undefined && v !== ''))
|
|
390
|
+
.sort((a, b) => (k === 'date' ? parseDate(a) - parseDate(b) : k === 'year' || k === 'number' ? num(a) - num(b) : 0));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function svgEl(tag, attrs) {
|
|
394
|
+
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
395
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
|
396
|
+
return n;
|
|
397
|
+
}
|
|
398
|
+
function range(min, max, value, label) {
|
|
399
|
+
const i = document.createElement('input');
|
|
400
|
+
i.type = 'range'; i.min = min; i.max = max; i.step = 1; i.value = value;
|
|
401
|
+
i.setAttribute('aria-label', label);
|
|
402
|
+
return i;
|
|
403
|
+
}
|
|
404
|
+
function button() {
|
|
405
|
+
const b = document.createElement('button');
|
|
406
|
+
b.type = 'button'; b.className = 'pa-play';
|
|
407
|
+
setIcon(b, 'play');
|
|
408
|
+
return b;
|
|
409
|
+
}
|
|
410
|
+
function setIcon(b, kind) {
|
|
411
|
+
b.setAttribute('aria-label', kind === 'play' ? 'Reproducir' : 'Pausar');
|
|
412
|
+
b.setAttribute('aria-pressed', kind === 'play' ? 'false' : 'true');
|
|
413
|
+
b.innerHTML = kind === 'play'
|
|
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>';
|
|
416
|
+
}
|
package/src/scale.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
/** 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];
|
|
30
|
+
if (min === max) {
|
|
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;
|
|
33
|
+
min -= pad;
|
|
34
|
+
max += pad;
|
|
35
|
+
}
|
|
36
|
+
let step = st(max - min);
|
|
37
|
+
let lo = Math.floor(min / step + 1e-9) * step;
|
|
38
|
+
let hi = Math.ceil(max / step - 1e-9) * step;
|
|
39
|
+
step = st(hi - lo);
|
|
40
|
+
lo = Math.floor(min / step + 1e-9) * step;
|
|
41
|
+
hi = Math.ceil(max / step - 1e-9) * step;
|
|
42
|
+
return { min: lo, max: hi, step };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function linear(d0, d1, r0, r1) {
|
|
46
|
+
const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);
|
|
47
|
+
const s = (v) => r0 + (v - d0) * k;
|
|
48
|
+
s.invert = (p) => (k === 0 ? d0 : d0 + (p - r0) / k);
|
|
49
|
+
s.domain = [d0, d1];
|
|
50
|
+
s.range = [r0, r1];
|
|
51
|
+
s.ticks = (step) => {
|
|
52
|
+
const out = [];
|
|
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));
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function log(d0, d1, r0, r1) {
|
|
67
|
+
const lo = Math.log10(Math.max(d0, 1e-12));
|
|
68
|
+
const hi = Math.log10(Math.max(d1, 1e-12));
|
|
69
|
+
const inner = linear(lo, hi, r0, r1);
|
|
70
|
+
const s = (v) => inner(Math.log10(Math.max(v, 1e-12)));
|
|
71
|
+
s.invert = (p) => 10 ** inner.invert(p);
|
|
72
|
+
s.domain = [d0, d1];
|
|
73
|
+
s.range = [r0, r1];
|
|
74
|
+
// Marcas 1-2-5 cuando hay pocas décadas; solo potencias de 10 si hay muchas.
|
|
75
|
+
s.ticks = (max = 7) => {
|
|
76
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [];
|
|
77
|
+
const cap = Number.isInteger(max) && max >= 2 ? max : 7;
|
|
78
|
+
const gen = (mult) => {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let e = Math.floor(lo) - 1; e <= Math.ceil(hi); e++)
|
|
81
|
+
for (const k of mult) {
|
|
82
|
+
const v = +(k * 10 ** e).toPrecision(12);
|
|
83
|
+
if (v >= d0 * 0.999 && v <= d1 * 1.001) out.push(v);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
for (const mult of [[1, 2, 5], [1, 3], [1]]) {
|
|
88
|
+
const t = gen(mult);
|
|
89
|
+
if (t.length <= cap) { const dense = gen([1, 2, 3, 5]); return t.length < 4 && dense.length <= cap ? dense : t; }
|
|
90
|
+
}
|
|
91
|
+
const t = gen([1]);
|
|
92
|
+
const every = Math.ceil(t.length / cap);
|
|
93
|
+
return t.filter((_, i) => i % every === 0);
|
|
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
|
+
};
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Dominio log ajustado a la secuencia 1-2-5 (sin décadas vacías). */
|
|
108
|
+
export function logDomain(min, max) {
|
|
109
|
+
const steps = [1, 2, 5, 10];
|
|
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; };
|
|
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;
|
|
114
|
+
const lo = down(Math.max(min, 1e-12)), hi = up(Math.max(max, 1e-12));
|
|
115
|
+
return { min: lo, max: hi === lo ? lo * 10 : hi };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Escala de bandas para categorías. */
|
|
119
|
+
export function band(domain, r0, r1, { inner = 0.3, outer = 0.15 } = {}) {
|
|
120
|
+
const n = domain.length;
|
|
121
|
+
const step = (r1 - r0) / Math.max(1, n - inner + outer * 2);
|
|
122
|
+
const bw = step * (1 - inner);
|
|
123
|
+
const start = r0 + step * outer;
|
|
124
|
+
const idx = new Map(domain.map((d, i) => [d, i]));
|
|
125
|
+
const s = (v) => start + (idx.get(v) ?? 0) * step;
|
|
126
|
+
s.bandwidth = bw;
|
|
127
|
+
s.step = step;
|
|
128
|
+
s.domain = domain;
|
|
129
|
+
s.center = (v) => s(v) + bw / 2;
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Tiempo
|
|
135
|
+
|
|
136
|
+
const MIN = 6e4, HOUR = 36e5, DAY = 864e5;
|
|
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],
|
|
146
|
+
['day', 1, DAY],
|
|
147
|
+
['day', 2, 2 * DAY],
|
|
148
|
+
['day', 3, 3 * DAY],
|
|
149
|
+
['day', 7, 7 * DAY],
|
|
150
|
+
['month', 1, 30.44 * DAY],
|
|
151
|
+
['month', 3, 91.3 * DAY],
|
|
152
|
+
['month', 6, 182.6 * DAY],
|
|
153
|
+
['year', 1, 365.25 * DAY],
|
|
154
|
+
['year', 2, 730.5 * DAY],
|
|
155
|
+
['year', 5, 1826 * DAY],
|
|
156
|
+
['year', 10, 3652 * DAY],
|
|
157
|
+
['year', 25, 9131 * DAY],
|
|
158
|
+
['year', 50, 18262 * DAY],
|
|
159
|
+
['year', 100, 36525 * DAY],
|
|
160
|
+
['year', 250, 91312 * DAY],
|
|
161
|
+
['year', 500, 182625 * DAY],
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/** Marcas de tiempo legibles: elige la unidad que da ≤ `count` marcas. */
|
|
165
|
+
export function timeTicks(t0, t1, count = 6, locale = 'es') {
|
|
166
|
+
if (!Number.isFinite(t0) || !Number.isFinite(t1)) return { ticks: [], unit: 'day', every: 1 };
|
|
167
|
+
const span = Math.max(1, t1 - t0);
|
|
168
|
+
let [unit, every] = UNITS.find(([, , ms]) => span / ms <= count) || UNITS[UNITS.length - 1];
|
|
169
|
+
const out = [];
|
|
170
|
+
const d = new Date(t0);
|
|
171
|
+
let cur;
|
|
172
|
+
if (unit === 'year') {
|
|
173
|
+
const y = Math.ceil(d.getUTCFullYear() / every) * every;
|
|
174
|
+
cur = Date.UTC(y, 0, 1);
|
|
175
|
+
if (cur < t0) cur = Date.UTC(y + every, 0, 1);
|
|
176
|
+
while (cur <= t1) {
|
|
177
|
+
out.push(cur);
|
|
178
|
+
cur = Date.UTC(new Date(cur).getUTCFullYear() + every, 0, 1);
|
|
179
|
+
}
|
|
180
|
+
} else if (unit === 'month') {
|
|
181
|
+
let y = d.getUTCFullYear(), m = Math.ceil(d.getUTCMonth() / every) * every;
|
|
182
|
+
cur = Date.UTC(y, m, 1);
|
|
183
|
+
if (cur < t0) cur = Date.UTC(y, m + every, 1);
|
|
184
|
+
while (cur <= t1) {
|
|
185
|
+
out.push(cur);
|
|
186
|
+
const c = new Date(cur);
|
|
187
|
+
cur = Date.UTC(c.getUTCFullYear(), c.getUTCMonth() + every, 1);
|
|
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
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
cur = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
199
|
+
if (cur < t0) cur += DAY;
|
|
200
|
+
while (cur <= t1) {
|
|
201
|
+
out.push(cur);
|
|
202
|
+
cur += every * DAY;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const dayOf = (t) => Math.floor(t / DAY);
|
|
206
|
+
const label = (t, i) => {
|
|
207
|
+
const dt = new Date(t);
|
|
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
|
+
}
|
|
217
|
+
if (unit === 'month') {
|
|
218
|
+
const first = i === 0 || dt.getUTCMonth() < every;
|
|
219
|
+
if (every === 3) return formatDate(t, 'quarter', locale, first);
|
|
220
|
+
return formatDate(t, 'month', locale, first);
|
|
221
|
+
}
|
|
222
|
+
const prev = i > 0 ? new Date(out[i - 1]) : null;
|
|
223
|
+
return formatDate(t, 'day', locale, !prev || prev.getUTCFullYear() !== dt.getUTCFullYear());
|
|
224
|
+
};
|
|
225
|
+
return { ticks: out.map((t, i) => ({ value: t, label: label(t, i) })), unit, every };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Unidad natural de una serie de fechas (para tooltips). */
|
|
229
|
+
export function dateUnit(times) {
|
|
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';
|
|
233
|
+
if (ds.every((d) => d.getUTCMonth() === 0 && d.getUTCDate() === 1)) return 'year';
|
|
234
|
+
if (ds.every((d) => d.getUTCDate() === 1)) return 'month';
|
|
235
|
+
return 'day';
|
|
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
|
+
}
|