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/interact.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// Capa de interacción para el navegador: tooltip por marca, crosshair en
|
|
2
|
+
// líneas, leyenda conectada y, en mapas, contorno al pasar, zoom con clic,
|
|
3
|
+
// Ctrl/⌘ + rueda, pellizco y arrastre, con botones +/−/restablecer.
|
|
4
|
+
// Autocontenida (sin imports) para poder incrustarla en HTML.
|
|
5
|
+
|
|
6
|
+
export function attach(root) {
|
|
7
|
+
if (!root || root.__pa) return;
|
|
8
|
+
root.__pa = true;
|
|
9
|
+
const svg = root.querySelector('svg.pa-chart');
|
|
10
|
+
if (!svg) return;
|
|
11
|
+
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
|
|
12
|
+
const dark = svg.getAttribute('data-pa-mode') === 'dark';
|
|
13
|
+
const tipEl = document.createElement('div');
|
|
14
|
+
tipEl.setAttribute('role', 'tooltip');
|
|
15
|
+
tipEl.id = 'pa-tip-' + Math.random().toString(36).slice(2, 8);
|
|
16
|
+
// Tooltip como tarjeta: fondo del tema (o `theme.tooltip`), cabecera del color de la marca.
|
|
17
|
+
const custom = (svg.getAttribute('data-pa-tip') || '').split(',');
|
|
18
|
+
const tipBg = custom[0] || (dark ? '#151A3D' : '#FFFFFF');
|
|
19
|
+
const tipInk = custom[1] || (dark ? '#F2F3FA' : '#141A3F');
|
|
20
|
+
tipEl.style.cssText = [
|
|
21
|
+
'position:absolute', 'pointer-events:none', 'z-index:10', 'opacity:0', 'transition:opacity .12s',
|
|
22
|
+
'padding:8px 10px', 'border-radius:8px', 'font:12.5px/1.45 ' + (svg.getAttribute('font-family') || 'system-ui'),
|
|
23
|
+
'width:max-content', 'max-width:280px', 'overflow:hidden', 'overflow-wrap:anywhere', 'font-variant-numeric:tabular-nums',
|
|
24
|
+
`background:${tipBg};color:${tipInk}`,
|
|
25
|
+
dark ? 'border:1px solid rgba(242,243,250,.14)' : 'border:1px solid rgba(20,26,63,.10)',
|
|
26
|
+
'box-shadow:0 8px 28px rgba(11,15,43,' + (dark ? '.45' : '.16') + ')',
|
|
27
|
+
].join(';');
|
|
28
|
+
root.appendChild(tipEl);
|
|
29
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
30
|
+
const overlay = document.createElementNS(NS, 'g');
|
|
31
|
+
overlay.setAttribute('pointer-events', 'none');
|
|
32
|
+
svg.appendChild(overlay);
|
|
33
|
+
let on = null;
|
|
34
|
+
let pinned = null;
|
|
35
|
+
|
|
36
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
37
|
+
// Colores que llegan desde atributos de datos: solo hex o rgb() (nada que rompa el HTML).
|
|
38
|
+
const col = (c) => (/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\))$/i.test(String(c || '').trim()) ? String(c).trim() : '#888888');
|
|
39
|
+
// Posición: junto a la marca (no al cursor), nunca sobre el título ni la
|
|
40
|
+
// bajada, y dentro del gráfico. Prueba arriba, derecha, izquierda y abajo
|
|
41
|
+
// (en barras horizontales, derecha primero) y conserva el lado anterior
|
|
42
|
+
// mientras siga sirviendo, para que no salte.
|
|
43
|
+
let lastSide = null;
|
|
44
|
+
const headerBottom = (r) => {
|
|
45
|
+
const t = +svg.getAttribute('data-pa-top');
|
|
46
|
+
const m = svg.getScreenCTM && svg.getScreenCTM();
|
|
47
|
+
return t && m ? t * m.d + m.f - r.top : 0;
|
|
48
|
+
};
|
|
49
|
+
const show = (html, ev, anchor, order) => {
|
|
50
|
+
tipEl.innerHTML = html;
|
|
51
|
+
tipEl.style.opacity = '1';
|
|
52
|
+
const r = root.getBoundingClientRect();
|
|
53
|
+
const w = tipEl.offsetWidth, h = tipEl.offsetHeight, G = 10, M = 4;
|
|
54
|
+
const cx = ev.clientX - r.left, cy = ev.clientY - r.top;
|
|
55
|
+
let a = null;
|
|
56
|
+
if (anchor && anchor.l !== undefined) a = anchor;
|
|
57
|
+
else if (anchor && anchor.getBoundingClientRect) {
|
|
58
|
+
const b = anchor.getBoundingClientRect(), sb = svg.getBoundingClientRect();
|
|
59
|
+
// Marcas grandes (celdas de treemap, regiones, sectores): mejor el puntero.
|
|
60
|
+
if (b.width > 0 && b.width * b.height < 0.2 * sb.width * sb.height) a = { l: b.left - r.left, r: b.right - r.left, t: b.top - r.top, b: b.bottom - r.top };
|
|
61
|
+
}
|
|
62
|
+
if (!a) a = { l: cx - 8, r: cx + 8, t: cy - 8, b: cy + 8 };
|
|
63
|
+
const minY = Math.min(Math.max(M, headerBottom(r) + M), Math.max(M, r.height - h - M));
|
|
64
|
+
const maxY = Math.max(minY, r.height - h - M), maxX = Math.max(M, r.width - w - M);
|
|
65
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
66
|
+
const hx = clamp(cx, a.l, a.r) - w / 2, vy = clamp(cy, a.t, a.b) - h / 2;
|
|
67
|
+
const C = {
|
|
68
|
+
top: [clamp(hx, M, maxX), a.t - G - h, 'y'],
|
|
69
|
+
bottom: [clamp(hx, M, maxX), a.b + G, 'y'],
|
|
70
|
+
right: [a.r + G, clamp(vy, minY, maxY), 'x'],
|
|
71
|
+
left: [a.l - G - w, clamp(vy, minY, maxY), 'x'],
|
|
72
|
+
};
|
|
73
|
+
const ok = ([x, y]) => x >= M - 0.5 && x <= maxX + 0.5 && y >= minY - 0.5 && y <= maxY + 0.5;
|
|
74
|
+
const sides = order || (a.r - a.l > a.b - a.t && a.r - a.l > 40 ? ['right', 'top', 'left', 'bottom'] : ['top', 'right', 'left', 'bottom']);
|
|
75
|
+
const pick = [lastSide, ...sides].find((sd) => sd && C[sd] && ok(C[sd]));
|
|
76
|
+
let x, y;
|
|
77
|
+
if (pick) { [x, y] = C[pick]; lastSide = pick; }
|
|
78
|
+
else { x = clamp(cx + 14, M, maxX); y = clamp(cy + 14, minY, maxY); lastSide = null; }
|
|
79
|
+
tipEl.style.left = x + 'px';
|
|
80
|
+
tipEl.style.top = y + 'px';
|
|
81
|
+
};
|
|
82
|
+
const type = svg.getAttribute('data-pa-type') || '';
|
|
83
|
+
const isMap = type === 'choropleth' || type === 'symbolmap' || type === 'pointmap';
|
|
84
|
+
const zoomG = svg.querySelector('.pa-zoom');
|
|
85
|
+
const mapOverlay = document.createElementNS(NS, 'g');
|
|
86
|
+
mapOverlay.setAttribute('pointer-events', 'none');
|
|
87
|
+
if (zoomG) zoomG.appendChild(mapOverlay);
|
|
88
|
+
// Red: al pasar sobre un nodo se encienden sus vecinos y sus aristas (como
|
|
89
|
+
// emphasis.focus: 'adjacency' de ECharts); el resto se apaga.
|
|
90
|
+
let extraOn = [];
|
|
91
|
+
const adjacency = (node) => {
|
|
92
|
+
extraOn.forEach((n) => n.classList.remove('pa-on'));
|
|
93
|
+
extraOn = [];
|
|
94
|
+
const id = node && node.getAttribute && node.getAttribute('data-node');
|
|
95
|
+
const net = id !== null && id !== undefined && node.closest && node.closest('.pa-net');
|
|
96
|
+
if (!net) return;
|
|
97
|
+
const near = new Set([id]);
|
|
98
|
+
for (const e of net.querySelectorAll('.pa-links [data-s]')) {
|
|
99
|
+
const a = e.getAttribute('data-s'), b = e.getAttribute('data-t');
|
|
100
|
+
if (a === id || b === id) { near.add(a); near.add(b); e.classList.add('pa-on'); extraOn.push(e); }
|
|
101
|
+
}
|
|
102
|
+
for (const c of net.querySelectorAll('.pa-nodes [data-node]')) if (near.has(c.getAttribute('data-node')) && c !== node) { c.classList.add('pa-on'); extraOn.push(c); }
|
|
103
|
+
};
|
|
104
|
+
const hide = () => {
|
|
105
|
+
extraOn.forEach((n) => n.classList.remove('pa-on'));
|
|
106
|
+
extraOn = [];
|
|
107
|
+
tipEl.style.opacity = '0';
|
|
108
|
+
if (on) on.classList.remove('pa-on');
|
|
109
|
+
on = null;
|
|
110
|
+
overlay.innerHTML = '';
|
|
111
|
+
mapOverlay.innerHTML = '';
|
|
112
|
+
if (!pinned) svg.classList.remove('pa-focus');
|
|
113
|
+
};
|
|
114
|
+
// Cabecera del tooltip con el color de la marca de fondo y texto blanco u
|
|
115
|
+
// oscuro según el contraste (≥ 4,5:1; si ninguno llega, se ajusta el fondo).
|
|
116
|
+
const rgb = (h) => { h = String(h).replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); const n = parseInt(h.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
|
117
|
+
const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
118
|
+
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
119
|
+
const hex = (a) => '#' + a.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
120
|
+
const toHex = (c) => {
|
|
121
|
+
c = String(c || '').trim();
|
|
122
|
+
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c)) return c;
|
|
123
|
+
const m = /^rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/i.exec(c);
|
|
124
|
+
return m ? hex([+m[1], +m[2], +m[3]]) : null;
|
|
125
|
+
};
|
|
126
|
+
const headerInk = (c) => {
|
|
127
|
+
let bg = toHex(c);
|
|
128
|
+
if (!bg) return null;
|
|
129
|
+
const DARK = '#141A3F', LIGHT = '#FFFFFF';
|
|
130
|
+
const fg = ratio(LIGHT, bg) >= ratio(DARK, bg) ? LIGHT : DARK;
|
|
131
|
+
const to = fg === LIGHT ? [0, 0, 0] : [255, 255, 255];
|
|
132
|
+
const A = rgb(bg);
|
|
133
|
+
for (let t = 0.05; ratio(fg, bg) < 4.5 && t <= 1.001; t += 0.05) bg = hex(A.map((v, i) => v + (to[i] - v) * t));
|
|
134
|
+
return { bg, fg };
|
|
135
|
+
};
|
|
136
|
+
const markColor = (node) => {
|
|
137
|
+
if (!node) return null;
|
|
138
|
+
const dc = node.getAttribute && (node.getAttribute('data-color') || (node.querySelector('[data-color]') || { getAttribute: () => null }).getAttribute('data-color'));
|
|
139
|
+
if (dc && /^#/.test(dc)) return dc;
|
|
140
|
+
const cands = [node, ...node.querySelectorAll('[fill],[stroke]')];
|
|
141
|
+
for (const n of cands) {
|
|
142
|
+
const f = n.getAttribute && n.getAttribute('fill');
|
|
143
|
+
if (f && /^#/.test(f)) return f;
|
|
144
|
+
}
|
|
145
|
+
for (const n of cands) {
|
|
146
|
+
const st = n.getAttribute && n.getAttribute('stroke');
|
|
147
|
+
if (st && /^#/.test(st) && st.toLowerCase() !== (dark ? '#0b0f2b' : '#fcfbf9')) return st;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
const linesHtml = (txt, color) => {
|
|
152
|
+
const hd = headerInk(color);
|
|
153
|
+
return String(txt).split('\n').map((l, i) => {
|
|
154
|
+
if (i === 0) return hd
|
|
155
|
+
? `<div style="margin:-8px -10px 6px;padding:6px 10px;background:${hd.bg};color:${hd.fg};font-weight:600">${esc(l)}</div>`
|
|
156
|
+
: `<div style="font-weight:600;margin-bottom:2px">${esc(l)}</div>`;
|
|
157
|
+
// "Etiqueta: valor" → etiqueta a la izquierda y valor en negrita a la derecha.
|
|
158
|
+
const m = /^(.{1,40}?):\s+(.+)$/.exec(l);
|
|
159
|
+
return m
|
|
160
|
+
? `<div style="display:flex;gap:12px;justify-content:space-between"><span style="opacity:.78">${esc(m[1])}</span><b style="font-weight:600">${esc(m[2])}</b></div>`
|
|
161
|
+
: `<div style="opacity:.86">${esc(l)}</div>`;
|
|
162
|
+
}).join('');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const toSvg = (ev) => {
|
|
166
|
+
const pt = svg.createSVGPoint();
|
|
167
|
+
pt.x = ev.clientX; pt.y = ev.clientY;
|
|
168
|
+
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
svg.addEventListener('pointermove', (ev) => {
|
|
172
|
+
const t = ev.target;
|
|
173
|
+
if (t && t.closest && t.closest('.pa-key')) return; // la leyenda maneja su propio foco
|
|
174
|
+
if (t && t.classList && t.classList.contains('pa-cross')) {
|
|
175
|
+
const data = JSON.parse(t.getAttribute('data-cross'));
|
|
176
|
+
const p = toSvg(ev);
|
|
177
|
+
let best = data[0];
|
|
178
|
+
for (const d of data) if (Math.abs(d.x - p.x) < Math.abs(best.x - p.x)) best = d;
|
|
179
|
+
const y0 = +t.getAttribute('data-y0'), y1 = +t.getAttribute('data-y1');
|
|
180
|
+
overlay.innerHTML =
|
|
181
|
+
`<line x1="${+best.x}" x2="${+best.x}" y1="${y0}" y2="${y1}" stroke="${dark ? '#8A90B4' : '#7C819C'}" stroke-width="1"/>` +
|
|
182
|
+
best.v.map((v) => `<circle cx="${+best.x}" cy="${+v[2]}" r="4.5" fill="${col(v[1])}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`).join('');
|
|
183
|
+
const rows = [...best.v].sort((a, b) => a[2] - b[2]).map((v) => `<div style="display:flex;gap:8px;align-items:center"><span style="width:8px;height:8px;border-radius:50%;background:${col(v[1])}"></span><span style="opacity:.86;flex:1">${esc(v[0])}</span><b style="font-weight:600;margin-left:12px">${esc(v[3])}</b></div>`).join('');
|
|
184
|
+
const m = svg.getScreenCTM(), rr = root.getBoundingClientRect();
|
|
185
|
+
const lx = m ? best.x * m.a + m.e - rr.left : ev.clientX - rr.left, ly = ev.clientY - rr.top;
|
|
186
|
+
show(`<div style="font-weight:600;margin-bottom:4px">${esc(best.t)}</div>${rows}`, ev, { l: lx - 6, r: lx + 6, t: ly - 4, b: ly + 4 }, ['right', 'left', 'top', 'bottom']);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (t && t.classList && t.classList.contains('pa-near')) {
|
|
190
|
+
// Capa densa: busca el punto más cercano (≤ 14 px).
|
|
191
|
+
const pts = t.__near || (t.__near = JSON.parse(t.getAttribute('data-near')));
|
|
192
|
+
const p = toSvg(ev);
|
|
193
|
+
let best = null, bd = 196;
|
|
194
|
+
for (const q of pts) { const d = (q[0] - p.x) ** 2 + (q[1] - p.y) ** 2; if (d < bd) { bd = d; best = q; } }
|
|
195
|
+
if (!best) { hide(); return; }
|
|
196
|
+
overlay.innerHTML = `<circle cx="${+best[0]}" cy="${+best[1]}" r="6" fill="${col(best[3])}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`;
|
|
197
|
+
if (best[2]) { const m = svg.getScreenCTM(), rr = root.getBoundingClientRect(); const px = best[0] * m.a + m.e - rr.left, py = best[1] * m.d + m.f - rr.top; show(linesHtml(best[2], best[3]), ev, { l: px - 6, r: px + 6, t: py - 6, b: py + 6 }); } else tipEl.style.opacity = '0';
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const tg = t && t.closest ? t.closest('[data-tip]') : null;
|
|
201
|
+
if (!tg) { hide(); return; }
|
|
202
|
+
const mark = tg.classList.contains('pa-m') ? tg : tg.querySelector('.pa-m') && tg.querySelectorAll('.pa-m').length === 1 ? tg.querySelector('.pa-m') : null;
|
|
203
|
+
if (mark !== on) {
|
|
204
|
+
if (on) on.classList.remove('pa-on');
|
|
205
|
+
on = mark;
|
|
206
|
+
if (isMap) {
|
|
207
|
+
// Mapas: sin atenuar el resto; un contorno marcado sobre la región (o su círculo).
|
|
208
|
+
mapOverlay.innerHTML = '';
|
|
209
|
+
if (on && on.tagName === 'path') mapOverlay.innerHTML = `<path d="${esc(on.getAttribute('d'))}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`;
|
|
210
|
+
else if (on && on.tagName === 'circle') mapOverlay.innerHTML = `<circle cx="${on.getAttribute('cx')}" cy="${on.getAttribute('cy')}" r="${on.getAttribute('r')}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" vector-effect="non-scaling-stroke"/>`;
|
|
211
|
+
} else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); adjacency(on); } else svg.classList.remove('pa-focus');
|
|
212
|
+
}
|
|
213
|
+
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev, mark || tg);
|
|
214
|
+
});
|
|
215
|
+
// Táctil: un toque muestra el tooltip y lo deja abierto hasta otro toque fuera.
|
|
216
|
+
let touchTip = false;
|
|
217
|
+
svg.addEventListener('pointerdown', (ev) => {
|
|
218
|
+
if (ev.pointerType !== 'touch') return;
|
|
219
|
+
const tg = ev.target && ev.target.closest ? ev.target.closest('[data-tip]') : null;
|
|
220
|
+
if (!tg) { touchTip = false; hide(); return; }
|
|
221
|
+
touchTip = true;
|
|
222
|
+
const mark = tg.classList.contains('pa-m') ? tg : null;
|
|
223
|
+
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev, mark || tg);
|
|
224
|
+
});
|
|
225
|
+
svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'touch' && touchTip) return; hide(); });
|
|
226
|
+
|
|
227
|
+
// Teclado: Tab entra al gráfico; flechas recorren las marcas con datos,
|
|
228
|
+
// Home/End van al extremo y Esc cierra. El tooltip se ancla a la marca.
|
|
229
|
+
const lang = svg.getAttribute('data-pa-lang') === 'en';
|
|
230
|
+
svg.setAttribute('role', 'graphics-document');
|
|
231
|
+
svg.setAttribute('aria-roledescription', lang ? 'chart' : 'gráfico');
|
|
232
|
+
if (!svg.hasAttribute('tabindex')) svg.setAttribute('tabindex', '0');
|
|
233
|
+
const live = document.createElement('div');
|
|
234
|
+
live.setAttribute('aria-live', 'polite');
|
|
235
|
+
live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap';
|
|
236
|
+
root.appendChild(live);
|
|
237
|
+
let kIdx = -1;
|
|
238
|
+
const kMarks = () => [...svg.querySelectorAll('.pa-body [data-tip]')].filter((n) => !n.parentElement.closest('[data-tip]') && n.getAttribute('data-tip'));
|
|
239
|
+
svg.addEventListener('keydown', (ev) => {
|
|
240
|
+
if (ev.target !== svg) return;
|
|
241
|
+
if (ev.key === 'Escape') { kIdx = -1; hide(); return; }
|
|
242
|
+
const d = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : ev.key === 'Home' ? -1e9 : ev.key === 'End' ? 1e9 : 0;
|
|
243
|
+
if (!d) return;
|
|
244
|
+
const ms = kMarks();
|
|
245
|
+
if (!ms.length) return;
|
|
246
|
+
ev.preventDefault();
|
|
247
|
+
kIdx = Math.max(0, Math.min(ms.length - 1, kIdx < 0 ? (d > 0 ? 0 : ms.length - 1) : kIdx + d));
|
|
248
|
+
const tg = ms[kIdx];
|
|
249
|
+
const b = tg.getBoundingClientRect();
|
|
250
|
+
const mark = tg.classList.contains('pa-m') ? tg : tg.querySelector('.pa-m');
|
|
251
|
+
if (on) on.classList.remove('pa-on');
|
|
252
|
+
on = mark;
|
|
253
|
+
if (!isMap && on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); }
|
|
254
|
+
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), { clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }, tg);
|
|
255
|
+
live.textContent = tg.getAttribute('data-tip').replace(/\n/g, '. ');
|
|
256
|
+
});
|
|
257
|
+
svg.addEventListener('blur', () => { kIdx = -1; hide(); });
|
|
258
|
+
|
|
259
|
+
// Leyenda conectada: pasar sobre una clave destaca sus marcas (mismo color);
|
|
260
|
+
// un clic la deja fija, otro clic la suelta.
|
|
261
|
+
const norm = (c) => String(c || '').trim().toLowerCase();
|
|
262
|
+
const body = svg.querySelector('.pa-body');
|
|
263
|
+
const marksOf = (color) => {
|
|
264
|
+
const c = norm(color);
|
|
265
|
+
const out = new Set();
|
|
266
|
+
if (!body) return out;
|
|
267
|
+
// Clave "punteado": todas las formas marcadas con data-dashed.
|
|
268
|
+
if (c === 'dashed') { for (const n of body.querySelectorAll('[data-dashed]')) out.add(n.closest('.pa-m') || n); return out; }
|
|
269
|
+
for (const n of body.querySelectorAll('[fill],[stroke],[data-color]')) {
|
|
270
|
+
if (n.closest('.pa-legend')) continue;
|
|
271
|
+
if (norm(n.getAttribute('fill')) === c || norm(n.getAttribute('stroke')) === c || norm(n.getAttribute('data-color')) === c) {
|
|
272
|
+
const m = n.closest('.pa-m') || n;
|
|
273
|
+
out.add(m);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
};
|
|
278
|
+
const focusColor = (color) => {
|
|
279
|
+
svg.querySelectorAll('.pa-m.pa-on').forEach((n) => n.classList.remove('pa-on'));
|
|
280
|
+
svg.querySelectorAll('.pa-key').forEach((k) => (k.style.opacity = color && norm(k.getAttribute('data-color')) !== norm(color) ? '0.4' : ''));
|
|
281
|
+
if (!color) { svg.classList.remove('pa-focus'); return; }
|
|
282
|
+
const ms = marksOf(color);
|
|
283
|
+
// Marcas sin clase pa-m (p. ej. etiquetas) no se atenúan: solo las que tienen datos.
|
|
284
|
+
for (const n of body ? body.querySelectorAll('[fill],[stroke],[data-color]') : []) if (ms.has(n) && !n.classList.contains('pa-m')) n.classList.add('pa-m');
|
|
285
|
+
ms.forEach((m) => m.classList.add('pa-on'));
|
|
286
|
+
svg.classList.add('pa-focus');
|
|
287
|
+
};
|
|
288
|
+
for (const k of svg.querySelectorAll('.pa-key')) {
|
|
289
|
+
k.setAttribute('tabindex', '0');
|
|
290
|
+
k.setAttribute('role', 'button');
|
|
291
|
+
k.addEventListener('pointerenter', () => { if (!pinned) focusColor(k.getAttribute('data-color')); });
|
|
292
|
+
k.addEventListener('pointerleave', () => { if (!pinned) focusColor(null); });
|
|
293
|
+
const toggle = (e) => {
|
|
294
|
+
// render() puede tomar el clic (zoom en treemap, shift + clic = excluir).
|
|
295
|
+
if (root.__paLegend && root.__paLegend(k, e || {})) return;
|
|
296
|
+
pinned = pinned === k ? null : k; focusColor(pinned ? k.getAttribute('data-color') : null);
|
|
297
|
+
};
|
|
298
|
+
k.addEventListener('click', toggle);
|
|
299
|
+
k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (zoomG) mapZoom(root, svg, zoomG, { dark, hide });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Zoom y paneo de mapas (buenas prácticas de chartjs-plugin-zoom: la rueda
|
|
307
|
+
* sola desplaza la página; el zoom pide Ctrl/⌘, para no secuestrar el scroll).
|
|
308
|
+
* - clic en una región: acerca hasta encuadrarla; otro clic (o Esc, o doble
|
|
309
|
+
* clic, o el botón) vuelve a la vista completa.
|
|
310
|
+
* - Ctrl/⌘ + rueda: zoom alrededor del puntero. Pellizco en pantallas táctiles.
|
|
311
|
+
* - arrastre con zoom > 1: desplaza. Botones +, − y restablecer (teclado).
|
|
312
|
+
* El estado sobrevive a los redibujos (slider temporal) en root.__paZoom.
|
|
313
|
+
*/
|
|
314
|
+
function mapZoom(root, svg, z, { dark, hide }) {
|
|
315
|
+
const [bx, by, bw, bh] = z.getAttribute('data-zoom').split(',').map(Number);
|
|
316
|
+
const en = svg.getAttribute('data-pa-lang') === 'en';
|
|
317
|
+
const L = en ? { in: 'Zoom in', out: 'Zoom out', reset: 'Reset zoom', hint: 'Use Ctrl + scroll to zoom the map' } : { in: 'Acercar', out: 'Alejar', reset: 'Restablecer zoom', hint: 'Usa Ctrl + rueda para hacer zoom en el mapa' };
|
|
318
|
+
const MAX = 14;
|
|
319
|
+
// El zoom anterior solo vale si el mapa tiene el mismo cuadro (no tras cambiar el ancho).
|
|
320
|
+
let st = root.__paZoom && root.__paZoom.box === String([bx, by, bw, bh]) ? root.__paZoom : { k: 1, x: 0, y: 0, key: null };
|
|
321
|
+
let raf = 0;
|
|
322
|
+
const clamp = (s) => {
|
|
323
|
+
const k = Math.max(1, Math.min(MAX, s.k));
|
|
324
|
+
// Sin dejar el mapa fuera del cuadro: el desplazamiento se limita al borde.
|
|
325
|
+
const minX = bx + bw - (bx + bw) * k, maxX = bx - bx * k;
|
|
326
|
+
const minY = by + bh - (by + bh) * k, maxY = by - by * k;
|
|
327
|
+
return { ...s, k, x: Math.min(maxX, Math.max(minX, s.x)), y: Math.min(maxY, Math.max(minY, s.y)) };
|
|
328
|
+
};
|
|
329
|
+
const apply = (s) => {
|
|
330
|
+
z.setAttribute('transform', `translate(${s.x.toFixed(2)},${s.y.toFixed(2)}) scale(${s.k.toFixed(4)})`);
|
|
331
|
+
svg.classList.toggle('pa-zoomed', s.k > 1.02);
|
|
332
|
+
svg.style.touchAction = s.k > 1.02 ? 'none' : 'pan-x pan-y';
|
|
333
|
+
z.style.cursor = s.k > 1.02 ? 'grab' : '';
|
|
334
|
+
btnReset.hidden = s.k <= 1.02;
|
|
335
|
+
};
|
|
336
|
+
const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
337
|
+
const go = (target, animate = true) => {
|
|
338
|
+
const to = clamp(target);
|
|
339
|
+
cancelAnimationFrame(raf);
|
|
340
|
+
const from = { ...st };
|
|
341
|
+
st = to; root.__paZoom = { ...st, box: String([bx, by, bw, bh]) };
|
|
342
|
+
if (!animate || reduced) { apply(to); return; }
|
|
343
|
+
const t0 = performance.now(), D = 520;
|
|
344
|
+
const step = (now) => {
|
|
345
|
+
const t = Math.min(1, (now - t0) / D), e = 1 - (1 - t) ** 3;
|
|
346
|
+
// Interpola en escala logarítmica: el zoom se siente parejo.
|
|
347
|
+
const k = Math.exp(Math.log(from.k) + (Math.log(to.k) - Math.log(from.k)) * e);
|
|
348
|
+
const cx = (bx + bw / 2 - from.x) / from.k + ((bx + bw / 2 - to.x) / to.k - (bx + bw / 2 - from.x) / from.k) * e;
|
|
349
|
+
const cy = (by + bh / 2 - from.y) / from.k + ((by + bh / 2 - to.y) / to.k - (by + bh / 2 - from.y) / from.k) * e;
|
|
350
|
+
apply({ k, x: bx + bw / 2 - cx * k, y: by + bh / 2 - cy * k });
|
|
351
|
+
if (t < 1) raf = requestAnimationFrame(step); else apply(to);
|
|
352
|
+
};
|
|
353
|
+
raf = requestAnimationFrame(step);
|
|
354
|
+
};
|
|
355
|
+
const zoomAt = (k, px, py, animate) => {
|
|
356
|
+
// Mantiene fijo el punto (px, py) del SVG mientras cambia la escala.
|
|
357
|
+
const lx = (px - st.x) / st.k, ly = (py - st.y) / st.k;
|
|
358
|
+
go({ ...st, k, x: px - lx * k, y: py - ly * k, key: null }, animate);
|
|
359
|
+
};
|
|
360
|
+
const reset = () => go({ k: 1, x: 0, y: 0, key: null });
|
|
361
|
+
const toSvg = (cx, cy) => { const p = svg.createSVGPoint(); p.x = cx; p.y = cy; return p.matrixTransform(svg.getScreenCTM().inverse()); };
|
|
362
|
+
|
|
363
|
+
// Controles: + / − / restablecer, arriba a la derecha del mapa.
|
|
364
|
+
const box = document.createElement('div');
|
|
365
|
+
box.className = 'pa-zoomctl';
|
|
366
|
+
const fg = dark ? '#F2F3FA' : '#141A3F', bg = dark ? 'rgba(20,26,63,.92)' : 'rgba(252,251,249,.94)', bd = dark ? 'rgba(242,243,250,.22)' : 'rgba(20,26,63,.16)';
|
|
367
|
+
box.style.cssText = `position:absolute;display:flex;gap:4px;z-index:5;opacity:.0;transition:opacity .15s`;
|
|
368
|
+
const mk = (label, svgIcon) => {
|
|
369
|
+
const b = document.createElement('button');
|
|
370
|
+
b.type = 'button'; b.setAttribute('aria-label', label); b.title = label;
|
|
371
|
+
// Tamaño fijo en línea: hojas como `figure svg{width:100%}` no deben estirar el ícono.
|
|
372
|
+
b.innerHTML = svgIcon.replace('<svg ', '<svg style="width:12px;height:12px;display:block;flex:none" ');
|
|
373
|
+
b.style.cssText = `width:28px;height:28px;display:grid;place-items:center;padding:0;border-radius:7px;border:1px solid ${bd};background:${bg};color:${fg};cursor:pointer;box-shadow:0 1px 3px rgba(11,15,43,.12)`;
|
|
374
|
+
box.appendChild(b);
|
|
375
|
+
return b;
|
|
376
|
+
};
|
|
377
|
+
const btnIn = mk(L.in, '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>');
|
|
378
|
+
const btnOut = mk(L.out, '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>');
|
|
379
|
+
const btnReset = mk(L.reset, '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2.5 6a3.5 3.5 0 1 0 1.1-2.55M2.5 1.8v2.2h2.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
|
380
|
+
const hint = document.createElement('div');
|
|
381
|
+
hint.style.cssText = `position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:8px 12px;border-radius:8px;background:${dark ? '#F2F3FA' : '#141A3F'};color:${dark ? '#141A3F' : '#FCFBF9'};font:12.5px/1.4 ${svg.getAttribute('font-family') || 'system-ui'};pointer-events:none;opacity:0;transition:opacity .2s;z-index:6`;
|
|
382
|
+
hint.textContent = L.hint;
|
|
383
|
+
root.appendChild(box); root.appendChild(hint);
|
|
384
|
+
svg.style.userSelect = 'none';
|
|
385
|
+
svg.style.webkitUserSelect = 'none';
|
|
386
|
+
const place = () => {
|
|
387
|
+
const r = svg.getBoundingClientRect(), rr = root.getBoundingClientRect();
|
|
388
|
+
const s = r.width / (svg.viewBox.baseVal.width || r.width);
|
|
389
|
+
box.style.left = (r.left - rr.left + (bx + bw) * s - 3 * 32) + 'px';
|
|
390
|
+
box.style.top = (r.top - rr.top + by * s + 4) + 'px';
|
|
391
|
+
};
|
|
392
|
+
place();
|
|
393
|
+
root.addEventListener('pointerenter', () => { place(); box.style.opacity = '1'; });
|
|
394
|
+
root.addEventListener('pointerleave', () => { box.style.opacity = st.k > 1.02 ? '1' : '0'; });
|
|
395
|
+
root.addEventListener('focusin', () => { place(); box.style.opacity = '1'; });
|
|
396
|
+
const center = () => [bx + bw / 2, by + bh / 2];
|
|
397
|
+
btnIn.addEventListener('click', () => zoomAt(st.k * 1.6, ...center(), true));
|
|
398
|
+
btnOut.addEventListener('click', () => zoomAt(st.k / 1.6, ...center(), true));
|
|
399
|
+
btnReset.addEventListener('click', reset);
|
|
400
|
+
apply(st);
|
|
401
|
+
|
|
402
|
+
// Clic en una región: encuadrarla (otro clic en la misma, volver).
|
|
403
|
+
let moved = false;
|
|
404
|
+
svg.addEventListener('click', (ev) => {
|
|
405
|
+
if (moved) { moved = false; return; }
|
|
406
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom'))) return;
|
|
407
|
+
const m = ev.target.closest && ev.target.closest('.pa-m');
|
|
408
|
+
if (!m || !m.getBBox) return;
|
|
409
|
+
const key = m.getAttribute('data-k') || m.getAttribute('d') || m.getAttribute('cx');
|
|
410
|
+
if (st.key === key) { reset(); return; }
|
|
411
|
+
const b = m.getBBox();
|
|
412
|
+
const k = Math.max(1.4, Math.min(MAX, 0.62 * Math.min(bw / Math.max(b.width, 4), bh / Math.max(b.height, 4))));
|
|
413
|
+
const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
|
|
414
|
+
hide();
|
|
415
|
+
go({ k, x: bx + bw / 2 - cx * k, y: by + bh / 2 - cy * k, key });
|
|
416
|
+
});
|
|
417
|
+
svg.addEventListener('dblclick', (ev) => { if (ev.target.closest && ev.target.closest('.pa-zoom')) { ev.preventDefault(); reset(); } });
|
|
418
|
+
const onKey = (e) => { if (e.key === 'Escape' && st.k > 1.02 && root.isConnected) reset(); };
|
|
419
|
+
if (root.__paKey) document.removeEventListener('keydown', root.__paKey);
|
|
420
|
+
root.__paKey = onKey;
|
|
421
|
+
document.addEventListener('keydown', onKey);
|
|
422
|
+
|
|
423
|
+
// Ctrl/⌘ + rueda (o gesto de trackpad, que llega con ctrlKey): zoom en el puntero.
|
|
424
|
+
let hintT = 0;
|
|
425
|
+
svg.addEventListener('wheel', (ev) => {
|
|
426
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom-clip'))) return;
|
|
427
|
+
if (!ev.ctrlKey && !ev.metaKey) {
|
|
428
|
+
if (st.k > 1.02) return;
|
|
429
|
+
hint.style.opacity = '1'; clearTimeout(hintT); hintT = setTimeout(() => (hint.style.opacity = '0'), 1100);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
ev.preventDefault();
|
|
433
|
+
const p = toSvg(ev.clientX, ev.clientY);
|
|
434
|
+
zoomAt(st.k * Math.exp(-ev.deltaY * 0.004), p.x, p.y, false);
|
|
435
|
+
}, { passive: false });
|
|
436
|
+
|
|
437
|
+
// Arrastre (zoom > 1) y pellizco (dos dedos).
|
|
438
|
+
const ptrs = new Map();
|
|
439
|
+
let drag = null, pinch = null;
|
|
440
|
+
svg.addEventListener('pointerdown', (ev) => {
|
|
441
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom-clip'))) return;
|
|
442
|
+
moved = false;
|
|
443
|
+
ptrs.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
444
|
+
if (ptrs.size === 2) {
|
|
445
|
+
const [a, b] = [...ptrs.values()];
|
|
446
|
+
const mid = toSvg((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
447
|
+
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: st.k, mid };
|
|
448
|
+
drag = null;
|
|
449
|
+
} else if (st.k > 1.02) {
|
|
450
|
+
ev.preventDefault(); // sin selección de texto al arrastrar
|
|
451
|
+
drag = { x: ev.clientX, y: ev.clientY, sx: st.x, sy: st.y };
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
svg.addEventListener('pointermove', (ev) => {
|
|
455
|
+
if (!ptrs.has(ev.pointerId)) return;
|
|
456
|
+
ptrs.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
457
|
+
const s = (svg.viewBox.baseVal.width || 1) / svg.getBoundingClientRect().width;
|
|
458
|
+
if (pinch && ptrs.size === 2) {
|
|
459
|
+
const [a, b] = [...ptrs.values()];
|
|
460
|
+
zoomAt(pinch.k * Math.hypot(a.x - b.x, a.y - b.y) / (pinch.d || 1), pinch.mid.x, pinch.mid.y, false);
|
|
461
|
+
moved = true;
|
|
462
|
+
} else if (drag) {
|
|
463
|
+
const dx = (ev.clientX - drag.x) * s, dy = (ev.clientY - drag.y) * s;
|
|
464
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) {
|
|
465
|
+
moved = true;
|
|
466
|
+
z.style.cursor = 'grabbing';
|
|
467
|
+
if (!svg.hasPointerCapture(ev.pointerId)) svg.setPointerCapture(ev.pointerId);
|
|
468
|
+
go({ ...st, x: drag.sx + dx, y: drag.sy + dy, key: null }, false);
|
|
469
|
+
hide();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
const up = (ev) => {
|
|
474
|
+
ptrs.delete(ev.pointerId);
|
|
475
|
+
if (ptrs.size < 2) pinch = null;
|
|
476
|
+
if (!ptrs.size) { drag = null; z.style.cursor = st.k > 1.02 ? 'grab' : ''; }
|
|
477
|
+
};
|
|
478
|
+
svg.addEventListener('pointerup', up);
|
|
479
|
+
svg.addEventListener('pointercancel', up);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Fuente autocontenida (toHTML sin bundle): attach más su ayudante de zoom.
|
|
483
|
+
export const attachSource = `(function(){${mapZoom.toString()}\nreturn ${attach.toString()};})()`;
|