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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Proyecciones y trazado de GeoJSON a SVG (sin dependencias).
|
|
2
|
+
// mercator (países alargados como Chile), equalEarth (mapamundi de áreas
|
|
3
|
+
// iguales, Šavrič et al. 2018) y albersUsa (EE. UU. con Alaska, Hawái y
|
|
4
|
+
// Puerto Rico en recuadros, como d3.geoAlbersUsa). fit() ajusta una
|
|
5
|
+
// colección a un rectángulo.
|
|
6
|
+
|
|
7
|
+
const RAD = Math.PI / 180;
|
|
8
|
+
|
|
9
|
+
export function mercator() {
|
|
10
|
+
return ([lon, lat]) => {
|
|
11
|
+
const phi = Math.max(-85, Math.min(85, lat)) * RAD;
|
|
12
|
+
return [lon * RAD, -Math.log(Math.tan(Math.PI / 4 + phi / 2))];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const A1 = 1.340264, A2 = -0.081106, A3 = 0.000893, A4 = 0.003796, M = Math.sqrt(3) / 2;
|
|
17
|
+
export function equalEarth() {
|
|
18
|
+
return ([lon, lat]) => {
|
|
19
|
+
const l = lon * RAD;
|
|
20
|
+
const th = Math.asin(M * Math.sin(lat * RAD));
|
|
21
|
+
const t2 = th * th, t6 = t2 * t2 * t2;
|
|
22
|
+
const x = (l * Math.cos(th)) / (M * (A1 + 3 * A2 * t2 + t6 * (7 * A3 + 9 * A4 * t2)));
|
|
23
|
+
const y = th * (A1 + A2 * t2 + t6 * (A3 + A4 * t2));
|
|
24
|
+
return [x, -y];
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cónica de áreas iguales (Albers) con rotación y centro, en escala unitaria:
|
|
30
|
+
* misma fórmula que d3.geoConicEqualArea. Devuelve [x, y] con y hacia abajo.
|
|
31
|
+
*/
|
|
32
|
+
function conic(rot, center, [p0, p1], k = 1, [tx, ty] = [0, 0]) {
|
|
33
|
+
const sy0 = Math.sin(p0 * RAD), n = (sy0 + Math.sin(p1 * RAD)) / 2;
|
|
34
|
+
const c = 1 + sy0 * (2 * n - sy0), r0 = Math.sqrt(c) / n;
|
|
35
|
+
const raw = (lon, lat) => {
|
|
36
|
+
let l = lon + rot;
|
|
37
|
+
l = ((((l + 180) % 360) + 360) % 360) - 180;
|
|
38
|
+
const r = Math.sqrt(Math.max(0, c - 2 * n * Math.sin(lat * RAD))) / n;
|
|
39
|
+
const a = l * RAD * n;
|
|
40
|
+
return [r * Math.sin(a), r0 - r * Math.cos(a)];
|
|
41
|
+
};
|
|
42
|
+
const [cx, cy] = raw(center[0], center[1]);
|
|
43
|
+
return (lon, lat) => { const [x, y] = raw(lon, lat); return [tx + k * (x - cx), ty - k * (y - cy)]; };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* EE. UU. compuesto: 48 estados contiguos en Albers y recuadros para Alaska
|
|
48
|
+
* (a 0,35×), Hawái y Puerto Rico abajo, como en d3 / d3-composite-projections.
|
|
49
|
+
* Cada punto va al recuadro de su territorio (las geometrías no cruzan).
|
|
50
|
+
*/
|
|
51
|
+
export function albersUsa() {
|
|
52
|
+
const lower48 = conic(96, [-96.6, 38.7], [29.5, 45.5]);
|
|
53
|
+
const alaska = conic(154, [-156, 58.5], [55, 65], 0.35, [-0.307, 0.201]);
|
|
54
|
+
const hawaii = conic(157, [-160, 19.9], [8, 18], 1, [-0.205, 0.212]);
|
|
55
|
+
const puertoRico = conic(66, [-66, 18], [8, 18], 1, [0.35, 0.224]);
|
|
56
|
+
const which = ([lon, lat]) => (lat > 50 ? 'AK' : lat < 23 && lon < -150 ? 'HI' : lon > -68.5 && lon < -64 && lat < 19.5 ? 'PR' : null);
|
|
57
|
+
const P = { AK: alaska, HI: hawaii, PR: puertoRico };
|
|
58
|
+
const f = (p) => (P[which(p)] || lower48)(p[0], p[1]);
|
|
59
|
+
f.continuous = true; // sin corte en el antimeridiano: los recuadros ya son continuos
|
|
60
|
+
f.inset = which;
|
|
61
|
+
return f;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const projections = { mercator, equalEarth, 'equal-earth': equalEarth, albersUsa, 'albers-usa': albersUsa, albers: albersUsa };
|
|
65
|
+
|
|
66
|
+
/** Recorre todos los anillos de una geometría. */
|
|
67
|
+
export function rings(geom) {
|
|
68
|
+
if (!geom) return [];
|
|
69
|
+
if (geom.type === 'Polygon') return geom.coordinates;
|
|
70
|
+
if (geom.type === 'MultiPolygon') return geom.coordinates.flat();
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
export function polygonsOf(geom) {
|
|
74
|
+
if (!geom) return [];
|
|
75
|
+
if (geom.type === 'Polygon') return [geom.coordinates];
|
|
76
|
+
if (geom.type === 'MultiPolygon') return geom.coordinates;
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Proyección ajustada a [x0, y0, x1, y1] conservando la proporción.
|
|
82
|
+
* Devuelve una función lon/lat → [x, y] con .bounds (ancho/alto usados).
|
|
83
|
+
*/
|
|
84
|
+
export function fit(features, raw, [x0, y0, x1, y1], { align = 'center' } = {}) {
|
|
85
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
86
|
+
for (const f of features) for (const r of rings(f.geometry)) for (const p of r) {
|
|
87
|
+
const [x, y] = raw(p);
|
|
88
|
+
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
|
|
89
|
+
}
|
|
90
|
+
const w = x1 - x0, h = y1 - y0;
|
|
91
|
+
const k = Math.min(w / (bx1 - bx0 || 1), h / (by1 - by0 || 1));
|
|
92
|
+
const mw = (bx1 - bx0) * k, mh = (by1 - by0) * k;
|
|
93
|
+
const ox = align === 'left' ? x0 : x0 + (w - mw) / 2;
|
|
94
|
+
const oy = y0 + (h - mh) / 2;
|
|
95
|
+
const proj = (p) => { const [x, y] = raw(p); return [ox + (x - bx0) * k, oy + (y - by0) * k]; };
|
|
96
|
+
proj.bounds = { x: ox, y: oy, w: mw, h: mh };
|
|
97
|
+
proj.continuous = !!raw.continuous;
|
|
98
|
+
proj.inset = raw.inset;
|
|
99
|
+
proj.aspect = (by1 - by0) / (bx1 - bx0 || 1);
|
|
100
|
+
return proj;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Proporción alto/ancho de la colección proyectada (para elegir el alto). */
|
|
104
|
+
export function aspectOf(features, raw) {
|
|
105
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
106
|
+
for (const f of features) for (const r of rings(f.geometry)) for (const p of r) {
|
|
107
|
+
const [x, y] = raw(p);
|
|
108
|
+
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
|
|
109
|
+
}
|
|
110
|
+
return (by1 - by0) / (bx1 - bx0 || 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Geometría → atributo d de SVG. Los anillos que saltan más de 180° de
|
|
117
|
+
* longitud (antimeridiano) se cortan para no cruzar el mapa entero.
|
|
118
|
+
*/
|
|
119
|
+
export function pathD(geom, proj) {
|
|
120
|
+
let d = '';
|
|
121
|
+
const cut = !proj.continuous;
|
|
122
|
+
for (const ring of rings(geom)) {
|
|
123
|
+
let started = false, lastLon = null, lx = null, ly = null, n = 0;
|
|
124
|
+
for (const p of ring) {
|
|
125
|
+
if (cut && lastLon !== null && Math.abs(p[0] - lastLon) > 180) started = false;
|
|
126
|
+
lastLon = p[0];
|
|
127
|
+
const [x, y] = proj(p);
|
|
128
|
+
const X = r1(x), Y = r1(y);
|
|
129
|
+
if (started && X === lx && Y === ly) continue;
|
|
130
|
+
d += (started ? 'L' : 'M') + X + ',' + Y;
|
|
131
|
+
started = true; lx = X; ly = Y; n++;
|
|
132
|
+
}
|
|
133
|
+
if (n) d += 'Z';
|
|
134
|
+
}
|
|
135
|
+
return d;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Área (en px²) y centroide del polígono proyectado más grande. */
|
|
139
|
+
export function centroid(geom, proj) {
|
|
140
|
+
let best = null;
|
|
141
|
+
for (const poly of polygonsOf(geom)) {
|
|
142
|
+
const pts = poly[0].map(proj);
|
|
143
|
+
let a = 0, cx = 0, cy = 0;
|
|
144
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
145
|
+
const f = pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
|
|
146
|
+
a += f; cx += (pts[j][0] + pts[i][0]) * f; cy += (pts[j][1] + pts[i][1]) * f;
|
|
147
|
+
}
|
|
148
|
+
a /= 2;
|
|
149
|
+
if (!best || Math.abs(a) > Math.abs(best.area)) best = { area: a, x: a ? cx / (6 * a) : pts[0][0], y: a ? cy / (6 * a) : pts[0][1] };
|
|
150
|
+
}
|
|
151
|
+
return best ? { x: best.x, y: best.y, area: Math.abs(best.area) } : { x: 0, y: 0, area: 0 };
|
|
152
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Registro de geometrías. El núcleo no trae mapas pesados: se registran al
|
|
2
|
+
// importar 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o se pasa un
|
|
3
|
+
// GeoJSON propio en spec.geo.
|
|
4
|
+
import { PlotError } from '../data.js';
|
|
5
|
+
|
|
6
|
+
const REG = new Map();
|
|
7
|
+
|
|
8
|
+
export function registerGeo(name, fc, aliases = []) {
|
|
9
|
+
if (!fc || fc.type !== 'FeatureCollection') throw new PlotError(`La geometría "${name}" debe ser un GeoJSON FeatureCollection.`);
|
|
10
|
+
for (const n of [name, ...aliases]) REG.set(String(n).toLowerCase(), fc);
|
|
11
|
+
return fc;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getGeo(geo) {
|
|
15
|
+
if (geo && typeof geo === 'object') {
|
|
16
|
+
if (geo.type === 'FeatureCollection') return geo;
|
|
17
|
+
if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
|
|
18
|
+
throw new PlotError('`geo` debe ser un nombre registrado o un GeoJSON FeatureCollection.');
|
|
19
|
+
}
|
|
20
|
+
const fc = REG.get(String(geo || '').toLowerCase());
|
|
21
|
+
if (!fc) {
|
|
22
|
+
const known = [...new Set(REG.keys())];
|
|
23
|
+
throw new PlotError(
|
|
24
|
+
`No hay una geometría registrada con el nombre "${geo}".`,
|
|
25
|
+
known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return fc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const registeredGeo = () => [...new Set(REG.keys())];
|
package/src/geo/usa.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Registra los 50 estados de EE. UU., DC y Puerto Rico como 'usa'
|
|
2
|
+
// (alias 'us', 'eeuu', 'estados-unidos', 'united-states', 'states').
|
|
3
|
+
import { registerGeo } from './registry.js';
|
|
4
|
+
import estados, { attribution } from './data/usa-estados.js';
|
|
5
|
+
|
|
6
|
+
registerGeo('usa', estados, ['us', 'eeuu', 'ee.uu.', 'estados-unidos', 'estados unidos', 'united-states', 'united states', 'states', 'us-states']);
|
|
7
|
+
export { estados, attribution };
|
|
8
|
+
export default estados;
|
package/src/geo/world.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registra los países del mundo (Natural Earth 1:110m) como 'world' (alias 'mundo').
|
|
2
|
+
import { registerGeo } from './registry.js';
|
|
3
|
+
import world, { attribution } from './data/world.js';
|
|
4
|
+
|
|
5
|
+
registerGeo('world', world, ['mundo', 'countries', 'paises', 'países']);
|
|
6
|
+
export { world, attribution };
|
|
7
|
+
export default world;
|
package/src/hierarchy.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Jerarquías para treemap y sunburst: filas planas + campos de ruta → árbol
|
|
2
|
+
// agregado, ordenado de mayor a menor. También colores por familia, foco
|
|
3
|
+
// (drill-down) y migas de pan.
|
|
4
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from './data.js';
|
|
5
|
+
import { hexToRgb, rgbToHex } from './theme.js';
|
|
6
|
+
import { formatNumber } from './format.js';
|
|
7
|
+
import { el, g, text } from './svg.js';
|
|
8
|
+
import { measure, truncate } from './text.js';
|
|
9
|
+
|
|
10
|
+
/** Resuelve los campos de ruta y valor desde el spec (path | group+label | label | x). */
|
|
11
|
+
export function hierarchyFields(spec, data) {
|
|
12
|
+
const fields = fieldsOf(data);
|
|
13
|
+
const cats = fields.filter((f) => fieldKind(data, f) === 'category');
|
|
14
|
+
let path = spec.path ? [].concat(spec.path) : null;
|
|
15
|
+
if (!path) {
|
|
16
|
+
const leaf = spec.label ?? spec.x;
|
|
17
|
+
path = spec.group ? [spec.group, leaf ?? cats.find((f) => f !== spec.group)] : [leaf ?? cats[0]];
|
|
18
|
+
}
|
|
19
|
+
path = path.filter(Boolean);
|
|
20
|
+
const value = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => fieldKind(data, f) === 'number');
|
|
21
|
+
if (!path.length) throw new PlotError('No encontré un campo de categorías para la jerarquía.', 'Indica path: ["grupo", "elemento"] o label: "campo".');
|
|
22
|
+
path.forEach((p, i) => requireField(data, p, `path[${i}]`));
|
|
23
|
+
requireField(data, value, 'value');
|
|
24
|
+
return { path, value };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Construye el árbol. Nodo: { name, path, depth, value, children, parent }.
|
|
29
|
+
* `depth` (opcional) agrega todo bajo ese nivel.
|
|
30
|
+
*/
|
|
31
|
+
export function buildHierarchy(spec) {
|
|
32
|
+
const data = ensureData(spec);
|
|
33
|
+
const { path, value } = hierarchyFields(spec, data);
|
|
34
|
+
const levels = Math.max(1, Math.min(path.length, spec.depth ?? path.length));
|
|
35
|
+
const root = { name: '', path: [], depth: 0, value: 0, children: [], map: new Map() };
|
|
36
|
+
for (const r of data) {
|
|
37
|
+
const v = num(r[value]);
|
|
38
|
+
if (!(v > 0)) continue;
|
|
39
|
+
let node = root;
|
|
40
|
+
root.value += v;
|
|
41
|
+
for (let d = 0; d < levels; d++) {
|
|
42
|
+
const raw = r[path[d]];
|
|
43
|
+
const name = raw === null || raw === undefined || raw === '' ? '—' : String(raw);
|
|
44
|
+
if (!node.map.has(name)) {
|
|
45
|
+
const child = { name, path: [...node.path, name], depth: d + 1, value: 0, children: [], map: new Map(), parent: node };
|
|
46
|
+
node.map.set(name, child);
|
|
47
|
+
node.children.push(child);
|
|
48
|
+
}
|
|
49
|
+
node = node.map.get(name);
|
|
50
|
+
node.value += v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sortRec = (n) => { n.children.sort((a, b) => b.value - a.value); n.children.forEach(sortRec); delete n.map; };
|
|
54
|
+
sortRec(root);
|
|
55
|
+
return { root, fields: path.slice(0, levels), valueField: value, data };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Busca un nodo por ruta; si no existe, devuelve el más profundo que coincida. */
|
|
59
|
+
export function findNode(root, focus = []) {
|
|
60
|
+
let node = root;
|
|
61
|
+
for (const name of focus || []) {
|
|
62
|
+
const next = node.children.find((c) => c.name === String(name));
|
|
63
|
+
if (!next) break;
|
|
64
|
+
node = next;
|
|
65
|
+
}
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
|
|
70
|
+
export const descendants = (n) => [n, ...n.children.flatMap(descendants)];
|
|
71
|
+
|
|
72
|
+
/** Mezcla un color hacia otro (t ∈ [0,1]). */
|
|
73
|
+
export function mix(a, b, t) {
|
|
74
|
+
const A = hexToRgb(a), B = hexToRgb(b);
|
|
75
|
+
return rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Colores por familia: el primer nivel visible toma la paleta categórica en
|
|
80
|
+
* orden de tamaño (desde el 8.º, "Otros" en gris); los descendientes heredan
|
|
81
|
+
* el color con una variación de luminosidad sutil por hermano.
|
|
82
|
+
*/
|
|
83
|
+
export function assignColors(displayRoot, theme, spec, { single = false } = {}) {
|
|
84
|
+
const top = displayRoot.children;
|
|
85
|
+
const n = theme.categorical.length;
|
|
86
|
+
const fold = top.length > n;
|
|
87
|
+
top.forEach((c, i) => {
|
|
88
|
+
const fixed = spec.colors && spec.colors[c.name];
|
|
89
|
+
c.color = fixed || (single ? theme.categorical[0] : fold && i >= n - 1 ? theme.other : theme.categorical[i] || theme.other);
|
|
90
|
+
c.folded = !fixed && !single && fold && i >= n - 1;
|
|
91
|
+
});
|
|
92
|
+
const steps = [0, 0.14, 0.07, 0.2, 0.1];
|
|
93
|
+
const toward = theme.surface;
|
|
94
|
+
const rec = (node) => node.children.forEach((c, i) => {
|
|
95
|
+
// Color sólido por categoría completa (por defecto); `shade: true` vuelve a los tonos por hermano.
|
|
96
|
+
if (node !== displayRoot) c.color = (spec.colors && spec.colors[c.name]) || (spec.shade ? mix(node.color, toward, steps[i % steps.length]) : node.color);
|
|
97
|
+
rec(c);
|
|
98
|
+
});
|
|
99
|
+
rec(displayRoot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function shareFmt(locale) {
|
|
103
|
+
return (v, total) => {
|
|
104
|
+
const p = total > 0 ? (v / total) * 100 : 0;
|
|
105
|
+
return formatNumber(p, p < 1 ? 1 : p < 10 ? 1 : 0, locale, true) + '%';
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const SEP = ' › ';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Migas de pan de foco: "Todo › Minería › Cobre". Cada miga lleva data-drill
|
|
113
|
+
* con la ruta de su ancestro (la raíz, "[]"). Devuelve { svg, h }.
|
|
114
|
+
*/
|
|
115
|
+
export function breadcrumb(focusNode, x, y, w, theme, locale = 'es') {
|
|
116
|
+
if (!focusNode || !focusNode.path.length) return { svg: '', h: 0 };
|
|
117
|
+
const en = String(locale).startsWith('en');
|
|
118
|
+
const crumbs = [{ name: en ? 'All' : 'Todo', path: [] }, ...focusNode.path.map((name, i) => ({ name, path: focusNode.path.slice(0, i + 1) }))];
|
|
119
|
+
const out = [];
|
|
120
|
+
let cx = x;
|
|
121
|
+
const size = 12.5;
|
|
122
|
+
crumbs.forEach((c, i) => {
|
|
123
|
+
const last = i === crumbs.length - 1;
|
|
124
|
+
const label = truncate(c.name, Math.max(60, w / crumbs.length), size, last ? 600 : 400);
|
|
125
|
+
const wLab = measure(label, size, last ? 600 : 400);
|
|
126
|
+
const attrs = { fill: last ? theme.ink : theme.lede, fontSize: size, fontWeight: last ? 600 : 400 };
|
|
127
|
+
if (last) out.push(text(cx, y + 13, label, attrs));
|
|
128
|
+
else out.push(g({ class: 'pa-crumb', 'data-drill': JSON.stringify(c.path), style: 'cursor:pointer' }, [
|
|
129
|
+
el('rect', { x: cx - 3, y, width: wLab + 6, height: 18, fill: 'transparent' }),
|
|
130
|
+
text(cx, y + 13, label, { ...attrs, textDecoration: 'underline', 'text-decoration-color': theme.axis }),
|
|
131
|
+
]));
|
|
132
|
+
cx += wLab;
|
|
133
|
+
if (!last) {
|
|
134
|
+
out.push(text(cx + 6, y + 13, '›', { fill: theme.muted, fontSize: size }));
|
|
135
|
+
cx += 18;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return { svg: g({ class: 'pa-breadcrumb' }, out), h: 26 };
|
|
139
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// plotagents · gráficos bellos con una sola llamada, pensados para agentes.
|
|
2
|
+
//
|
|
3
|
+
// import { plot } from 'plotagents';
|
|
4
|
+
// const svg = plot({ data, x: 'region', y: 'ventas', title: 'Biobío lidera las ventas' });
|
|
5
|
+
//
|
|
6
|
+
import { getTheme, themeFor, themes, palettes, ramps, colorMap, fontSpec, FONT_CSS } from './theme.js';
|
|
7
|
+
import { withContext } from './context.js';
|
|
8
|
+
import { validatePalette } from './palette.js';
|
|
9
|
+
import { bar } from './charts/bar.js';
|
|
10
|
+
import { line } from './charts/line.js';
|
|
11
|
+
import { scatter } from './charts/scatter.js';
|
|
12
|
+
import { treemap, squarify } from './charts/treemap.js';
|
|
13
|
+
import { donut, pie } from './charts/donut.js';
|
|
14
|
+
import { sunburst } from './charts/sunburst.js';
|
|
15
|
+
import { hist, kde, ecdf } from './charts/dist.js';
|
|
16
|
+
import { regplot, residplot, jointplot } from './charts/regplot.js';
|
|
17
|
+
import { box, violin, strip, swarm, point } from './charts/categorical.js';
|
|
18
|
+
import { radar } from './charts/radar.js';
|
|
19
|
+
import { network } from './charts/network.js';
|
|
20
|
+
import { chord } from './charts/chord.js';
|
|
21
|
+
import { pyramid } from './charts/pyramid.js';
|
|
22
|
+
import { flowchart } from './charts/flowchart.js';
|
|
23
|
+
import { choropleth, symbolMap } from './charts/choropleth.js';
|
|
24
|
+
import { registerGeo, getGeo, registeredGeo } from './geo/registry.js';
|
|
25
|
+
// Mapas incluidos (Chile por regiones, estados de EE. UU. y países del mundo): listos sin importar nada más.
|
|
26
|
+
// El bundle del navegador los separa en dist/plotagents-geo.js.
|
|
27
|
+
import './geo/chile.js';
|
|
28
|
+
import './geo/world.js';
|
|
29
|
+
import './geo/usa.js';
|
|
30
|
+
import { heatmap } from './charts/heatmap.js';
|
|
31
|
+
import { sankey } from './charts/sankey.js';
|
|
32
|
+
import { kpi } from './charts/kpi.js';
|
|
33
|
+
import { ensureData, PlotError, fieldKind, uniq, num } from './data.js';
|
|
34
|
+
import { facet } from './facet.js';
|
|
35
|
+
import { parseDate } from './format.js';
|
|
36
|
+
import { inferType, TYPES, ALIASES, normalizeType } from './infer.js';
|
|
37
|
+
import { lint, _setProbe } from './lint.js';
|
|
38
|
+
import { schema, toolDefinition } from './schema.js';
|
|
39
|
+
import { attach, attachSource } from './interact.js';
|
|
40
|
+
import { render, _bind } from './runtime.js';
|
|
41
|
+
import { makeFormat, formatNumber, formatCompact } from './format.js';
|
|
42
|
+
import { esc } from './svg.js';
|
|
43
|
+
|
|
44
|
+
const RENDERERS = {
|
|
45
|
+
bar: (s, t) => bar(s, t),
|
|
46
|
+
column: (s, t) => bar({ ...s, horizontal: false }, t),
|
|
47
|
+
count: (s, t) => bar({ aggregate: 'count', ...s }, t),
|
|
48
|
+
line: (s, t) => line(s, t, 'line'),
|
|
49
|
+
area: (s, t) => line(s, t, 'area'),
|
|
50
|
+
scatter: (s, t) => scatter(s, t),
|
|
51
|
+
bubble: (s, t) => scatter(s, t),
|
|
52
|
+
treemap: (s, t) => treemap(s, t),
|
|
53
|
+
sunburst: (s, t) => sunburst(s, t),
|
|
54
|
+
donut: (s, t) => donut(s, t),
|
|
55
|
+
pie: (s, t) => pie(s, t),
|
|
56
|
+
heatmap: (s, t) => heatmap(s, t),
|
|
57
|
+
sankey: (s, t) => sankey(s, t),
|
|
58
|
+
kpi: (s, t) => kpi(s, t),
|
|
59
|
+
hist: (s, t) => hist(s, t),
|
|
60
|
+
kde: (s, t) => kde(s, t),
|
|
61
|
+
ecdf: (s, t) => ecdf(s, t),
|
|
62
|
+
regplot: (s, t) => regplot(s, t),
|
|
63
|
+
residplot: (s, t) => residplot(s, t),
|
|
64
|
+
jointplot: (s, t) => jointplot(s, t),
|
|
65
|
+
box: (s, t) => box(s, t),
|
|
66
|
+
violin: (s, t) => violin(s, t),
|
|
67
|
+
strip: (s, t) => strip(s, t),
|
|
68
|
+
swarm: (s, t) => swarm(s, t),
|
|
69
|
+
point: (s, t) => point(s, t),
|
|
70
|
+
radar: (s, t) => radar(s, t),
|
|
71
|
+
network: (s, t) => network(s, t),
|
|
72
|
+
chord: (s, t) => chord(s, t),
|
|
73
|
+
pyramid: (s, t) => pyramid(s, t),
|
|
74
|
+
flowchart: (s, t) => flowchart(s, t),
|
|
75
|
+
choropleth: (s, t) => choropleth(s, t),
|
|
76
|
+
symbolmap: (s, t) => symbolMap(s, t),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** Normaliza el spec: tipo inferido y datos como filas. */
|
|
82
|
+
export function resolve(spec = {}) {
|
|
83
|
+
if (!spec || typeof spec !== 'object') throw new PlotError('El spec debe ser un objeto.', 'plot({ data: [...], x: "campo", y: "campo" })');
|
|
84
|
+
const s = withDefaults(spec);
|
|
85
|
+
if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
|
|
86
|
+
let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
|
|
87
|
+
if (type) {
|
|
88
|
+
const [t, extra] = normalizeType(type);
|
|
89
|
+
type = t;
|
|
90
|
+
for (const [k, v] of Object.entries(extra)) if (s[k] === undefined) s[k] = v;
|
|
91
|
+
}
|
|
92
|
+
if (!type) {
|
|
93
|
+
const data = s.items ? [] : ensureData(s);
|
|
94
|
+
type = inferType(s, data);
|
|
95
|
+
}
|
|
96
|
+
if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
|
|
97
|
+
s.type = type;
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Dibuja un gráfico y devuelve un SVG (texto). Funciona en Node y navegador.
|
|
103
|
+
* Lanza PlotError con una pista de corrección si el spec no se puede dibujar.
|
|
104
|
+
*/
|
|
105
|
+
export function plot(spec) {
|
|
106
|
+
let s = resolve(spec);
|
|
107
|
+
s = applyTime(s);
|
|
108
|
+
const theme = themeFor(s);
|
|
109
|
+
const textScale = Math.max(0.5, Math.min(4, +(s.textScale ?? s.fontScale ?? s.labelScale ?? 1) || 1));
|
|
110
|
+
return withContext({ textScale }, () => {
|
|
111
|
+
if (s.facet || s.col) return facet(s, theme, (p) => RENDERERS[s.type](p, themeFor(p)));
|
|
112
|
+
return RENDERERS[s.type](s, theme);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Configuración global ──────────────────────────────────────────────────
|
|
117
|
+
const DEFAULTS = {};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Valores por defecto para TODOS los gráficos (el spec siempre gana):
|
|
121
|
+
* configure({ font: 'Poppins', textScale: 1.5, locale: 'en', theme: 'noche', palette: 'okabe-ito' })
|
|
122
|
+
* `font` acepta un nombre de Google Fonts, una pila CSS o { family, import }.
|
|
123
|
+
* `textScale` multiplica todos los textos (1.5, 2…) y el layout se recalcula.
|
|
124
|
+
* configure() sin argumentos devuelve la configuración actual; configure(null) la borra.
|
|
125
|
+
*/
|
|
126
|
+
export function configure(options) {
|
|
127
|
+
if (options === null) { for (const k of Object.keys(DEFAULTS)) delete DEFAULTS[k]; return {}; }
|
|
128
|
+
if (options) Object.assign(DEFAULTS, options);
|
|
129
|
+
return { ...DEFAULTS };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function withDefaults(spec) {
|
|
133
|
+
const s = { ...DEFAULTS, ...spec };
|
|
134
|
+
if (DEFAULTS.theme && typeof DEFAULTS.theme === 'object' && spec.theme && typeof spec.theme === 'object') s.theme = { ...DEFAULTS.theme, ...spec.theme };
|
|
135
|
+
// `font`: atajo de theme.font (familia + URL para cargarla).
|
|
136
|
+
const f = fontSpec(s.font);
|
|
137
|
+
if (f) {
|
|
138
|
+
const base = s.theme && typeof s.theme === 'object' ? s.theme : { extends: s.theme || undefined };
|
|
139
|
+
s.theme = { ...base, font: { ...(base.font || {}), ...f } };
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const TIME_AXIS = new Set(['line', 'area', 'ecdf']);
|
|
145
|
+
|
|
146
|
+
/** Valores ordenados del campo `time` (para sliders y animación). */
|
|
147
|
+
export function timeValues(spec) {
|
|
148
|
+
const s = spec.type ? spec : resolve(spec);
|
|
149
|
+
if (!s.time) return [];
|
|
150
|
+
const data = ensureData(s);
|
|
151
|
+
const k = fieldKind(data, s.time);
|
|
152
|
+
const vals = uniq(data.map((r) => r[s.time]).filter((v) => v !== null && v !== undefined && v !== ''));
|
|
153
|
+
return vals.sort((a, b) => (k === 'date' ? parseDate(a) - parseDate(b) : k === 'year' || k === 'number' ? num(a) - num(b) : 0));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `time` (estilo d3plus): en gráficos que no tienen el tiempo en el eje x,
|
|
158
|
+
* muestra un solo periodo (`timeValue`, por defecto el último). El periodo se
|
|
159
|
+
* agrega a la bajada; en el navegador, render() suma un slider con "play".
|
|
160
|
+
*/
|
|
161
|
+
function applyTime(s) {
|
|
162
|
+
if (!s.time || TIME_AXIS.has(s.type) || s.x === s.time) return s;
|
|
163
|
+
const vals = timeValues(s);
|
|
164
|
+
if (!vals.length) return s;
|
|
165
|
+
const cur = s.timeValue ?? vals[vals.length - 1];
|
|
166
|
+
const all = ensureData(s);
|
|
167
|
+
const data = all.filter((r) => String(r[s.time]) === String(cur));
|
|
168
|
+
const label = String(cur);
|
|
169
|
+
// Periodo de comparación para variaciones (treemap y otros): el anterior, o el primero con compare: "first".
|
|
170
|
+
const idx = vals.findIndex((v) => String(v) === String(cur));
|
|
171
|
+
const pv = s.compare === 'first' ? vals[0] : s.compare !== undefined && s.compare !== 'previous' && s.compare !== false ? s.compare : vals[idx - 1];
|
|
172
|
+
const _prev = s.compare !== false && pv !== undefined && String(pv) !== String(cur) ? { data: all.filter((r) => String(r[s.time]) === String(pv)), label: String(pv) } : undefined;
|
|
173
|
+
return { ...s, data, colors: stableColors(s, all), subtitle: s.subtitle ? `${s.subtitle} · ${label}` : label, _time: { field: s.time, value: cur, values: vals }, _prev };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Colores fijos por entidad a lo largo de TODOS los periodos: al mover el
|
|
178
|
+
* slider, "Minería" sigue siendo índigo aunque cambie su tamaño o su orden.
|
|
179
|
+
*/
|
|
180
|
+
function stableColors(s, all) {
|
|
181
|
+
const idf = s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0] : null) ?? s.group ?? s.from
|
|
182
|
+
?? (['donut', 'pie'].includes(s.type) ? s.label ?? s.x : null);
|
|
183
|
+
if (!idf || !all.length || !(idf in all[0])) return s.colors;
|
|
184
|
+
const vf = s.value ?? (typeof s.y === 'string' ? s.y : null);
|
|
185
|
+
const tot = new Map();
|
|
186
|
+
for (const r of all) {
|
|
187
|
+
const k = String(r[idf]);
|
|
188
|
+
tot.set(k, (tot.get(k) || 0) + (vf && Number.isFinite(num(r[vf])) ? Math.abs(num(r[vf])) : 1));
|
|
189
|
+
}
|
|
190
|
+
const keys = [...tot.keys()].sort((a, b) => tot.get(b) - tot.get(a));
|
|
191
|
+
const cmap = colorMap(keys, themeFor(s), s.colors);
|
|
192
|
+
return { ...Object.fromEntries(cmap), ...(s.colors || {}) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Versión que nunca lanza: ideal como herramienta de un agente.
|
|
197
|
+
* Devuelve { ok, svg, type, description, errors, warnings, suggestions }.
|
|
198
|
+
*/
|
|
199
|
+
export function safePlot(spec) {
|
|
200
|
+
const report = lint(spec || {});
|
|
201
|
+
try {
|
|
202
|
+
const svg = plot(spec);
|
|
203
|
+
return { ok: true, svg, type: report.type, description: describeSvg(svg), errors: [], warnings: report.warnings, suggestions: report.suggestions };
|
|
204
|
+
} catch (e) {
|
|
205
|
+
const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
|
|
206
|
+
return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Descripción en texto del gráfico (también va en <desc> del SVG). */
|
|
211
|
+
export function describe(spec) {
|
|
212
|
+
return describeSvg(plot(spec));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function describeSvg(svg) {
|
|
216
|
+
const m = /<desc[^>]*>([\s\S]*?)<\/desc>/.exec(svg);
|
|
217
|
+
return m ? m[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") : '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Página HTML autónoma (un archivo) con uno o varios gráficos interactivos:
|
|
222
|
+
* tooltips, slider temporal y navegación por niveles. Incluye la biblioteca
|
|
223
|
+
* en línea (dist/plotagents.js) cuando está disponible; si no, solo tooltips.
|
|
224
|
+
*/
|
|
225
|
+
export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
|
|
226
|
+
const list = Array.isArray(specs) ? specs : [specs];
|
|
227
|
+
const dark = list.every((s) => themeFor(s).mode === 'dark');
|
|
228
|
+
const bg = dark ? '#0B0F2B' : '#FCFBF9';
|
|
229
|
+
const needsGeo = list.some((s) => { try { return ['choropleth', 'symbolmap'].includes(resolve(s).type); } catch { return false; } });
|
|
230
|
+
const bundle = bundleSource() && (needsGeo ? bundleSource() + '\n' + (bundleSource('plotagents-geo.js') || '') : bundleSource());
|
|
231
|
+
const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
|
|
232
|
+
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');
|
|
233
|
+
const boot = bundle
|
|
234
|
+
? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>\n<script>document.querySelectorAll('figure.pa').forEach(function(f,i){var s=JSON.parse(document.getElementById('pa-spec-'+i).textContent);plotagents.render(f,s);});</script>`
|
|
235
|
+
: `<script>document.querySelectorAll('figure.pa').forEach(function(f){(${attachSource})(f)});</script>`;
|
|
236
|
+
return `<!doctype html>
|
|
237
|
+
<html lang="${esc(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
238
|
+
<title>${esc(title)}</title>
|
|
239
|
+
${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
|
|
240
|
+
<style>
|
|
241
|
+
:root{color-scheme:${dark ? 'dark' : 'light'}}
|
|
242
|
+
body{margin:0;background:${bg};font-family:${esc(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
|
|
243
|
+
main{max-width:${columns > 1 ? 1500 : 820}px;margin:0 auto;padding:24px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,${columns > 1 ? 560 : 320}px),1fr));gap:24px}
|
|
244
|
+
figure.pa{margin:0;min-width:0}
|
|
245
|
+
figure.pa svg{width:100%;height:auto;display:block}
|
|
246
|
+
</style></head>
|
|
247
|
+
<body><main>
|
|
248
|
+
${charts}
|
|
249
|
+
</main>
|
|
250
|
+
${boot}
|
|
251
|
+
</body></html>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Código de la biblioteca en un archivo (para incrustar en HTML). */
|
|
255
|
+
export function bundleSource(file = 'plotagents.js') {
|
|
256
|
+
try {
|
|
257
|
+
const fs = globalThis.process?.getBuiltinModule?.('node:fs');
|
|
258
|
+
if (!fs) return null;
|
|
259
|
+
return fs.readFileSync(new URL('../dist/' + file, import.meta.url), 'utf8');
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_bind(plot, resolve);
|
|
266
|
+
_setProbe((s) => plot(s));
|
|
267
|
+
export { render };
|
|
268
|
+
|
|
269
|
+
export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo };
|
|
270
|
+
export { lint, schema, toolDefinition, themes, palettes, ramps, validatePalette, getTheme, themeFor, attach, PlotError, TYPES, squarify, makeFormat, formatNumber, formatCompact };
|
|
271
|
+
export default plot;
|
package/src/infer.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Elige el tipo de gráfico cuando el agente no lo indica (type: "auto").
|
|
2
|
+
import { fieldsOf, fieldKind } from './data.js';
|
|
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'];
|
|
5
|
+
|
|
6
|
+
// Nombres de seaborn, d3plus y otros: el agente puede usar el que conozca.
|
|
7
|
+
export const ALIASES = {
|
|
8
|
+
barplot: ['bar', { ci: true }], countplot: 'count', columns: 'column', lineplot: 'line', areaplot: 'area',
|
|
9
|
+
scatterplot: 'scatter', histplot: 'hist', histogram: 'hist', kdeplot: 'kde', density: 'kde', ecdfplot: 'ecdf',
|
|
10
|
+
lmplot: 'regplot', reg: 'regplot', regression: 'regplot', joint: 'jointplot', boxplot: 'box', violinplot: 'violin',
|
|
11
|
+
stripplot: 'strip', swarmplot: 'swarm', beeswarm: 'swarm', pointplot: 'point', spider: 'radar', doughnut: 'donut',
|
|
12
|
+
graph: 'network', force: 'network', red: 'network', 'chord-diagram': 'chord', cuerdas: 'chord',
|
|
13
|
+
populationpyramid: 'pyramid', population: 'pyramid', piramide: 'pyramid', 'pirámide': 'pyramid',
|
|
14
|
+
flow: 'flowchart', diagram: 'flowchart', process: 'flowchart', diagrama: 'flowchart',
|
|
15
|
+
map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
|
|
16
|
+
tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Tipo canónico + opciones implícitas del alias (p. ej. barplot → bar con ci). */
|
|
20
|
+
export function normalizeType(t) {
|
|
21
|
+
const k = String(t || '').toLowerCase();
|
|
22
|
+
const al = ALIASES[k];
|
|
23
|
+
if (!al) return [k, {}];
|
|
24
|
+
return Array.isArray(al) ? [al[0], al[1]] : [al, {}];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function inferType(spec, data = []) {
|
|
28
|
+
if (spec.items) return 'kpi';
|
|
29
|
+
if (spec.geo) return 'choropleth';
|
|
30
|
+
if (spec.nodes && spec.nodes.some?.((n) => n && n.type)) return 'flowchart';
|
|
31
|
+
if (spec.nodes) return 'network';
|
|
32
|
+
if (spec.matrix) return 'chord';
|
|
33
|
+
if (spec.from || spec.to) return 'sankey';
|
|
34
|
+
if (spec.path || spec.group) return 'treemap';
|
|
35
|
+
if (spec.size) return 'bubble';
|
|
36
|
+
const fields = fieldsOf(data);
|
|
37
|
+
if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
|
|
38
|
+
const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
|
|
39
|
+
const x = spec.x ?? fields[0];
|
|
40
|
+
const xk = kinds[x];
|
|
41
|
+
if (xk === 'date' || xk === 'year') return 'line';
|
|
42
|
+
if (xk === 'number') {
|
|
43
|
+
const y = spec.y ?? fields.find((f) => f !== x && kinds[f] === 'number');
|
|
44
|
+
if (y && kinds[y] === 'number') return 'scatter';
|
|
45
|
+
}
|
|
46
|
+
const cats = fields.filter((f) => kinds[f] === 'category');
|
|
47
|
+
if (cats.length >= 2 && !spec.series && fields.some((f) => kinds[f] === 'number') && spec.value) return 'heatmap';
|
|
48
|
+
return 'bar';
|
|
49
|
+
}
|