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