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.
- package/AGENTS.md +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- 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:
|
|
30
|
-
.pa-play{flex:none;width:
|
|
31
|
-
.pa-play
|
|
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-
|
|
34
|
-
.pa-
|
|
35
|
-
.pa-
|
|
36
|
-
.pa-
|
|
37
|
-
.pa-track
|
|
38
|
-
.pa-track input[type=range]
|
|
39
|
-
.pa-
|
|
40
|
-
.pa-
|
|
41
|
-
.pa-
|
|
42
|
-
.pa-
|
|
43
|
-
.pa-
|
|
44
|
-
.pa-
|
|
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, '<')}</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
|
-
|
|
118
|
-
n.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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;
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
305
|
+
function stop() { clearInterval(timer); timer = null; if (play) setIcon(play, 'play'); }
|
|
306
|
+
setUI(i);
|
|
179
307
|
}
|
|
180
308
|
|
|
181
|
-
// ── Rango
|
|
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 <
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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(
|
|
229
|
-
|
|
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 ===
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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="
|
|
303
|
-
: '<svg width="
|
|
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
|
-
/**
|
|
14
|
-
export
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|