plotagents 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
/** Equirectangular (placa carré): lon/lat tal cual. Útil para mapas chicos o datos ya en grados. */
|
|
65
|
+
export function equirectangular() {
|
|
66
|
+
return ([lon, lat]) => [lon * RAD, -lat * RAD];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Coordenadas planas, sin proyectar: para GeoJSON en metros (UTM), en
|
|
71
|
+
* píxeles o dibujado a mano. `identity` deja el eje y hacia abajo (como
|
|
72
|
+
* SVG); `planar` lo invierte (y hacia arriba, como un plano cartesiano o UTM).
|
|
73
|
+
*/
|
|
74
|
+
export function identity() { const f = ([x, y]) => [x, y]; f.continuous = true; return f; }
|
|
75
|
+
export function planar() { const f = ([x, y]) => [x, -y]; f.continuous = true; return f; }
|
|
76
|
+
|
|
77
|
+
/** Cónica de áreas iguales con paralelos y meridiano central propios. */
|
|
78
|
+
export function conicEqualArea({ parallels = [29.5, 45.5], rotate, center = [0, 0] } = {}) {
|
|
79
|
+
const lon0 = rotate !== undefined ? -[].concat(rotate)[0] : center[0];
|
|
80
|
+
const f = conic(-lon0, [lon0, center[1] ?? 0], parallels);
|
|
81
|
+
return ([lon, lat]) => f(lon, lat);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const projections = {
|
|
85
|
+
mercator, equalEarth, 'equal-earth': equalEarth, albersUsa, 'albers-usa': albersUsa, albers: albersUsa,
|
|
86
|
+
equirectangular, 'plate-carree': equirectangular, latlon: equirectangular,
|
|
87
|
+
identity, none: identity, pixels: identity, planar, cartesian: planar, utm: planar,
|
|
88
|
+
conic: conicEqualArea, conicEqualArea, 'conic-equal-area': conicEqualArea,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Proyección desde el spec: un nombre ("mercator", "identity"…) o un objeto
|
|
93
|
+
* { type: "conic", parallels: [-30, -45], rotate: 72 }. Devuelve null si no
|
|
94
|
+
* se reconoce (se elige una automáticamente).
|
|
95
|
+
*/
|
|
96
|
+
export function makeProjection(p) {
|
|
97
|
+
if (!p) return null;
|
|
98
|
+
if (typeof p === 'string') { const f = projections[p] || projections[p.toLowerCase()]; return f ? f() : null; }
|
|
99
|
+
if (typeof p === 'object') { const f = projections[p.type || p.name]; return f ? f(p) : null; }
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Todas las secuencias de puntos de una geometría (anillos de polígonos,
|
|
105
|
+
* líneas y puntos): sirve para encuadrar y para saber si la forma existe.
|
|
106
|
+
*/
|
|
107
|
+
export function rings(geom) {
|
|
108
|
+
if (!geom || !geom.type) return [];
|
|
109
|
+
const c = geom.coordinates;
|
|
110
|
+
switch (geom.type) {
|
|
111
|
+
case 'Polygon': case 'MultiLineString': return c || [];
|
|
112
|
+
case 'MultiPolygon': return (c || []).flat();
|
|
113
|
+
case 'LineString': case 'MultiPoint': return c && c.length ? [c] : [];
|
|
114
|
+
case 'Point': return c && c.length >= 2 ? [[c]] : [];
|
|
115
|
+
case 'GeometryCollection': return (geom.geometries || []).flatMap(rings);
|
|
116
|
+
default: return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** ¿La geometría es de puntos o de líneas (sin área)? */
|
|
120
|
+
export const isPointGeom = (geom) => !!geom && (geom.type === 'Point' || geom.type === 'MultiPoint');
|
|
121
|
+
export const isLineGeom = (geom) => !!geom && (geom.type === 'LineString' || geom.type === 'MultiLineString');
|
|
122
|
+
export function polygonsOf(geom) {
|
|
123
|
+
if (!geom) return [];
|
|
124
|
+
if (geom.type === 'Polygon') return [geom.coordinates];
|
|
125
|
+
if (geom.type === 'MultiPolygon') return geom.coordinates;
|
|
126
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).flatMap(polygonsOf);
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Proyección ajustada a [x0, y0, x1, y1] conservando la proporción.
|
|
132
|
+
* Devuelve una función lon/lat → [x, y] con .bounds (ancho/alto usados).
|
|
133
|
+
*/
|
|
134
|
+
export function fit(features, raw, [x0, y0, x1, y1], { align = 'center' } = {}) {
|
|
135
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
136
|
+
for (const f of features) for (const r of rings(f.geometry)) for (const p of r) {
|
|
137
|
+
const [x, y] = raw(p);
|
|
138
|
+
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
|
|
139
|
+
}
|
|
140
|
+
const w = x1 - x0, h = y1 - y0;
|
|
141
|
+
const k = Math.min(w / (bx1 - bx0 || 1), h / (by1 - by0 || 1));
|
|
142
|
+
const mw = (bx1 - bx0) * k, mh = (by1 - by0) * k;
|
|
143
|
+
const ox = align === 'left' ? x0 : x0 + (w - mw) / 2;
|
|
144
|
+
const oy = y0 + (h - mh) / 2;
|
|
145
|
+
const proj = (p) => { const [x, y] = raw(p); return [ox + (x - bx0) * k, oy + (y - by0) * k]; };
|
|
146
|
+
proj.bounds = { x: ox, y: oy, w: mw, h: mh };
|
|
147
|
+
proj.continuous = !!raw.continuous;
|
|
148
|
+
proj.inset = raw.inset;
|
|
149
|
+
proj.aspect = (by1 - by0) / (bx1 - bx0 || 1);
|
|
150
|
+
return proj;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Proporción alto/ancho de la colección proyectada (para elegir el alto). */
|
|
154
|
+
export function aspectOf(features, raw) {
|
|
155
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
156
|
+
for (const f of features) for (const r of rings(f.geometry)) for (const p of r) {
|
|
157
|
+
const [x, y] = raw(p);
|
|
158
|
+
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
|
|
159
|
+
}
|
|
160
|
+
return (by1 - by0) / (bx1 - bx0 || 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const r1 = (v) => Math.round(v * 10) / 10;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Geometría → atributo d de SVG. Los anillos que saltan más de 180° de
|
|
167
|
+
* longitud (antimeridiano) se cortan para no cruzar el mapa entero. Las
|
|
168
|
+
* líneas quedan abiertas y los puntos se dibujan como círculos de radio `r`.
|
|
169
|
+
*/
|
|
170
|
+
export function pathD(geom, proj, { r = 4.5 } = {}) {
|
|
171
|
+
if (!geom) return '';
|
|
172
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).map((x) => pathD(x, proj, { r })).join('');
|
|
173
|
+
if (isPointGeom(geom)) {
|
|
174
|
+
const pts = geom.type === 'Point' ? [geom.coordinates] : geom.coordinates || [];
|
|
175
|
+
return pts.map((p) => { const [x, y] = proj(p); return `M${r1(x - r)},${r1(y)}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0`; }).join('');
|
|
176
|
+
}
|
|
177
|
+
const open = isLineGeom(geom);
|
|
178
|
+
let d = '';
|
|
179
|
+
const cut = !proj.continuous;
|
|
180
|
+
for (const ring of rings(geom)) {
|
|
181
|
+
let started = false, lastLon = null, lx = null, ly = null, n = 0;
|
|
182
|
+
for (const p of ring) {
|
|
183
|
+
if (cut && lastLon !== null && Math.abs(p[0] - lastLon) > 180) started = false;
|
|
184
|
+
lastLon = p[0];
|
|
185
|
+
const [x, y] = proj(p);
|
|
186
|
+
const X = r1(x), Y = r1(y);
|
|
187
|
+
if (started && X === lx && Y === ly) continue;
|
|
188
|
+
d += (started ? 'L' : 'M') + X + ',' + Y;
|
|
189
|
+
started = true; lx = X; ly = Y; n++;
|
|
190
|
+
}
|
|
191
|
+
if (n && !open) d += 'Z';
|
|
192
|
+
}
|
|
193
|
+
return d;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Área (en px²) y centroide del polígono proyectado más grande. */
|
|
197
|
+
export function centroid(geom, proj) {
|
|
198
|
+
if (!polygonsOf(geom).length) {
|
|
199
|
+
// Puntos y líneas: el promedio de sus vértices (área chica fija para las etiquetas).
|
|
200
|
+
const pts = rings(geom).flat().map(proj);
|
|
201
|
+
if (!pts.length) return { x: 0, y: 0, area: 0 };
|
|
202
|
+
const mid = isLineGeom(geom) ? pts[Math.floor(pts.length / 2)] : [pts.reduce((a, p) => a + p[0], 0) / pts.length, pts.reduce((a, p) => a + p[1], 0) / pts.length];
|
|
203
|
+
return { x: mid[0], y: mid[1], area: 0, point: true };
|
|
204
|
+
}
|
|
205
|
+
let best = null;
|
|
206
|
+
for (const poly of polygonsOf(geom)) {
|
|
207
|
+
const pts = poly[0].map(proj);
|
|
208
|
+
let a = 0, cx = 0, cy = 0;
|
|
209
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
210
|
+
const f = pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
|
|
211
|
+
a += f; cx += (pts[j][0] + pts[i][0]) * f; cy += (pts[j][1] + pts[i][1]) * f;
|
|
212
|
+
}
|
|
213
|
+
a /= 2;
|
|
214
|
+
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] };
|
|
215
|
+
}
|
|
216
|
+
return best ? { x: best.x, y: best.y, area: Math.abs(best.area) } : { x: 0, y: 0, area: 0 };
|
|
217
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
//
|
|
5
|
+
// `geo` acepta, además de un nombre registrado:
|
|
6
|
+
// · un GeoJSON FeatureCollection, un Feature, un arreglo de Features o una geometría suelta
|
|
7
|
+
// · un TopoJSON (Topology); `geoObject` elige el objeto (por defecto, el primero)
|
|
8
|
+
// · en el navegador, una URL (render() la descarga; ver runtime.js)
|
|
9
|
+
import { PlotError } from '../data.js';
|
|
10
|
+
|
|
11
|
+
const REG = new Map();
|
|
12
|
+
|
|
13
|
+
export function registerGeo(name, geo, aliases = []) {
|
|
14
|
+
const fc = toFeatureCollection(geo);
|
|
15
|
+
for (const n of [name, ...aliases]) REG.set(String(n).toLowerCase(), fc);
|
|
16
|
+
return fc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** ¿Parece una URL o una ruta a un archivo .json / .geojson / .topojson? */
|
|
20
|
+
export const isGeoUrl = (s) => typeof s === 'string' && (/^(https?:)?\/\//i.test(s) || /^(\.{0,2}\/|data:)/.test(s) || /\.(geo|topo)?json(\?.*)?$/i.test(s));
|
|
21
|
+
|
|
22
|
+
export function getGeo(geo, objectName) {
|
|
23
|
+
if (geo && typeof geo === 'object') return toFeatureCollection(geo, objectName);
|
|
24
|
+
if (isGeoUrl(geo)) {
|
|
25
|
+
throw new PlotError(
|
|
26
|
+
`"${geo}" es una URL o un archivo: plot() es sincrónico y no descarga nada.`,
|
|
27
|
+
"En Node, lee el archivo y pásalo como objeto: geo: JSON.parse(fs.readFileSync('mapa.geojson', 'utf8')). En el navegador, render(el, spec) sí acepta la URL."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const fc = REG.get(String(geo || '').toLowerCase());
|
|
31
|
+
if (!fc) {
|
|
32
|
+
const known = [...new Set(REG.keys())];
|
|
33
|
+
throw new PlotError(
|
|
34
|
+
`No hay una geometría registrada con el nombre "${geo}".`,
|
|
35
|
+
known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON o TopoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return fc;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const registeredGeo = () => [...new Set(REG.keys())];
|
|
42
|
+
|
|
43
|
+
const GEOMS = new Set(['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']);
|
|
44
|
+
const cache = new WeakMap();
|
|
45
|
+
|
|
46
|
+
/** Cualquier forma de GeoJSON / TopoJSON → FeatureCollection. */
|
|
47
|
+
export function toFeatureCollection(geo, objectName) {
|
|
48
|
+
if (!geo || typeof geo !== 'object') throw new PlotError('`geo` debe ser un nombre registrado, un GeoJSON o un TopoJSON.', 'geo: "chile" | "usa" | "world" | { type: "FeatureCollection", features: [...] }');
|
|
49
|
+
if (Array.isArray(geo)) return { type: 'FeatureCollection', features: geo.map(asFeature) };
|
|
50
|
+
if (geo.type === 'FeatureCollection') {
|
|
51
|
+
if (!Array.isArray(geo.features)) throw new PlotError('El FeatureCollection no tiene `features`.');
|
|
52
|
+
return geo.features.every((f) => f && f.type === 'Feature') ? geo : { ...geo, features: geo.features.map(asFeature) };
|
|
53
|
+
}
|
|
54
|
+
if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
|
|
55
|
+
if (GEOMS.has(geo.type)) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: geo }] };
|
|
56
|
+
if (geo.type === 'Topology') {
|
|
57
|
+
const key = objectName ?? '';
|
|
58
|
+
const memo = cache.get(geo) || {};
|
|
59
|
+
if (!memo[key]) { memo[key] = topoFeatures(geo, objectName); cache.set(geo, memo); }
|
|
60
|
+
return memo[key];
|
|
61
|
+
}
|
|
62
|
+
throw new PlotError(`No reconozco este \`geo\` (type: "${geo.type}").`, 'Usa un GeoJSON (FeatureCollection, Feature o geometría) o un TopoJSON (type: "Topology").');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asFeature(f) {
|
|
66
|
+
if (f && f.type === 'Feature') return f;
|
|
67
|
+
if (f && GEOMS.has(f.type)) return { type: 'Feature', properties: {}, geometry: f };
|
|
68
|
+
throw new PlotError('Cada elemento de `geo` debe ser un Feature de GeoJSON.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── TopoJSON (decodificador mínimo, sin dependencias) ──────────────────────
|
|
72
|
+
function topoFeatures(topo, objectName) {
|
|
73
|
+
const names = Object.keys(topo.objects || {});
|
|
74
|
+
if (!names.length) throw new PlotError('El TopoJSON no tiene objetos.');
|
|
75
|
+
const name = objectName ?? names[0];
|
|
76
|
+
const obj = topo.objects[name];
|
|
77
|
+
if (!obj) throw new PlotError(`El TopoJSON no tiene el objeto "${name}".`, `Objetos disponibles: ${names.join(', ')}. Indícalo con \`geoObject\`.`);
|
|
78
|
+
const tf = topo.transform;
|
|
79
|
+
// Arcos absolutos (deshace la cuantización y la codificación delta).
|
|
80
|
+
const arcs = (topo.arcs || []).map((arc) => {
|
|
81
|
+
if (!tf) return arc.map((p) => p.slice(0, 2));
|
|
82
|
+
let x = 0, y = 0;
|
|
83
|
+
return arc.map((p) => { x += p[0]; y += p[1]; return [x * tf.scale[0] + tf.translate[0], y * tf.scale[1] + tf.translate[1]]; });
|
|
84
|
+
});
|
|
85
|
+
const point = (p) => (tf ? [p[0] * tf.scale[0] + tf.translate[0], p[1] * tf.scale[1] + tf.translate[1]] : p.slice(0, 2));
|
|
86
|
+
const line = (ids) => {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const i of ids) {
|
|
89
|
+
const a = i < 0 ? arcs[~i].slice().reverse() : arcs[i];
|
|
90
|
+
if (!a) continue;
|
|
91
|
+
for (let k = out.length ? 1 : 0; k < a.length; k++) out.push(a[k]);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
const ring = (ids) => { const r = line(ids); if (r.length && (r[0][0] !== r[r.length - 1][0] || r[0][1] !== r[r.length - 1][1])) r.push(r[0]); return r; };
|
|
96
|
+
const geom = (o) => {
|
|
97
|
+
switch (o.type) {
|
|
98
|
+
case 'Point': return { type: 'Point', coordinates: point(o.coordinates) };
|
|
99
|
+
case 'MultiPoint': return { type: 'MultiPoint', coordinates: o.coordinates.map(point) };
|
|
100
|
+
case 'LineString': return { type: 'LineString', coordinates: line(o.arcs) };
|
|
101
|
+
case 'MultiLineString': return { type: 'MultiLineString', coordinates: o.arcs.map(line) };
|
|
102
|
+
case 'Polygon': return { type: 'Polygon', coordinates: o.arcs.map(ring) };
|
|
103
|
+
case 'MultiPolygon': return { type: 'MultiPolygon', coordinates: o.arcs.map((p) => p.map(ring)) };
|
|
104
|
+
default: return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const feat = (o) => ({ type: 'Feature', id: o.id, properties: { ...(o.properties || {}), ...(o.id !== undefined && (o.properties || {}).id === undefined ? { id: o.id } : {}) }, geometry: geom(o) });
|
|
108
|
+
const list = obj.type === 'GeometryCollection' ? obj.geometries.map(feat) : [feat(obj)];
|
|
109
|
+
return { type: 'FeatureCollection', features: list.filter((f) => f.geometry) };
|
|
110
|
+
}
|
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
|
+
}
|