plotagents 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +78 -0
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/bin/plotagents.js +49 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +7954 -0
- package/docs/api.md +154 -0
- package/docs/charts.md +218 -0
- package/docs/getting-started.md +138 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +86 -0
- package/index.d.ts +213 -0
- package/package.json +74 -4
- package/schema.json +895 -0
- package/src/axes.js +144 -0
- package/src/charts/bar.js +339 -0
- package/src/charts/categorical.js +471 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +559 -0
- package/src/charts/dist.js +513 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +97 -0
- package/src/charts/line.js +419 -0
- package/src/charts/network.js +344 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +152 -0
- package/src/charts/regplot.js +364 -0
- package/src/charts/sankey.js +133 -0
- package/src/charts/scatter.js +136 -0
- package/src/charts/sunburst.js +135 -0
- package/src/charts/treemap.js +194 -0
- package/src/context.js +10 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +171 -0
- package/src/frame.js +157 -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 +152 -0
- package/src/geo/registry.js +31 -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 +271 -0
- package/src/infer.js +49 -0
- package/src/interact.js +362 -0
- package/src/lint.js +137 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +304 -0
- package/src/scale.js +171 -0
- package/src/schema.js +181 -0
- package/src/stats.js +246 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +224 -0
package/src/interact.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
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.style.cssText = [
|
|
16
|
+
'position:absolute', 'pointer-events:none', 'z-index:10', 'opacity:0', 'transition:opacity .12s',
|
|
17
|
+
'padding:8px 10px', 'border-radius:8px', 'font:12.5px/1.45 ' + (svg.getAttribute('font-family') || 'system-ui'),
|
|
18
|
+
'white-space:nowrap', 'max-width:280px',
|
|
19
|
+
dark ? 'background:#F2F3FA;color:#141A3F' : 'background:#141A3F;color:#FCFBF9',
|
|
20
|
+
'box-shadow:0 6px 24px rgba(11,15,43,.18)',
|
|
21
|
+
].join(';');
|
|
22
|
+
root.appendChild(tipEl);
|
|
23
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
24
|
+
const overlay = document.createElementNS(NS, 'g');
|
|
25
|
+
overlay.setAttribute('pointer-events', 'none');
|
|
26
|
+
svg.appendChild(overlay);
|
|
27
|
+
let on = null;
|
|
28
|
+
let pinned = null;
|
|
29
|
+
|
|
30
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
31
|
+
const show = (html, ev) => {
|
|
32
|
+
tipEl.innerHTML = html;
|
|
33
|
+
tipEl.style.opacity = '1';
|
|
34
|
+
const r = root.getBoundingClientRect();
|
|
35
|
+
let x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 14;
|
|
36
|
+
const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
|
|
37
|
+
if (x + w > r.width) x = ev.clientX - r.left - w - 14;
|
|
38
|
+
if (y + h > r.height) y = Math.max(0, ev.clientY - r.top - h - 14);
|
|
39
|
+
tipEl.style.left = x + 'px';
|
|
40
|
+
tipEl.style.top = y + 'px';
|
|
41
|
+
};
|
|
42
|
+
const type = svg.getAttribute('data-pa-type') || '';
|
|
43
|
+
const isMap = type === 'choropleth' || type === 'symbolmap';
|
|
44
|
+
const zoomG = svg.querySelector('.pa-zoom');
|
|
45
|
+
const mapOverlay = document.createElementNS(NS, 'g');
|
|
46
|
+
mapOverlay.setAttribute('pointer-events', 'none');
|
|
47
|
+
if (zoomG) zoomG.appendChild(mapOverlay);
|
|
48
|
+
const hide = () => {
|
|
49
|
+
tipEl.style.opacity = '0';
|
|
50
|
+
if (on) on.classList.remove('pa-on');
|
|
51
|
+
on = null;
|
|
52
|
+
overlay.innerHTML = '';
|
|
53
|
+
mapOverlay.innerHTML = '';
|
|
54
|
+
if (!pinned) svg.classList.remove('pa-focus');
|
|
55
|
+
};
|
|
56
|
+
// El título del tooltip hereda el color de la categoría, aclarado u
|
|
57
|
+
// oscurecido hasta leerse bien sobre el fondo del tooltip; el resto, neutro.
|
|
58
|
+
const tipBg = dark ? '#F2F3FA' : '#141A3F';
|
|
59
|
+
const rgb = (h) => { h = String(h).replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); const n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
|
60
|
+
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; };
|
|
61
|
+
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
62
|
+
const hex = (a) => '#' + a.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
63
|
+
const onTip = (c) => {
|
|
64
|
+
if (!/^#[0-9a-f]{3,6}$/i.test(c || '')) return null;
|
|
65
|
+
const to = dark ? [20, 26, 63] : [255, 255, 255];
|
|
66
|
+
const A = rgb(c);
|
|
67
|
+
for (let t = 0; t <= 1.001; t += 0.05) { const m = hex(A.map((v, i) => v + (to[i] - v) * t)); if (ratio(m, tipBg) >= 4.5) return m; }
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const markColor = (node) => {
|
|
71
|
+
if (!node) return null;
|
|
72
|
+
const dc = node.getAttribute && (node.getAttribute('data-color') || (node.querySelector('[data-color]') || { getAttribute: () => null }).getAttribute('data-color'));
|
|
73
|
+
if (dc && /^#/.test(dc)) return dc;
|
|
74
|
+
const cands = [node, ...node.querySelectorAll('[fill],[stroke]')];
|
|
75
|
+
for (const n of cands) {
|
|
76
|
+
const f = n.getAttribute && n.getAttribute('fill');
|
|
77
|
+
if (f && /^#/.test(f)) return f;
|
|
78
|
+
}
|
|
79
|
+
for (const n of cands) {
|
|
80
|
+
const st = n.getAttribute && n.getAttribute('stroke');
|
|
81
|
+
if (st && /^#/.test(st) && st.toLowerCase() !== (dark ? '#0b0f2b' : '#fcfbf9')) return st;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
const linesHtml = (txt, color) => {
|
|
86
|
+
const c = onTip(color);
|
|
87
|
+
return String(txt).split('\n').map((l, i) => (i === 0
|
|
88
|
+
? `<div style="font-weight:600${c ? ';color:' + c : ''}">${c ? `<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};margin-right:6px;vertical-align:1px"></span>` : ''}${esc(l)}</div>`
|
|
89
|
+
: `<div style="opacity:.86">${esc(l)}</div>`)).join('');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const toSvg = (ev) => {
|
|
93
|
+
const pt = svg.createSVGPoint();
|
|
94
|
+
pt.x = ev.clientX; pt.y = ev.clientY;
|
|
95
|
+
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
svg.addEventListener('pointermove', (ev) => {
|
|
99
|
+
const t = ev.target;
|
|
100
|
+
if (t && t.closest && t.closest('.pa-key')) return; // la leyenda maneja su propio foco
|
|
101
|
+
if (t && t.classList && t.classList.contains('pa-cross')) {
|
|
102
|
+
const data = JSON.parse(t.getAttribute('data-cross'));
|
|
103
|
+
const p = toSvg(ev);
|
|
104
|
+
let best = data[0];
|
|
105
|
+
for (const d of data) if (Math.abs(d.x - p.x) < Math.abs(best.x - p.x)) best = d;
|
|
106
|
+
const y0 = +t.getAttribute('data-y0'), y1 = +t.getAttribute('data-y1');
|
|
107
|
+
overlay.innerHTML =
|
|
108
|
+
`<line x1="${best.x}" x2="${best.x}" y1="${y0}" y2="${y1}" stroke="${dark ? '#8A90B4' : '#7C819C'}" stroke-width="1"/>` +
|
|
109
|
+
best.v.map((v) => `<circle cx="${best.x}" cy="${v[2]}" r="4.5" fill="${v[1]}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`).join('');
|
|
110
|
+
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:${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('');
|
|
111
|
+
show(`<div style="font-weight:600;margin-bottom:2px">${esc(best.t)}</div>${rows}`, ev);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (t && t.classList && t.classList.contains('pa-near')) {
|
|
115
|
+
// Capa densa: busca el punto más cercano (≤ 14 px).
|
|
116
|
+
const pts = t.__near || (t.__near = JSON.parse(t.getAttribute('data-near')));
|
|
117
|
+
const p = toSvg(ev);
|
|
118
|
+
let best = null, bd = 196;
|
|
119
|
+
for (const q of pts) { const d = (q[0] - p.x) ** 2 + (q[1] - p.y) ** 2; if (d < bd) { bd = d; best = q; } }
|
|
120
|
+
if (!best) { hide(); return; }
|
|
121
|
+
overlay.innerHTML = `<circle cx="${best[0]}" cy="${best[1]}" r="6" fill="${best[3]}" stroke="${dark ? '#0B0F2B' : '#FCFBF9'}" stroke-width="2"/>`;
|
|
122
|
+
if (best[2]) show(linesHtml(best[2], best[3]), ev); else tipEl.style.opacity = '0';
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const tg = t && t.closest ? t.closest('[data-tip]') : null;
|
|
126
|
+
if (!tg) { hide(); return; }
|
|
127
|
+
const mark = tg.classList.contains('pa-m') ? tg : tg.querySelector('.pa-m') && tg.querySelectorAll('.pa-m').length === 1 ? tg.querySelector('.pa-m') : null;
|
|
128
|
+
if (mark !== on) {
|
|
129
|
+
if (on) on.classList.remove('pa-on');
|
|
130
|
+
on = mark;
|
|
131
|
+
if (isMap) {
|
|
132
|
+
// Mapas: sin atenuar el resto; un contorno marcado sobre la región (o su círculo).
|
|
133
|
+
mapOverlay.innerHTML = '';
|
|
134
|
+
if (on && on.tagName === 'path') mapOverlay.innerHTML = `<path d="${on.getAttribute('d')}" fill="none" stroke="${dark ? '#F2F3FA' : '#141A3F'}" stroke-width="2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`;
|
|
135
|
+
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"/>`;
|
|
136
|
+
} else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); } else svg.classList.remove('pa-focus');
|
|
137
|
+
}
|
|
138
|
+
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev);
|
|
139
|
+
});
|
|
140
|
+
svg.addEventListener('pointerleave', hide);
|
|
141
|
+
|
|
142
|
+
// Leyenda conectada: pasar sobre una clave destaca sus marcas (mismo color);
|
|
143
|
+
// un clic la deja fija, otro clic la suelta.
|
|
144
|
+
const norm = (c) => String(c || '').trim().toLowerCase();
|
|
145
|
+
const body = svg.querySelector('.pa-body');
|
|
146
|
+
const marksOf = (color) => {
|
|
147
|
+
const c = norm(color);
|
|
148
|
+
const out = new Set();
|
|
149
|
+
if (!body) return out;
|
|
150
|
+
// Clave "punteado": todas las formas marcadas con data-dashed.
|
|
151
|
+
if (c === 'dashed') { for (const n of body.querySelectorAll('[data-dashed]')) out.add(n.closest('.pa-m') || n); return out; }
|
|
152
|
+
for (const n of body.querySelectorAll('[fill],[stroke],[data-color]')) {
|
|
153
|
+
if (n.closest('.pa-legend')) continue;
|
|
154
|
+
if (norm(n.getAttribute('fill')) === c || norm(n.getAttribute('stroke')) === c || norm(n.getAttribute('data-color')) === c) {
|
|
155
|
+
const m = n.closest('.pa-m') || n;
|
|
156
|
+
out.add(m);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
const focusColor = (color) => {
|
|
162
|
+
svg.querySelectorAll('.pa-m.pa-on').forEach((n) => n.classList.remove('pa-on'));
|
|
163
|
+
svg.querySelectorAll('.pa-key').forEach((k) => (k.style.opacity = color && norm(k.getAttribute('data-color')) !== norm(color) ? '0.4' : ''));
|
|
164
|
+
if (!color) { svg.classList.remove('pa-focus'); return; }
|
|
165
|
+
const ms = marksOf(color);
|
|
166
|
+
// Marcas sin clase pa-m (p. ej. etiquetas) no se atenúan: solo las que tienen datos.
|
|
167
|
+
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');
|
|
168
|
+
ms.forEach((m) => m.classList.add('pa-on'));
|
|
169
|
+
svg.classList.add('pa-focus');
|
|
170
|
+
};
|
|
171
|
+
for (const k of svg.querySelectorAll('.pa-key')) {
|
|
172
|
+
k.setAttribute('tabindex', '0');
|
|
173
|
+
k.setAttribute('role', 'button');
|
|
174
|
+
k.addEventListener('pointerenter', () => { if (!pinned) focusColor(k.getAttribute('data-color')); });
|
|
175
|
+
k.addEventListener('pointerleave', () => { if (!pinned) focusColor(null); });
|
|
176
|
+
const toggle = () => { pinned = pinned === k ? null : k; focusColor(pinned ? k.getAttribute('data-color') : null); };
|
|
177
|
+
k.addEventListener('click', toggle);
|
|
178
|
+
k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (zoomG) mapZoom(root, svg, zoomG, { dark, hide });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Zoom y paneo de mapas (buenas prácticas de chartjs-plugin-zoom: la rueda
|
|
186
|
+
* sola desplaza la página; el zoom pide Ctrl/⌘, para no secuestrar el scroll).
|
|
187
|
+
* - clic en una región: acerca hasta encuadrarla; otro clic (o Esc, o doble
|
|
188
|
+
* clic, o el botón) vuelve a la vista completa.
|
|
189
|
+
* - Ctrl/⌘ + rueda: zoom alrededor del puntero. Pellizco en pantallas táctiles.
|
|
190
|
+
* - arrastre con zoom > 1: desplaza. Botones +, − y restablecer (teclado).
|
|
191
|
+
* El estado sobrevive a los redibujos (slider temporal) en root.__paZoom.
|
|
192
|
+
*/
|
|
193
|
+
function mapZoom(root, svg, z, { dark, hide }) {
|
|
194
|
+
const [bx, by, bw, bh] = z.getAttribute('data-zoom').split(',').map(Number);
|
|
195
|
+
const en = svg.getAttribute('data-pa-lang') === 'en';
|
|
196
|
+
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' };
|
|
197
|
+
const MAX = 14;
|
|
198
|
+
// El zoom anterior solo vale si el mapa tiene el mismo cuadro (no tras cambiar el ancho).
|
|
199
|
+
let st = root.__paZoom && root.__paZoom.box === String([bx, by, bw, bh]) ? root.__paZoom : { k: 1, x: 0, y: 0, key: null };
|
|
200
|
+
let raf = 0;
|
|
201
|
+
const clamp = (s) => {
|
|
202
|
+
const k = Math.max(1, Math.min(MAX, s.k));
|
|
203
|
+
// Sin dejar el mapa fuera del cuadro: el desplazamiento se limita al borde.
|
|
204
|
+
const minX = bx + bw - (bx + bw) * k, maxX = bx - bx * k;
|
|
205
|
+
const minY = by + bh - (by + bh) * k, maxY = by - by * k;
|
|
206
|
+
return { ...s, k, x: Math.min(maxX, Math.max(minX, s.x)), y: Math.min(maxY, Math.max(minY, s.y)) };
|
|
207
|
+
};
|
|
208
|
+
const apply = (s) => {
|
|
209
|
+
z.setAttribute('transform', `translate(${s.x.toFixed(2)},${s.y.toFixed(2)}) scale(${s.k.toFixed(4)})`);
|
|
210
|
+
svg.classList.toggle('pa-zoomed', s.k > 1.02);
|
|
211
|
+
svg.style.touchAction = s.k > 1.02 ? 'none' : 'pan-x pan-y';
|
|
212
|
+
z.style.cursor = s.k > 1.02 ? 'grab' : '';
|
|
213
|
+
btnReset.hidden = s.k <= 1.02;
|
|
214
|
+
};
|
|
215
|
+
const reduced = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
216
|
+
const go = (target, animate = true) => {
|
|
217
|
+
const to = clamp(target);
|
|
218
|
+
cancelAnimationFrame(raf);
|
|
219
|
+
const from = { ...st };
|
|
220
|
+
st = to; root.__paZoom = { ...st, box: String([bx, by, bw, bh]) };
|
|
221
|
+
if (!animate || reduced) { apply(to); return; }
|
|
222
|
+
const t0 = performance.now(), D = 520;
|
|
223
|
+
const step = (now) => {
|
|
224
|
+
const t = Math.min(1, (now - t0) / D), e = 1 - (1 - t) ** 3;
|
|
225
|
+
// Interpola en escala logarítmica: el zoom se siente parejo.
|
|
226
|
+
const k = Math.exp(Math.log(from.k) + (Math.log(to.k) - Math.log(from.k)) * e);
|
|
227
|
+
const cx = (bx + bw / 2 - from.x) / from.k + ((bx + bw / 2 - to.x) / to.k - (bx + bw / 2 - from.x) / from.k) * e;
|
|
228
|
+
const cy = (by + bh / 2 - from.y) / from.k + ((by + bh / 2 - to.y) / to.k - (by + bh / 2 - from.y) / from.k) * e;
|
|
229
|
+
apply({ k, x: bx + bw / 2 - cx * k, y: by + bh / 2 - cy * k });
|
|
230
|
+
if (t < 1) raf = requestAnimationFrame(step); else apply(to);
|
|
231
|
+
};
|
|
232
|
+
raf = requestAnimationFrame(step);
|
|
233
|
+
};
|
|
234
|
+
const zoomAt = (k, px, py, animate) => {
|
|
235
|
+
// Mantiene fijo el punto (px, py) del SVG mientras cambia la escala.
|
|
236
|
+
const lx = (px - st.x) / st.k, ly = (py - st.y) / st.k;
|
|
237
|
+
go({ ...st, k, x: px - lx * k, y: py - ly * k, key: null }, animate);
|
|
238
|
+
};
|
|
239
|
+
const reset = () => go({ k: 1, x: 0, y: 0, key: null });
|
|
240
|
+
const toSvg = (cx, cy) => { const p = svg.createSVGPoint(); p.x = cx; p.y = cy; return p.matrixTransform(svg.getScreenCTM().inverse()); };
|
|
241
|
+
|
|
242
|
+
// Controles: + / − / restablecer, arriba a la derecha del mapa.
|
|
243
|
+
const box = document.createElement('div');
|
|
244
|
+
box.className = 'pa-zoomctl';
|
|
245
|
+
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)';
|
|
246
|
+
box.style.cssText = `position:absolute;display:flex;gap:4px;z-index:5;opacity:.0;transition:opacity .15s`;
|
|
247
|
+
const mk = (label, svgIcon) => {
|
|
248
|
+
const b = document.createElement('button');
|
|
249
|
+
b.type = 'button'; b.setAttribute('aria-label', label); b.title = label;
|
|
250
|
+
// Tamaño fijo en línea: hojas como `figure svg{width:100%}` no deben estirar el ícono.
|
|
251
|
+
b.innerHTML = svgIcon.replace('<svg ', '<svg style="width:12px;height:12px;display:block;flex:none" ');
|
|
252
|
+
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)`;
|
|
253
|
+
box.appendChild(b);
|
|
254
|
+
return b;
|
|
255
|
+
};
|
|
256
|
+
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>');
|
|
257
|
+
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>');
|
|
258
|
+
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>');
|
|
259
|
+
const hint = document.createElement('div');
|
|
260
|
+
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`;
|
|
261
|
+
hint.textContent = L.hint;
|
|
262
|
+
root.appendChild(box); root.appendChild(hint);
|
|
263
|
+
svg.style.userSelect = 'none';
|
|
264
|
+
svg.style.webkitUserSelect = 'none';
|
|
265
|
+
const place = () => {
|
|
266
|
+
const r = svg.getBoundingClientRect(), rr = root.getBoundingClientRect();
|
|
267
|
+
const s = r.width / (svg.viewBox.baseVal.width || r.width);
|
|
268
|
+
box.style.left = (r.left - rr.left + (bx + bw) * s - 3 * 32) + 'px';
|
|
269
|
+
box.style.top = (r.top - rr.top + by * s + 4) + 'px';
|
|
270
|
+
};
|
|
271
|
+
place();
|
|
272
|
+
root.addEventListener('pointerenter', () => { place(); box.style.opacity = '1'; });
|
|
273
|
+
root.addEventListener('pointerleave', () => { box.style.opacity = st.k > 1.02 ? '1' : '0'; });
|
|
274
|
+
root.addEventListener('focusin', () => { place(); box.style.opacity = '1'; });
|
|
275
|
+
const center = () => [bx + bw / 2, by + bh / 2];
|
|
276
|
+
btnIn.addEventListener('click', () => zoomAt(st.k * 1.6, ...center(), true));
|
|
277
|
+
btnOut.addEventListener('click', () => zoomAt(st.k / 1.6, ...center(), true));
|
|
278
|
+
btnReset.addEventListener('click', reset);
|
|
279
|
+
apply(st);
|
|
280
|
+
|
|
281
|
+
// Clic en una región: encuadrarla (otro clic en la misma, volver).
|
|
282
|
+
let moved = false;
|
|
283
|
+
svg.addEventListener('click', (ev) => {
|
|
284
|
+
if (moved) { moved = false; return; }
|
|
285
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom'))) return;
|
|
286
|
+
const m = ev.target.closest && ev.target.closest('.pa-m');
|
|
287
|
+
if (!m || !m.getBBox) return;
|
|
288
|
+
const key = m.getAttribute('data-k') || m.getAttribute('d') || m.getAttribute('cx');
|
|
289
|
+
if (st.key === key) { reset(); return; }
|
|
290
|
+
const b = m.getBBox();
|
|
291
|
+
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))));
|
|
292
|
+
const cx = b.x + b.width / 2, cy = b.y + b.height / 2;
|
|
293
|
+
hide();
|
|
294
|
+
go({ k, x: bx + bw / 2 - cx * k, y: by + bh / 2 - cy * k, key });
|
|
295
|
+
});
|
|
296
|
+
svg.addEventListener('dblclick', (ev) => { if (ev.target.closest && ev.target.closest('.pa-zoom')) { ev.preventDefault(); reset(); } });
|
|
297
|
+
const onKey = (e) => { if (e.key === 'Escape' && st.k > 1.02 && root.isConnected) reset(); };
|
|
298
|
+
if (root.__paKey) document.removeEventListener('keydown', root.__paKey);
|
|
299
|
+
root.__paKey = onKey;
|
|
300
|
+
document.addEventListener('keydown', onKey);
|
|
301
|
+
|
|
302
|
+
// Ctrl/⌘ + rueda (o gesto de trackpad, que llega con ctrlKey): zoom en el puntero.
|
|
303
|
+
let hintT = 0;
|
|
304
|
+
svg.addEventListener('wheel', (ev) => {
|
|
305
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom-clip'))) return;
|
|
306
|
+
if (!ev.ctrlKey && !ev.metaKey) {
|
|
307
|
+
if (st.k > 1.02) return;
|
|
308
|
+
hint.style.opacity = '1'; clearTimeout(hintT); hintT = setTimeout(() => (hint.style.opacity = '0'), 1100);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
ev.preventDefault();
|
|
312
|
+
const p = toSvg(ev.clientX, ev.clientY);
|
|
313
|
+
zoomAt(st.k * Math.exp(-ev.deltaY * 0.004), p.x, p.y, false);
|
|
314
|
+
}, { passive: false });
|
|
315
|
+
|
|
316
|
+
// Arrastre (zoom > 1) y pellizco (dos dedos).
|
|
317
|
+
const ptrs = new Map();
|
|
318
|
+
let drag = null, pinch = null;
|
|
319
|
+
svg.addEventListener('pointerdown', (ev) => {
|
|
320
|
+
if (!(ev.target.closest && ev.target.closest('.pa-zoom-clip'))) return;
|
|
321
|
+
moved = false;
|
|
322
|
+
ptrs.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
323
|
+
if (ptrs.size === 2) {
|
|
324
|
+
const [a, b] = [...ptrs.values()];
|
|
325
|
+
const mid = toSvg((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
326
|
+
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: st.k, mid };
|
|
327
|
+
drag = null;
|
|
328
|
+
} else if (st.k > 1.02) {
|
|
329
|
+
ev.preventDefault(); // sin selección de texto al arrastrar
|
|
330
|
+
drag = { x: ev.clientX, y: ev.clientY, sx: st.x, sy: st.y };
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
svg.addEventListener('pointermove', (ev) => {
|
|
334
|
+
if (!ptrs.has(ev.pointerId)) return;
|
|
335
|
+
ptrs.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
336
|
+
const s = (svg.viewBox.baseVal.width || 1) / svg.getBoundingClientRect().width;
|
|
337
|
+
if (pinch && ptrs.size === 2) {
|
|
338
|
+
const [a, b] = [...ptrs.values()];
|
|
339
|
+
zoomAt(pinch.k * Math.hypot(a.x - b.x, a.y - b.y) / (pinch.d || 1), pinch.mid.x, pinch.mid.y, false);
|
|
340
|
+
moved = true;
|
|
341
|
+
} else if (drag) {
|
|
342
|
+
const dx = (ev.clientX - drag.x) * s, dy = (ev.clientY - drag.y) * s;
|
|
343
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) {
|
|
344
|
+
moved = true;
|
|
345
|
+
z.style.cursor = 'grabbing';
|
|
346
|
+
if (!svg.hasPointerCapture(ev.pointerId)) svg.setPointerCapture(ev.pointerId);
|
|
347
|
+
go({ ...st, x: drag.sx + dx, y: drag.sy + dy, key: null }, false);
|
|
348
|
+
hide();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
const up = (ev) => {
|
|
353
|
+
ptrs.delete(ev.pointerId);
|
|
354
|
+
if (ptrs.size < 2) pinch = null;
|
|
355
|
+
if (!ptrs.size) { drag = null; z.style.cursor = st.k > 1.02 ? 'grab' : ''; }
|
|
356
|
+
};
|
|
357
|
+
svg.addEventListener('pointerup', up);
|
|
358
|
+
svg.addEventListener('pointercancel', up);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Fuente autocontenida (toHTML sin bundle): attach más su ayudante de zoom.
|
|
362
|
+
export const attachSource = `(function(){${mapZoom.toString()}\nreturn ${attach.toString()};})()`;
|
package/src/lint.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Revisión de un spec ANTES de dibujar: errores que impiden el gráfico y
|
|
2
|
+
// sugerencias de diseño (de los cursos de visualización para la gestión).
|
|
3
|
+
// Pensado para que un agente corrija su propio spec en un segundo intento.
|
|
4
|
+
import { ensureData, fieldsOf, fieldKind, uniq, num, PlotError } from './data.js';
|
|
5
|
+
import { inferType, TYPES, normalizeType } from './infer.js';
|
|
6
|
+
import { validatePalette } from './palette.js';
|
|
7
|
+
import { themeFor } from './theme.js';
|
|
8
|
+
|
|
9
|
+
/** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
|
|
10
|
+
let RENDER_PROBE = null;
|
|
11
|
+
export const _setProbe = (fn) => { RENDER_PROBE = fn; };
|
|
12
|
+
|
|
13
|
+
export function lint(spec = {}) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const tips = [];
|
|
17
|
+
const err = (code, message, fix) => errors.push({ code, message, fix });
|
|
18
|
+
const warn = (code, message, fix) => warnings.push({ code, message, fix });
|
|
19
|
+
const sug = (code, message, fix) => tips.push({ code, message, fix });
|
|
20
|
+
|
|
21
|
+
let data = [];
|
|
22
|
+
try { data = spec.type === 'kpi' || spec.items ? [] : ensureData(spec); } catch (e) { err('data', e.message.split('\n')[0], 'Pasa `data` como arreglo de filas: [{ categoria: "A", valor: 10 }]'); }
|
|
23
|
+
const type = spec.type && spec.type !== 'auto' ? normalizeType(spec.type)[0] : inferType(spec, data);
|
|
24
|
+
if (!TYPES.includes(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')}`);
|
|
25
|
+
const fields = fieldsOf(data);
|
|
26
|
+
const check = (f, role) => {
|
|
27
|
+
for (const ff of [].concat(f ?? [])) if (ff && !fields.includes(ff)) err('field', `El campo "${ff}" (${role}) no existe.`, `Campos disponibles: ${fields.join(', ')}`);
|
|
28
|
+
};
|
|
29
|
+
if (data.length) {
|
|
30
|
+
check(spec.x, 'x'); check(spec.y, 'y'); check(spec.series ?? spec.color, 'series'); check(spec.size, 'size');
|
|
31
|
+
check(spec.value, 'value'); check(spec.label, 'label'); if (!['network', 'flowchart'].includes(type)) check(spec.group, 'group'); check(spec.path, 'path');
|
|
32
|
+
check(spec.from, 'from'); check(spec.to, 'to');
|
|
33
|
+
} else if (type !== 'kpi' && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
|
|
34
|
+
|
|
35
|
+
if (!spec.title) sug('title', 'Falta el título.', 'Escribe el hallazgo como una frase completa: "Biobío concentra el 32% de las ventas", no "Ventas por región".');
|
|
36
|
+
else if (!/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
|
|
37
|
+
if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
|
|
38
|
+
|
|
39
|
+
const series = spec.series ?? spec.color;
|
|
40
|
+
const nSeries = series && fields.includes(series) ? uniq(data.map((r) => String(r[series]))).length : Array.isArray(spec.y) ? spec.y.length : 1;
|
|
41
|
+
if (nSeries > 8) warn('series-max', `${nSeries} series: solo hay 8 colores distinguibles; desde la 9.ª se pintan en gris.`, 'Agrupa la cola en "Otros", usa `highlight` o divide en varios gráficos.');
|
|
42
|
+
|
|
43
|
+
if (type === 'bar') {
|
|
44
|
+
const x = spec.x ?? fields.find((f) => fieldKind(data, f) === 'category');
|
|
45
|
+
const nCat = x && fields.includes(x) ? uniq(data.map((r) => String(r[x]))).length : 0;
|
|
46
|
+
if (nCat === 1 && nSeries === 1) sug('one-bar', 'Una sola barra no compara nada.', 'Usa `type: "kpi"` para mostrar el número.');
|
|
47
|
+
if (nCat > 40 && !spec.top) warn('too-many-bars', `${nCat} categorías: se mostrarán las 25 mayores y el resto como "Otros".`, 'Fija `top: N` para controlar el corte.');
|
|
48
|
+
if (spec.stack && nSeries > 5) sug('stack-many', 'Apilar más de 5 series dificulta comparar los segmentos del medio.', 'Considera barras agrupadas, `highlight` o `stack: "percent"`.');
|
|
49
|
+
if (x && fieldKind(data, x) === 'date' && nCat > 24) sug('bar-time', 'Muchos periodos en barras.', 'Para tendencias largas, `type: "line"` se lee mejor.');
|
|
50
|
+
}
|
|
51
|
+
if (type === 'line' || type === 'area') {
|
|
52
|
+
const x = spec.x;
|
|
53
|
+
if (nSeries > 4 && spec.highlight === undefined) warn('line-many', `${nSeries} líneas: sobre 3-4 el gráfico se vuelve espagueti.`, 'Usa `highlight: "Serie"` para destacar una y atenuar el resto, o separa en varios gráficos.');
|
|
54
|
+
if (x && fields.includes(x) && fieldKind(data, x) === 'category' && !/^(q|t)\d|^\d/i.test(String(data[0]?.[x]))) sug('line-nominal', 'El eje x tiene categorías sin orden: las líneas sugieren continuidad.', 'Para comparar categorías usa `type: "bar"`.');
|
|
55
|
+
const pts = x && fields.includes(x) ? uniq(data.map((r) => String(r[x]))).length : 0;
|
|
56
|
+
if (pts && pts < 3) sug('line-few', 'Menos de 3 puntos en el tiempo.', 'Con 2 periodos, barras agrupadas o un `kpi` con variación comunican mejor.');
|
|
57
|
+
if (Array.isArray(spec.y) && spec.y.length === 2 && !spec.index) {
|
|
58
|
+
const [a, b] = spec.y.map((f) => Math.max(...data.map((r) => Math.abs(num(r[f]))).filter(Number.isFinite)));
|
|
59
|
+
if (a && b && Math.max(a, b) / Math.min(a, b) > 10) warn('dual-scale', 'Las dos medidas tienen escalas muy distintas (no hay doble eje: inventa correlaciones).', 'Usa `index: true` (base 100) o dos gráficos separados.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (type === 'donut') {
|
|
63
|
+
const lf = spec.label ?? spec.x ?? fields.find((f) => fieldKind(data, f) === 'category');
|
|
64
|
+
const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
|
|
65
|
+
const n = lf ? uniq(data.map((r) => String(r[lf]))).length : 0;
|
|
66
|
+
if (n > 6) warn('donut-slices', `${n} segmentos: sobre 6 la dona no se lee; se agrupan en "Otros".`, 'Usa `type: "bar"` o `type: "treemap"`.');
|
|
67
|
+
if (n === 2) sug('donut-two', 'Dos segmentos son un solo número.', 'Usa `type: "kpi"` con el porcentaje.');
|
|
68
|
+
if (vf && n >= 3) {
|
|
69
|
+
const vals = data.map((r) => num(r[vf])).filter((v) => v > 0).sort((a, b) => b - a);
|
|
70
|
+
if (vals.length >= 3 && vals[0] / vals[vals.length - 1] < 1.35) sug('donut-close', 'Los valores son muy parecidos: los ángulos no permiten compararlos.', 'Usa `type: "bar"` ordenado.');
|
|
71
|
+
}
|
|
72
|
+
if (data.some((r) => num(r[vf]) < 0)) err('negative', 'La dona no admite valores negativos.', 'Usa barras.');
|
|
73
|
+
}
|
|
74
|
+
if (type === 'treemap') {
|
|
75
|
+
const vf = spec.value ?? spec.y ?? fields.find((f) => fieldKind(data, f) === 'number');
|
|
76
|
+
if (vf && data.some((r) => num(r[vf]) < 0)) warn('negative', 'Los valores negativos se omiten en el treemap.', 'Usa barras para datos con signo.');
|
|
77
|
+
const gf = spec.group ?? (Array.isArray(spec.path) && spec.path.length > 1 ? spec.path[0] : null);
|
|
78
|
+
if (gf && fields.includes(gf) && uniq(data.map((r) => String(r[gf]))).length > 8) warn('groups-max', 'Más de 8 grupos: desde el 8.º van en gris "Otros".', 'Agrupa categorías o usa otro nivel de jerarquía.');
|
|
79
|
+
}
|
|
80
|
+
if (type === 'scatter' || type === 'bubble') {
|
|
81
|
+
if (nSeries > 3) warn('scatter-colors', `${nSeries} grupos de color en dispersión: solo los 3 primeros colores son distinguibles entre todos los pares para daltonismo.`, 'Usa `highlight`, agrupa o separa en varios gráficos.');
|
|
82
|
+
if (data.length > 2000) sug('overplot', `${data.length} puntos: puede haber sobreposición.`, 'Reduce `radius` o usa un mapa de calor.');
|
|
83
|
+
}
|
|
84
|
+
// Campos de facetas y tiempo
|
|
85
|
+
if (data.length) { check(spec.facet ?? spec.col, 'facet'); check(spec.time, 'time'); check(spec.hue, 'hue'); }
|
|
86
|
+
|
|
87
|
+
// Paleta propia: se valida con las mismas reglas que la de fábrica.
|
|
88
|
+
const custom = spec.palette || (spec.theme && typeof spec.theme === 'object' && spec.theme.categorical);
|
|
89
|
+
if (custom) {
|
|
90
|
+
const th = themeFor(spec);
|
|
91
|
+
const n = Math.max(2, Math.min(th.categorical.length, nSeries));
|
|
92
|
+
const rep = validatePalette(th.categorical.slice(0, n), { mode: th.mode, surface: th.surface, pairs: type === 'scatter' || type === 'bubble' ? 'all' : 'adjacent' });
|
|
93
|
+
for (const c of rep.checks) if (c.status === 'fail') warn('palette-' + c.name, `Paleta: ${c.detail}`, 'Cambia el orden o los tonos, o usa la paleta de fábrica.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const numericIn = (f) => f && fields.includes(f) && ['number', 'year'].includes(fieldKind(data, f));
|
|
97
|
+
if (['hist', 'kde', 'ecdf'].includes(type)) {
|
|
98
|
+
const f = spec.x ?? spec.y;
|
|
99
|
+
if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: ${type} necesita una medida.`, 'Para contar categorías usa `type: "countplot"`.');
|
|
100
|
+
if (data.length < 20) sug('few-obs', `Solo ${data.length} observaciones: la forma de la distribución es poco confiable.`, 'Con pocos datos, `type: "strip"` muestra cada punto.');
|
|
101
|
+
}
|
|
102
|
+
if (['regplot', 'residplot', 'jointplot'].includes(type)) {
|
|
103
|
+
for (const f of [spec.x, spec.y]) if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: la regresión necesita dos medidas.`, 'Usa `type: "box"` para comparar una medida entre categorías.');
|
|
104
|
+
if (data.length < 8) warn('few-obs', `Solo ${data.length} puntos: la recta y su intervalo son poco confiables.`, 'Muestra los puntos con `type: "scatter"`.');
|
|
105
|
+
}
|
|
106
|
+
if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
|
|
109
|
+
const small = [...counts].filter(([, n]) => n < 5).map(([k]) => k);
|
|
110
|
+
if (small.length) sug('few-per-group', `Grupos con menos de 5 datos: ${small.slice(0, 4).join(', ')}.`, '`type: "strip"` o `points: true` muestran cada observación.');
|
|
111
|
+
}
|
|
112
|
+
if (type === 'radar' && nSeries > 4) warn('radar-many', `${nSeries} series en un radar se tapan entre sí.`, 'Usa `highlight`, `facet` o barras agrupadas.');
|
|
113
|
+
if ((type === 'line' || type === 'area') && spec.yScale === 'log' && data.some((r) => num(r[Array.isArray(spec.y) ? spec.y[0] : spec.y]) <= 0)) warn('log-nonpositive', 'Hay valores ≤ 0: la escala log no los puede mostrar.', 'Quita la escala log o filtra esos valores.');
|
|
114
|
+
|
|
115
|
+
if (type === 'choropleth' || type === 'symbolmap') {
|
|
116
|
+
const _unmatched = [];
|
|
117
|
+
try { RENDER_PROBE?.({ ...spec, _unmatched }); } catch { /* el error ya se reporta al dibujar */ }
|
|
118
|
+
if (_unmatched.length) warn('geo-unmatched', `${_unmatched.length} fila(s) no coinciden con el mapa: ${_unmatched.slice(0, 5).join(', ')}.`, 'Revisa los nombres o usa códigos (p. ej. "XIII", "ARG", "TX"), o indica `featureKey`.');
|
|
119
|
+
}
|
|
120
|
+
// Formas punteadas: sin explicación, el rayado no se entiende.
|
|
121
|
+
if ((spec.dashed !== undefined && spec.dashed !== false) || spec.dashedFrom !== undefined) {
|
|
122
|
+
if (spec.dashedLabel === undefined) sug('dashed-label', 'Hay formas punteadas pero no se dice qué significan (la leyenda dirá "Estimado").', 'Agrega `dashedLabel: "Proyección"`, "Cifra preliminar" o lo que corresponda.');
|
|
123
|
+
if (spec.dashedFrom !== undefined && !['line', 'area', 'bar', 'column', 'count'].includes(type)) warn('dashed-from-type', '`dashedFrom` solo aplica a líneas, áreas y columnas con eje de tiempo.', 'Usa `dashed` con una lista de categorías o un campo de los datos.');
|
|
124
|
+
}
|
|
125
|
+
if (type === 'pyramid') {
|
|
126
|
+
const sf = spec.sex ?? spec.series ?? spec.hue;
|
|
127
|
+
if (sf && fields.includes(sf)) { const n = uniq(data.map((r) => String(r[sf]))).length; if (n !== 2) err('pyramid-sides', `La pirámide necesita exactamente 2 grupos en "${sf}" (hay ${n}).`, 'Usa `type: "bar"` con `series` para más grupos.'); }
|
|
128
|
+
}
|
|
129
|
+
if (type === 'chord' && data.length && uniq(data.flatMap((r) => [r[spec.from ?? 'from'], r[spec.to ?? 'to']])).length > 16) warn('chord-many', 'Más de 16 grupos en un diagrama de cuerdas: los arcos se vuelven ilegibles.', 'Agrupa los menores en "Otros" o usa `type: "sankey"`.');
|
|
130
|
+
if (type === 'network' && data.length > 2000) sug('network-big', `${data.length} conexiones: la red puede verse como una mancha.`, 'Filtra las conexiones débiles o usa `layout: "circle"`.');
|
|
131
|
+
|
|
132
|
+
if (type === 'kpi' && !(spec.items || spec.data || []).length) err('items', 'El KPI necesita `items`.', 'items: [{ label: "Ventas", value: 1250, delta: 0.12, period: "vs 2025" }]');
|
|
133
|
+
|
|
134
|
+
return { ok: errors.length === 0, type, errors, warnings, suggestions: tips };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { PlotError };
|
package/src/palette.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Validador de paletas categóricas: lo mismo que se usó para validar las
|
|
2
|
+
// paletas de fábrica, disponible para que un agente revise la suya.
|
|
3
|
+
// Umbrales: banda de luminosidad OKLCH, croma mínimo, separación ΔE (OKLab×100)
|
|
4
|
+
// entre vecinos bajo protanopía/deuteranopía (Machado 2009) y con visión normal.
|
|
5
|
+
|
|
6
|
+
const BAND = { light: [0.43, 0.77], dark: [0.48, 0.67] };
|
|
7
|
+
const CHROMA_FLOOR = 0.1;
|
|
8
|
+
const CVD_TARGET = 8, CVD_FLOOR = 6, NORMAL_FLOOR = 15;
|
|
9
|
+
const MACHADO = {
|
|
10
|
+
protan: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
|
|
11
|
+
deutan: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
15
|
+
const rgbLin = (hex) => { const h = hex.replace('#', ''); const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(lin); };
|
|
16
|
+
function oklab([r, g, b]) {
|
|
17
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
18
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
19
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
20
|
+
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
|
21
|
+
}
|
|
22
|
+
const sim = (rgb, M) => M.map((row) => Math.max(0, Math.min(1, row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2])));
|
|
23
|
+
function dE(a, b, kind) {
|
|
24
|
+
let A = rgbLin(a), B = rgbLin(b);
|
|
25
|
+
if (kind) { A = sim(A, MACHADO[kind]); B = sim(B, MACHADO[kind]); }
|
|
26
|
+
const p = oklab(A), q = oklab(B);
|
|
27
|
+
return 100 * Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
|
|
28
|
+
}
|
|
29
|
+
const lum = (hex) => { const [r, g, b] = rgbLin(hex); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
30
|
+
const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Revisa una paleta categórica. Devuelve { ok, checks: [{ name, status, detail }] }.
|
|
34
|
+
* status: 'pass' | 'warn' | 'fail'. `pairs: 'all'` para dispersión y mapas.
|
|
35
|
+
*/
|
|
36
|
+
export function validatePalette(colors, { mode = 'light', surface, pairs = 'adjacent', locale = 'es' } = {}) {
|
|
37
|
+
const en = String(locale).startsWith('en');
|
|
38
|
+
const pal = colors.map(String);
|
|
39
|
+
surface = surface || (mode === 'dark' ? '#0B0F2B' : '#FCFBF9');
|
|
40
|
+
const checks = [];
|
|
41
|
+
const [lo, hi] = BAND[mode] || BAND.light;
|
|
42
|
+
const Ls = pal.map((c) => oklab(rgbLin(c)));
|
|
43
|
+
const off = pal.filter((c, i) => Ls[i][0] < lo || Ls[i][0] > hi);
|
|
44
|
+
checks.push({ name: 'lightness', status: off.length ? 'fail' : 'pass', detail: off.length ? (en ? `Outside the lightness band ${lo}–${hi}: ` : `Fuera de la banda de luminosidad ${lo}–${hi}: `) + off.join(', ') : en ? 'All inside the lightness band.' : 'Todos dentro de la banda de luminosidad.' });
|
|
45
|
+
const gray = pal.filter((c, i) => Math.hypot(Ls[i][1], Ls[i][2]) < CHROMA_FLOOR);
|
|
46
|
+
checks.push({ name: 'chroma', status: gray.length ? 'fail' : 'pass', detail: gray.length ? (en ? 'Read as gray: ' : 'Se leen como gris: ') + gray.join(', ') : en ? 'All have enough chroma.' : 'Todos con color suficiente.' });
|
|
47
|
+
const list = pairs === 'all' ? pal.flatMap((_, i) => pal.slice(i + 1).map((__, k) => [i, i + 1 + k])) : pal.slice(1).map((_, i) => [i, i + 1]);
|
|
48
|
+
let worst = null, nworst = null;
|
|
49
|
+
for (const [i, j] of list) {
|
|
50
|
+
for (const k of ['protan', 'deutan']) { const d = dE(pal[i], pal[j], k); if (!worst || d < worst[0]) worst = [d, pal[i], pal[j], k]; }
|
|
51
|
+
const n = dE(pal[i], pal[j]);
|
|
52
|
+
if (!nworst || n < nworst[0]) nworst = [n, pal[i], pal[j]];
|
|
53
|
+
}
|
|
54
|
+
if (worst) checks.push({ name: 'cvd', status: worst[0] >= CVD_TARGET ? 'pass' : worst[0] >= CVD_FLOOR ? 'warn' : 'fail', detail: en ? `Worst colorblind pair (${worst[3]}): ${worst[1]} ↔ ${worst[2]}, ΔE ${worst[0].toFixed(1)} (target ≥ ${CVD_TARGET}).` : `Peor par para daltonismo (${worst[3]}): ${worst[1]} ↔ ${worst[2]}, ΔE ${worst[0].toFixed(1)} (meta ≥ ${CVD_TARGET}).` });
|
|
55
|
+
if (nworst) checks.push({ name: 'normal', status: nworst[0] >= NORMAL_FLOOR ? 'pass' : 'fail', detail: en ? `Worst normal-vision pair: ${nworst[1]} ↔ ${nworst[2]}, ΔE ${nworst[0].toFixed(1)} (minimum ${NORMAL_FLOOR}).` : `Peor par con visión normal: ${nworst[1]} ↔ ${nworst[2]}, ΔE ${nworst[0].toFixed(1)} (mínimo ${NORMAL_FLOOR}).` });
|
|
56
|
+
const low = pal.filter((c) => contrast(c, surface) < 3);
|
|
57
|
+
checks.push({ name: 'contrast', status: low.length ? 'warn' : 'pass', detail: low.length ? (en ? 'Below 3:1 against the surface (use visible labels): ' : 'Bajo 3:1 contra el fondo (usa etiquetas visibles): ') + low.join(', ') : en ? 'All ≥ 3:1 against the surface.' : 'Todos ≥ 3:1 contra el fondo.' });
|
|
58
|
+
return { ok: !checks.some((c) => c.status === 'fail'), checks };
|
|
59
|
+
}
|