plotagents 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- package/src/theme.js +101 -14
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// import { plot } from 'plotagents';
|
|
4
4
|
// const svg = plot({ data, x: 'region', y: 'ventas', title: 'Biobío lidera las ventas' });
|
|
5
5
|
//
|
|
6
|
-
import { getTheme, themeFor, themes, palettes, ramps, colorMap, fontSpec, FONT_CSS } from './theme.js';
|
|
6
|
+
import { getTheme, themeFor, themes, palettes, ramps, colorMap, fontSpec, deepMerge, registerTheme, isColor, FONT_CSS } from './theme.js';
|
|
7
7
|
import { withContext } from './context.js';
|
|
8
8
|
import { validatePalette } from './palette.js';
|
|
9
9
|
import { bar } from './charts/bar.js';
|
|
@@ -20,8 +20,11 @@ import { network } from './charts/network.js';
|
|
|
20
20
|
import { chord } from './charts/chord.js';
|
|
21
21
|
import { pyramid } from './charts/pyramid.js';
|
|
22
22
|
import { flowchart } from './charts/flowchart.js';
|
|
23
|
+
import { gantt } from './charts/gantt.js';
|
|
24
|
+
import { wordcloud, countWords } from './charts/wordcloud.js';
|
|
23
25
|
import { choropleth, symbolMap } from './charts/choropleth.js';
|
|
24
|
-
import {
|
|
26
|
+
import { pointmap, embedTiles, TILE_PROVIDERS } from './charts/pointmap.js';
|
|
27
|
+
import { registerGeo, getGeo, registeredGeo, toFeatureCollection } from './geo/registry.js';
|
|
25
28
|
// Mapas incluidos (Chile por regiones, estados de EE. UU. y países del mundo): listos sin importar nada más.
|
|
26
29
|
// El bundle del navegador los separa en dist/plotagents-geo.js.
|
|
27
30
|
import './geo/chile.js';
|
|
@@ -35,11 +38,13 @@ import { facet } from './facet.js';
|
|
|
35
38
|
import { parseDate } from './format.js';
|
|
36
39
|
import { inferType, TYPES, ALIASES, normalizeType } from './infer.js';
|
|
37
40
|
import { lint, _setProbe } from './lint.js';
|
|
38
|
-
import { schema, toolDefinition } from './schema.js';
|
|
41
|
+
import { schema, toolDefinition, dashboardSchema } from './schema.js';
|
|
39
42
|
import { attach, attachSource } from './interact.js';
|
|
40
|
-
import { render, _bind } from './runtime.js';
|
|
43
|
+
import { render, _bind, loadGeo } from './runtime.js';
|
|
44
|
+
import { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows, _bindDashboard } from './dashboard.js';
|
|
41
45
|
import { makeFormat, formatNumber, formatCompact } from './format.js';
|
|
42
46
|
import { esc } from './svg.js';
|
|
47
|
+
import { resolveColorScale, colorScaleNames, interpolate as interpolateColor, COLORMAPS, QUALITATIVE } from './colorscales.js';
|
|
43
48
|
|
|
44
49
|
const RENDERERS = {
|
|
45
50
|
bar: (s, t) => bar(s, t),
|
|
@@ -72,8 +77,11 @@ const RENDERERS = {
|
|
|
72
77
|
chord: (s, t) => chord(s, t),
|
|
73
78
|
pyramid: (s, t) => pyramid(s, t),
|
|
74
79
|
flowchart: (s, t) => flowchart(s, t),
|
|
80
|
+
gantt: (s, t) => gantt(s, t),
|
|
81
|
+
wordcloud: (s, t) => wordcloud(s, t),
|
|
75
82
|
choropleth: (s, t) => choropleth(s, t),
|
|
76
83
|
symbolmap: (s, t) => symbolMap(s, t),
|
|
84
|
+
pointmap: (s, t) => pointmap(s, t),
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
|
|
@@ -82,6 +90,8 @@ const RENDERERS = {
|
|
|
82
90
|
export function resolve(spec = {}) {
|
|
83
91
|
if (!spec || typeof spec !== 'object') throw new PlotError('El spec debe ser un objeto.', 'plot({ data: [...], x: "campo", y: "campo" })');
|
|
84
92
|
const s = withDefaults(spec);
|
|
93
|
+
// refLines / annotations: un objeto suelto vale como lista de uno.
|
|
94
|
+
for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
|
|
85
95
|
if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
|
|
86
96
|
let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
|
|
87
97
|
if (type) {
|
|
@@ -90,11 +100,21 @@ export function resolve(spec = {}) {
|
|
|
90
100
|
for (const [k, v] of Object.entries(extra)) if (s[k] === undefined) s[k] = v;
|
|
91
101
|
}
|
|
92
102
|
if (!type) {
|
|
93
|
-
const data = s.items ? [] : ensureData(s);
|
|
103
|
+
const data = s.items || (!s.data && (typeof s.text === 'string' || Array.isArray(s.text))) ? [] : ensureData(s);
|
|
94
104
|
type = inferType(s, data);
|
|
95
105
|
}
|
|
96
106
|
if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
|
|
97
107
|
s.type = type;
|
|
108
|
+
// Colormap divergente (RdBu, coolwarm…): centrada en 0 si los datos lo cruzan; si no, en el medio.
|
|
109
|
+
const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
|
|
110
|
+
if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
|
|
111
|
+
const vf = typeof s.colorScale === 'object' && s.colorScale.mid !== undefined ? null : s.value;
|
|
112
|
+
if (typeof s.colorScale === 'object' && s.colorScale?.mid !== undefined) s.midpoint = +s.colorScale.mid;
|
|
113
|
+
else if (vf && Array.isArray(s.data) && s.data.length && typeof s.data[0] === 'object') {
|
|
114
|
+
const vs = s.data.map((r) => num(r[vf])).filter(Number.isFinite);
|
|
115
|
+
if (vs.length) { let lo = Infinity, hi = -Infinity; for (const v of vs) { if (v < lo) lo = v; if (v > hi) hi = v; } s.midpoint = lo < 0 && hi > 0 ? 0 : (lo + hi) / 2; }
|
|
116
|
+
} else s.diverging = true;
|
|
117
|
+
}
|
|
98
118
|
return s;
|
|
99
119
|
}
|
|
100
120
|
|
|
@@ -105,6 +125,7 @@ export function resolve(spec = {}) {
|
|
|
105
125
|
export function plot(spec) {
|
|
106
126
|
let s = resolve(spec);
|
|
107
127
|
s = applyTime(s);
|
|
128
|
+
s = applyExclude(s);
|
|
108
129
|
const theme = themeFor(s);
|
|
109
130
|
const textScale = Math.max(0.5, Math.min(4, +(s.textScale ?? s.fontScale ?? s.labelScale ?? 1) || 1));
|
|
110
131
|
return withContext({ textScale }, () => {
|
|
@@ -131,7 +152,12 @@ export function configure(options) {
|
|
|
131
152
|
|
|
132
153
|
function withDefaults(spec) {
|
|
133
154
|
const s = { ...DEFAULTS, ...spec };
|
|
134
|
-
|
|
155
|
+
// Colores fijados por entidad: solo colores CSS válidos (viajan a atributos y al tooltip).
|
|
156
|
+
if (s.colors && typeof s.colors === 'object') s.colors = Object.fromEntries(Object.entries(s.colors).filter(([, c]) => isColor(c)));
|
|
157
|
+
// Tema global + tema del spec: el del spec se fusiona en profundidad sobre el global.
|
|
158
|
+
if (DEFAULTS.theme && spec.theme && typeof spec.theme === 'object' && spec.theme.extends === undefined) {
|
|
159
|
+
s.theme = typeof DEFAULTS.theme === 'string' ? { ...spec.theme, extends: DEFAULTS.theme } : deepMerge(DEFAULTS.theme, spec.theme);
|
|
160
|
+
}
|
|
135
161
|
// `font`: atajo de theme.font (familia + URL para cargarla).
|
|
136
162
|
const f = fontSpec(s.font);
|
|
137
163
|
if (f) {
|
|
@@ -173,6 +199,37 @@ function applyTime(s) {
|
|
|
173
199
|
return { ...s, data, colors: stableColors(s, all), subtitle: s.subtitle ? `${s.subtitle} · ${label}` : label, _time: { field: s.time, value: cur, values: vals }, _prev };
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
/** Campo que define las entradas de la leyenda (series, primer nivel, grupo…). */
|
|
203
|
+
function legendField(s) {
|
|
204
|
+
if (Array.isArray(s.path) && s.path.length) return s.path[0];
|
|
205
|
+
return s.series ?? s.hue ?? s.group ?? (['donut', 'pie'].includes(s.type) ? s.label ?? s.x : null) ?? (s.type === 'treemap' || s.type === 'sunburst' ? s.label ?? s.x : null);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `exclude: ["Maule"]` (o shift + clic en la leyenda): quita esas entradas y
|
|
210
|
+
* recalcula ejes, participaciones y layout con lo que queda. Los colores no
|
|
211
|
+
* se mueven y las entradas excluidas siguen en la leyenda, apagadas.
|
|
212
|
+
*/
|
|
213
|
+
function applyExclude(s) {
|
|
214
|
+
const ex = s.exclude == null || s.exclude === false ? [] : [].concat(s.exclude).map(String);
|
|
215
|
+
if (!ex.length) return s;
|
|
216
|
+
// Tabla ancha: y: ["ingresos", "costos"] → se excluyen columnas.
|
|
217
|
+
if (Array.isArray(s.y) && !s.series && !s.hue) {
|
|
218
|
+
const keep = s.y.filter((k) => !ex.includes(String(k)));
|
|
219
|
+
if (!keep.length || keep.length === s.y.length) return s;
|
|
220
|
+
const cmap = colorMap(s.y, themeFor(s), s.colors);
|
|
221
|
+
return { ...s, y: keep, colors: { ...Object.fromEntries(cmap), ...(s.colors || {}) }, _excluded: s.y.filter((k) => ex.includes(String(k))) };
|
|
222
|
+
}
|
|
223
|
+
const f = legendField(s);
|
|
224
|
+
if (!f || s.items) return s;
|
|
225
|
+
const all = ensureData(s);
|
|
226
|
+
if (!all.length || !(f in all[0])) return s;
|
|
227
|
+
const keep = all.filter((r) => !ex.includes(String(r[f])));
|
|
228
|
+
if (!keep.length || keep.length === all.length) return s;
|
|
229
|
+
const present = new Set(all.map((r) => String(r[f])));
|
|
230
|
+
return { ...s, data: keep, colors: { ...stableColors(s, all), ...(s.colors || {}) }, _excluded: ex.filter((v) => present.has(v)) };
|
|
231
|
+
}
|
|
232
|
+
|
|
176
233
|
/**
|
|
177
234
|
* Colores fijos por entidad a lo largo de TODOS los periodos: al mover el
|
|
178
235
|
* slider, "Minería" sigue siendo índigo aunque cambie su tamaño o su orden.
|
|
@@ -200,7 +257,8 @@ export function safePlot(spec) {
|
|
|
200
257
|
const report = lint(spec || {});
|
|
201
258
|
try {
|
|
202
259
|
const svg = plot(spec);
|
|
203
|
-
|
|
260
|
+
// Se dibujó (con valores de respaldo), pero los errores de lint igual se devuelven: ok = sin errores.
|
|
261
|
+
return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
204
262
|
} catch (e) {
|
|
205
263
|
const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
|
|
206
264
|
return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
@@ -224,9 +282,11 @@ function describeSvg(svg) {
|
|
|
224
282
|
*/
|
|
225
283
|
export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
|
|
226
284
|
const list = Array.isArray(specs) ? specs : [specs];
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
285
|
+
const themesOf = list.map((s) => themeFor(withDefaults(s)));
|
|
286
|
+
const dark = themesOf.every((t) => t.mode === 'dark');
|
|
287
|
+
// Fondo de la página = fondo del tema (también uno global de configure()).
|
|
288
|
+
const bg = themesOf.every((t) => t.surface === themesOf[0].surface) ? themesOf[0].surface : dark ? '#0B0F2B' : '#FCFBF9';
|
|
289
|
+
const needsGeo = list.some((s) => { try { return ['choropleth', 'symbolmap', 'pointmap'].includes(resolve(s).type); } catch { return false; } });
|
|
230
290
|
const bundle = bundleSource() && (needsGeo ? bundleSource() + '\n' + (bundleSource('plotagents-geo.js') || '') : bundleSource());
|
|
231
291
|
const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
|
|
232
292
|
const charts = list.map((s, i) => `<figure class="pa" id="pa-${i}">${plot(s)}</figure>` + (bundle ? `<script type="application/json" id="pa-spec-${i}">${safeJSON(s)}</script>` : '')).join('\n');
|
|
@@ -263,9 +323,14 @@ export function bundleSource(file = 'plotagents.js') {
|
|
|
263
323
|
}
|
|
264
324
|
|
|
265
325
|
_bind(plot, resolve);
|
|
326
|
+
_bindDashboard({ plot, resolve, lint, bundleSource, resolveDefaults: withDefaults });
|
|
266
327
|
_setProbe((s) => plot(s));
|
|
267
328
|
export { render };
|
|
268
329
|
|
|
269
|
-
export {
|
|
330
|
+
export { countWords, dashboardSchema };
|
|
331
|
+
export { resolveColorScale, colorScaleNames, interpolateColor, COLORMAPS, QUALITATIVE };
|
|
332
|
+
export { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows };
|
|
333
|
+
export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo, toFeatureCollection, loadGeo };
|
|
334
|
+
export { registerTheme, withDefaults as resolveDefaults, embedTiles, TILE_PROVIDERS };
|
|
270
335
|
export { lint, schema, toolDefinition, themes, palettes, ramps, validatePalette, getTheme, themeFor, attach, PlotError, TYPES, squarify, makeFormat, formatNumber, formatCompact };
|
|
271
336
|
export default plot;
|
package/src/infer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Elige el tipo de gráfico cuando el agente no lo indica (type: "auto").
|
|
2
2
|
import { fieldsOf, fieldKind } from './data.js';
|
|
3
3
|
|
|
4
|
-
export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'choropleth', 'symbolmap'];
|
|
4
|
+
export const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap'];
|
|
5
5
|
|
|
6
6
|
// Nombres de seaborn, d3plus y otros: el agente puede usar el que conozca.
|
|
7
7
|
export const ALIASES = {
|
|
@@ -9,10 +9,13 @@ export const ALIASES = {
|
|
|
9
9
|
scatterplot: 'scatter', histplot: 'hist', histogram: 'hist', kdeplot: 'kde', density: 'kde', ecdfplot: 'ecdf',
|
|
10
10
|
lmplot: 'regplot', reg: 'regplot', regression: 'regplot', joint: 'jointplot', boxplot: 'box', violinplot: 'violin',
|
|
11
11
|
stripplot: 'strip', swarmplot: 'swarm', beeswarm: 'swarm', pointplot: 'point', spider: 'radar', doughnut: 'donut',
|
|
12
|
+
pointmap: 'pointmap', dotmap: 'pointmap', 'dot-map': 'pointmap', tilemap: 'pointmap', basemap: 'pointmap', 'mapa-puntos': 'pointmap', 'mapa-de-puntos': 'pointmap', latlon: 'pointmap',
|
|
12
13
|
graph: 'network', force: 'network', red: 'network', 'chord-diagram': 'chord', cuerdas: 'chord',
|
|
13
14
|
populationpyramid: 'pyramid', population: 'pyramid', piramide: 'pyramid', 'pirámide': 'pyramid',
|
|
14
15
|
flow: 'flowchart', diagram: 'flowchart', process: 'flowchart', diagrama: 'flowchart',
|
|
15
16
|
map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
|
|
17
|
+
words: 'wordcloud', 'word-cloud': 'wordcloud', wordclouds: 'wordcloud', tagcloud: 'wordcloud', 'tag-cloud': 'wordcloud', nube: 'wordcloud', 'nube-de-palabras': 'wordcloud', cloud: 'wordcloud',
|
|
18
|
+
timeline: 'gantt', roadmap: 'gantt', schedule: 'gantt', cronograma: 'gantt', 'carta-gantt': 'gantt', ganttchart: 'gantt', 'gantt-chart': 'gantt', planning: 'gantt',
|
|
16
19
|
tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
|
|
17
20
|
};
|
|
18
21
|
|
|
@@ -27,6 +30,10 @@ export function normalizeType(t) {
|
|
|
27
30
|
export function inferType(spec, data = []) {
|
|
28
31
|
if (spec.items) return 'kpi';
|
|
29
32
|
if (spec.geo) return 'choropleth';
|
|
33
|
+
if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
|
|
34
|
+
{ const f = fieldsOf(data); if (f.some((k) => /^(lat|latitude|latitud)$/i.test(k)) && f.some((k) => /^(lon|lng|long|longitude|longitud)$/i.test(k))) return 'pointmap'; }
|
|
35
|
+
if ((typeof spec.text === 'string' && !(data[0] && spec.text in data[0])) || (Array.isArray(spec.text) && spec.text.length && typeof spec.text[0] === 'string') || spec.textField) return 'wordcloud';
|
|
36
|
+
if (spec.start !== undefined && (spec.end !== undefined || spec.duration !== undefined) && typeof spec.start === 'string') return 'gantt';
|
|
30
37
|
if (spec.nodes && spec.nodes.some?.((n) => n && n.type)) return 'flowchart';
|
|
31
38
|
if (spec.nodes) return 'network';
|
|
32
39
|
if (spec.matrix) return 'chord';
|
|
@@ -36,6 +43,8 @@ export function inferType(spec, data = []) {
|
|
|
36
43
|
const fields = fieldsOf(data);
|
|
37
44
|
if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
|
|
38
45
|
const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
|
|
46
|
+
// Dos fechas por fila (inicio y fin) y un nombre: un cronograma.
|
|
47
|
+
if (fields.filter((f) => kinds[f] === 'date').length >= 2 && fields.some((f) => kinds[f] === 'category') && !spec.y && !spec.series) return 'gantt';
|
|
39
48
|
const x = spec.x ?? fields[0];
|
|
40
49
|
const xk = kinds[x];
|
|
41
50
|
if (xk === 'date' || xk === 'year') return 'line';
|
package/src/interact.js
CHANGED
|
@@ -12,12 +12,18 @@ export function attach(root) {
|
|
|
12
12
|
const dark = svg.getAttribute('data-pa-mode') === 'dark';
|
|
13
13
|
const tipEl = document.createElement('div');
|
|
14
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');
|
|
15
20
|
tipEl.style.cssText = [
|
|
16
21
|
'position:absolute', 'pointer-events:none', 'z-index:10', 'opacity:0', 'transition:opacity .12s',
|
|
17
22
|
'padding:8px 10px', 'border-radius:8px', 'font:12.5px/1.45 ' + (svg.getAttribute('font-family') || 'system-ui'),
|
|
18
|
-
'
|
|
19
|
-
|
|
20
|
-
'
|
|
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') + ')',
|
|
21
27
|
].join(';');
|
|
22
28
|
root.appendChild(tipEl);
|
|
23
29
|
const NS = 'http://www.w3.org/2000/svg';
|
|
@@ -27,25 +33,77 @@ export function attach(root) {
|
|
|
27
33
|
let on = null;
|
|
28
34
|
let pinned = null;
|
|
29
35
|
|
|
30
|
-
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
31
|
-
|
|
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) => {
|
|
32
50
|
tipEl.innerHTML = html;
|
|
33
51
|
tipEl.style.opacity = '1';
|
|
34
52
|
const r = root.getBoundingClientRect();
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (
|
|
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; }
|
|
39
79
|
tipEl.style.left = x + 'px';
|
|
40
80
|
tipEl.style.top = y + 'px';
|
|
41
81
|
};
|
|
42
82
|
const type = svg.getAttribute('data-pa-type') || '';
|
|
43
|
-
const isMap = type === 'choropleth' || type === 'symbolmap';
|
|
83
|
+
const isMap = type === 'choropleth' || type === 'symbolmap' || type === 'pointmap';
|
|
44
84
|
const zoomG = svg.querySelector('.pa-zoom');
|
|
45
85
|
const mapOverlay = document.createElementNS(NS, 'g');
|
|
46
86
|
mapOverlay.setAttribute('pointer-events', 'none');
|
|
47
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
|
+
};
|
|
48
104
|
const hide = () => {
|
|
105
|
+
extraOn.forEach((n) => n.classList.remove('pa-on'));
|
|
106
|
+
extraOn = [];
|
|
49
107
|
tipEl.style.opacity = '0';
|
|
50
108
|
if (on) on.classList.remove('pa-on');
|
|
51
109
|
on = null;
|
|
@@ -53,19 +111,27 @@ export function attach(root) {
|
|
|
53
111
|
mapOverlay.innerHTML = '';
|
|
54
112
|
if (!pinned) svg.classList.remove('pa-focus');
|
|
55
113
|
};
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
const
|
|
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]; };
|
|
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]; };
|
|
60
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; };
|
|
61
118
|
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
62
119
|
const hex = (a) => '#' + a.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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 };
|
|
69
135
|
};
|
|
70
136
|
const markColor = (node) => {
|
|
71
137
|
if (!node) return null;
|
|
@@ -83,10 +149,17 @@ export function attach(root) {
|
|
|
83
149
|
return null;
|
|
84
150
|
};
|
|
85
151
|
const linesHtml = (txt, color) => {
|
|
86
|
-
const
|
|
87
|
-
return String(txt).split('\n').map((l, i) =>
|
|
88
|
-
|
|
89
|
-
|
|
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('');
|
|
90
163
|
};
|
|
91
164
|
|
|
92
165
|
const toSvg = (ev) => {
|
|
@@ -105,10 +178,12 @@ export function attach(root) {
|
|
|
105
178
|
for (const d of data) if (Math.abs(d.x - p.x) < Math.abs(best.x - p.x)) best = d;
|
|
106
179
|
const y0 = +t.getAttribute('data-y0'), y1 = +t.getAttribute('data-y1');
|
|
107
180
|
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
|
-
|
|
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']);
|
|
112
187
|
return;
|
|
113
188
|
}
|
|
114
189
|
if (t && t.classList && t.classList.contains('pa-near')) {
|
|
@@ -118,8 +193,8 @@ export function attach(root) {
|
|
|
118
193
|
let best = null, bd = 196;
|
|
119
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; } }
|
|
120
195
|
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';
|
|
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';
|
|
123
198
|
return;
|
|
124
199
|
}
|
|
125
200
|
const tg = t && t.closest ? t.closest('[data-tip]') : null;
|
|
@@ -131,13 +206,55 @@ export function attach(root) {
|
|
|
131
206
|
if (isMap) {
|
|
132
207
|
// Mapas: sin atenuar el resto; un contorno marcado sobre la región (o su círculo).
|
|
133
208
|
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"/>`;
|
|
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"/>`;
|
|
135
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"/>`;
|
|
136
|
-
} else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); } else svg.classList.remove('pa-focus');
|
|
211
|
+
} else if (on) { on.classList.add('pa-on'); svg.classList.add('pa-focus'); adjacency(on); } else svg.classList.remove('pa-focus');
|
|
137
212
|
}
|
|
138
|
-
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev);
|
|
213
|
+
show(linesHtml(tg.getAttribute('data-tip'), markColor(mark || tg)), ev, mark || tg);
|
|
139
214
|
});
|
|
140
|
-
|
|
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(); });
|
|
141
258
|
|
|
142
259
|
// Leyenda conectada: pasar sobre una clave destaca sus marcas (mismo color);
|
|
143
260
|
// un clic la deja fija, otro clic la suelta.
|
|
@@ -173,9 +290,13 @@ export function attach(root) {
|
|
|
173
290
|
k.setAttribute('role', 'button');
|
|
174
291
|
k.addEventListener('pointerenter', () => { if (!pinned) focusColor(k.getAttribute('data-color')); });
|
|
175
292
|
k.addEventListener('pointerleave', () => { if (!pinned) focusColor(null); });
|
|
176
|
-
const toggle = () => {
|
|
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
|
+
};
|
|
177
298
|
k.addEventListener('click', toggle);
|
|
178
|
-
k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
|
|
299
|
+
k.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } });
|
|
179
300
|
}
|
|
180
301
|
|
|
181
302
|
if (zoomG) mapZoom(root, svg, zoomG, { dark, hide });
|