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,559 @@
|
|
|
1
|
+
// Mapas: coroplético (color por valor) y de símbolos (círculos proporcionales).
|
|
2
|
+
// Geometrías por nombre registrado ('chile', 'usa', 'world') o GeoJSON propio.
|
|
3
|
+
// Países alargados como Chile se dibujan a la izquierda con una columna de
|
|
4
|
+
// etiquetas (nombre, barra y valor) ordenada de norte a sur; mapas anchos
|
|
5
|
+
// usan todo el ancho con etiquetas solo donde importan. Los estados de EE. UU.
|
|
6
|
+
// llevan su sigla (y el valor, si cabe); los chicos del noreste, afuera con
|
|
7
|
+
// línea guía. En el navegador: zoom con clic, rueda (+ Ctrl/⌘) o pellizco.
|
|
8
|
+
import { el, g, text } from '../svg.js';
|
|
9
|
+
import { measure, truncate } from '../text.js';
|
|
10
|
+
import { makeFormat } from '../format.js';
|
|
11
|
+
import { ramp, textOn } from '../theme.js';
|
|
12
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, aggregate } from '../data.js';
|
|
13
|
+
import { createFrame, hash } from '../frame.js';
|
|
14
|
+
import { niceStep } from '../scale.js';
|
|
15
|
+
import { getGeo } from '../geo/registry.js';
|
|
16
|
+
import { mercator, equalEarth, projections, fit, aspectOf, pathD, centroid, rings } from '../geo/projection.js';
|
|
17
|
+
import { dashedMatcher, hatcher, dashedAttrs, dashedDefaultLabel, DASH } from '../dashed.js';
|
|
18
|
+
|
|
19
|
+
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
20
|
+
|
|
21
|
+
/** Normaliza nombres para que "Región del Bío-Bío", "Biobio" y "BIOBÍO" coincidan. */
|
|
22
|
+
export function normKey(s) {
|
|
23
|
+
return String(s ?? '')
|
|
24
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/^\s*(region|provincia|comuna)\s+(metropolitana\s+de\s+)?(de\s+la\s+|del\s+|de\s+los\s+|de\s+las\s+|de\s+)?/, (m, a, metro) => (metro ? 'metropolitana' : ''))
|
|
27
|
+
.replace(/[^a-z0-9]/g, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function featureIndex(features, featureKey) {
|
|
31
|
+
const idx = new Map();
|
|
32
|
+
const add = (k, f) => { const n = normKey(k); if (n && !idx.has(n)) idx.set(n, f); };
|
|
33
|
+
for (const f of features) {
|
|
34
|
+
const p = f.properties || {};
|
|
35
|
+
if (featureKey) { add(p[featureKey], f); continue; }
|
|
36
|
+
for (const k of ['code', 'num', 'id', 'iso3', 'iso_a3', 'name', 'nameEn', 'NAME', 'nombre']) if (p[k] !== undefined && p[k] !== null) add(p[k], f);
|
|
37
|
+
for (const a of p.aliases || []) add(a, f);
|
|
38
|
+
}
|
|
39
|
+
return idx;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nameOf = (f, locale) => {
|
|
43
|
+
const p = f.properties || {};
|
|
44
|
+
return (String(locale).startsWith('en') ? p.nameEn || p.name : p.name || p.nameEn) || p.NAME || p.nombre || String(p.code ?? p.id ?? '');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Redondeo "bonito" de un corte de clase (2 cifras relativas al rango). */
|
|
48
|
+
function niceRound(v, range) {
|
|
49
|
+
if (!range) return v;
|
|
50
|
+
const step = 10 ** (Math.floor(Math.log10(range)) - 1);
|
|
51
|
+
return Math.round(v / step) * step;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function quantile(sorted, p) {
|
|
55
|
+
const h = (sorted.length - 1) * p, lo = Math.floor(h), hi = Math.ceil(h);
|
|
56
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escala de color: clases (cuantiles, intervalos iguales o cortes dados) o continua. */
|
|
60
|
+
export function colorScale(values, spec, theme) {
|
|
61
|
+
const v = values.filter(Number.isFinite).sort((a, b) => a - b);
|
|
62
|
+
const min = v[0], max = v[v.length - 1], range = max - min;
|
|
63
|
+
const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
|
|
64
|
+
const mode = Array.isArray(spec.scale) || Array.isArray(spec.thresholds) ? 'thresholds' : spec.scale || 'quantile';
|
|
65
|
+
if (mid !== undefined) {
|
|
66
|
+
const stops = theme.diverging;
|
|
67
|
+
const dev = Math.max(Math.abs(max - mid), Math.abs(min - mid)) || 1;
|
|
68
|
+
if (mode === 'continuous') return { mode, min: mid - dev, max: mid + dev, mid, color: (x) => ramp(stops, 0.5 + (x - mid) / dev / 2), stops };
|
|
69
|
+
// Clases simétricas: el paso "bonito" más chico que cubre la desviación
|
|
70
|
+
// con a lo más `classes/2` clases por lado (sin cortes fuera de los datos).
|
|
71
|
+
const perSide = Math.max(2, Math.floor((spec.classes ?? 6) / 2));
|
|
72
|
+
const mag = 10 ** Math.floor(Math.log10(dev / perSide));
|
|
73
|
+
const step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((st) => Math.ceil(dev / st - 1e-9) <= perSide) || 10 * mag;
|
|
74
|
+
const k = Math.max(1, Math.ceil(dev / step - 1e-9));
|
|
75
|
+
const breaks = Array.from({ length: 2 * k - 1 }, (_, j) => +(mid + step * (j + 1 - k)).toPrecision(12));
|
|
76
|
+
const n = 2 * k;
|
|
77
|
+
const colors = Array.from({ length: n }, (_, i) => ramp(stops, 0.5 + ((i + 0.5 - k) / k) * 0.5));
|
|
78
|
+
// Extremos simétricos (mid ± k·paso): la leyenda se lee igual hacia ambos lados.
|
|
79
|
+
return { ...classed(breaks, colors, Math.min(min, +(mid - k * step).toPrecision(12)), Math.max(max, +(mid + k * step).toPrecision(12))), mid };
|
|
80
|
+
}
|
|
81
|
+
const stops = theme.sequential;
|
|
82
|
+
if (mode === 'continuous') return { mode, min, max, color: (x) => ramp(stops.slice(1), (x - min) / (range || 1)), stops: stops.slice(1) };
|
|
83
|
+
let breaks;
|
|
84
|
+
if (mode === 'thresholds') breaks = [...(Array.isArray(spec.scale) ? spec.scale : spec.thresholds)].map(Number).sort((a, b) => a - b);
|
|
85
|
+
else {
|
|
86
|
+
const n = Math.max(2, Math.min(9, spec.classes ?? Math.min(5, v.length)));
|
|
87
|
+
if (mode === 'equal') breaks = Array.from({ length: n - 1 }, (_, k) => niceRound(min + (range * (k + 1)) / n, range));
|
|
88
|
+
else breaks = Array.from({ length: n - 1 }, (_, k) => niceRound(quantile(v, (k + 1) / n), range));
|
|
89
|
+
breaks = [...new Set(breaks)].filter((b) => b > min && b <= max);
|
|
90
|
+
}
|
|
91
|
+
const n = breaks.length + 1;
|
|
92
|
+
const t0 = theme.mode === 'dark' ? 0.26 : 0.16;
|
|
93
|
+
const colors = Array.from({ length: n }, (_, i) => ramp(stops, n === 1 ? 0.6 : t0 + ((1 - t0) * i) / (n - 1)));
|
|
94
|
+
return classed(breaks, colors, min, max);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function classed(breaks, colors, min, max) {
|
|
98
|
+
const cls = (x) => { let i = 0; while (i < breaks.length && x >= breaks[i]) i++; return i; };
|
|
99
|
+
return { mode: 'classes', breaks, colors, min, max, cls, color: (x) => colors[cls(x)] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Leyenda escalonada (o degradado), pegada al mapa. Devuelve { svg, h }. */
|
|
103
|
+
function legendBar(sc, x, y, maxW, theme, fmt, { title, missing, locale, gid, dashed }) {
|
|
104
|
+
const out = [];
|
|
105
|
+
let lx = x;
|
|
106
|
+
const size = theme.type?.axis ?? 11.5;
|
|
107
|
+
if (title) {
|
|
108
|
+
const tw = Math.min(measure(title, 12, 500), maxW * 0.4);
|
|
109
|
+
out.push(text(lx, y + 13, truncate(title, tw, 12, 500), { fill: theme.lede, fontSize: 12, fontWeight: 500 }));
|
|
110
|
+
lx += tw + 12;
|
|
111
|
+
}
|
|
112
|
+
// Anchos con su separación: cuadro + texto (+ espacio hasta la siguiente clave).
|
|
113
|
+
const missW = missing ? measure(T(locale, 'Sin dato', 'No data'), size) + 14 + (dashed ? 14 : 0) : 0;
|
|
114
|
+
const dashW = dashed ? measure(dashed.label, size) + 16 : 0;
|
|
115
|
+
// Si "Sin dato" y la clave punteada no caben junto a la escala, bajan a una segunda fila.
|
|
116
|
+
const extra = missW + dashW ? 14 + missW + dashW : 0;
|
|
117
|
+
const wrapX = 140 + extra > x + maxW - lx;
|
|
118
|
+
const barW = Math.max(120, Math.min(360, x + maxW - lx - (wrapX ? 0 : extra)));
|
|
119
|
+
const by = y + 4;
|
|
120
|
+
if (sc.mode === 'classes') {
|
|
121
|
+
const n = sc.colors.length, gap = 2;
|
|
122
|
+
const sw = (barW - gap * (n - 1)) / n;
|
|
123
|
+
// Cada clase es una clave conectada: al pasar encima se destacan sus regiones.
|
|
124
|
+
sc.colors.forEach((c, i) => out.push(g({ class: 'pa-key', 'data-color': c, style: 'cursor:pointer' }, [
|
|
125
|
+
el('rect', { x: lx + i * (sw + gap), y: by - 4, width: sw + gap, height: 18, fill: 'transparent' }),
|
|
126
|
+
el('rect', { x: lx + i * (sw + gap), y: by, width: sw, height: 10, rx: i === 0 || i === n - 1 ? 3 : 1, fill: c }),
|
|
127
|
+
])));
|
|
128
|
+
// Etiquetas en los cortes; los extremos (mín. y máx.) en las puntas.
|
|
129
|
+
// Los extremos solo se rotulan si caen fuera de los cortes (con cortes fijos, un año puede no llegar a la última clase).
|
|
130
|
+
const labels = [
|
|
131
|
+
...(sc.min < sc.breaks[0] || !sc.breaks.length ? [{ x: lx, v: sc.min, anchor: 'start' }] : []),
|
|
132
|
+
...sc.breaks.map((b, i) => ({ x: lx + (i + 1) * (sw + gap) - gap / 2, v: b, anchor: 'middle' })),
|
|
133
|
+
...(sc.max >= sc.breaks[sc.breaks.length - 1] || !sc.breaks.length ? [{ x: lx + barW, v: sc.max, anchor: 'end' }] : []),
|
|
134
|
+
];
|
|
135
|
+
let last = -Infinity;
|
|
136
|
+
for (const L of labels) {
|
|
137
|
+
const s = fmt(L.v), w = measure(s, size);
|
|
138
|
+
const left = L.anchor === 'start' ? L.x : L.anchor === 'end' ? L.x - w : L.x - w / 2;
|
|
139
|
+
if (left < last + 6) continue;
|
|
140
|
+
out.push(text(L.x, by + 24, s, { fill: theme.muted, fontSize: size, textAnchor: L.anchor }));
|
|
141
|
+
last = left + w;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const id = gid + '-grad';
|
|
145
|
+
const stops = Array.from({ length: 9 }, (_, i) => el('stop', { offset: `${i * 12.5}%`, stopColor: sc.color(sc.min + ((sc.max - sc.min) * i) / 8) }));
|
|
146
|
+
out.push(el('defs', {}, el('linearGradient', { id }, stops)));
|
|
147
|
+
out.push(el('rect', { x: lx, y: by, width: barW, height: 10, rx: 3, fill: `url(#${id})` }));
|
|
148
|
+
out.push(text(lx, by + 24, fmt(sc.min), { fill: theme.muted, fontSize: size }));
|
|
149
|
+
if (sc.mid !== undefined) out.push(text(lx + barW / 2, by + 24, fmt(sc.mid), { fill: theme.muted, fontSize: size, textAnchor: 'middle' }));
|
|
150
|
+
out.push(text(lx + barW, by + 24, fmt(sc.max), { fill: theme.muted, fontSize: size, textAnchor: 'end' }));
|
|
151
|
+
}
|
|
152
|
+
let mx = wrapX ? x : lx + barW + 14;
|
|
153
|
+
const ey = wrapX ? by + 32 : by;
|
|
154
|
+
if (missing) {
|
|
155
|
+
out.push(g({ class: 'pa-key', 'data-color': `url(#${gid}-nd)`, style: 'cursor:pointer' }, [
|
|
156
|
+
el('rect', { x: mx, y: ey, width: 10, height: 10, rx: 2, fill: `url(#${gid}-nd)`, stroke: theme.axis, strokeWidth: 0.5 }),
|
|
157
|
+
text(mx + 14, ey + 9, T(locale, 'Sin dato', 'No data'), { fill: theme.muted, fontSize: size }),
|
|
158
|
+
]));
|
|
159
|
+
mx += missW;
|
|
160
|
+
}
|
|
161
|
+
if (dashed) {
|
|
162
|
+
out.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
|
|
163
|
+
el('rect', { x: mx + 0.6, y: ey + 0.6, width: 9.8, height: 9.8, rx: 2, fill: dashed.fill, stroke: dashed.stroke, strokeWidth: 1.1, strokeDasharray: DASH.legend }),
|
|
164
|
+
text(mx + 15, ey + 9, dashed.label, { fill: theme.lede, fontSize: size }),
|
|
165
|
+
]));
|
|
166
|
+
}
|
|
167
|
+
return { svg: g({ class: 'pa-legend pa-map-legend' }, out), h: wrapX && (missing || dashed) ? 50 : 34 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Patrón rayado para regiones sin dato. */
|
|
171
|
+
const noDataPattern = (id, theme) =>
|
|
172
|
+
`<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="${theme.tint}"/><line x1="0" y1="0" x2="0" y2="5" stroke="${theme.muted}" stroke-width="1" stroke-opacity="0.35"/></pattern>`;
|
|
173
|
+
|
|
174
|
+
/** Datos + geometría emparejados. */
|
|
175
|
+
function prepare(spec, locale) {
|
|
176
|
+
const fc = getGeo(spec.geo ?? 'chile');
|
|
177
|
+
const data = ensureData(spec);
|
|
178
|
+
const fields = fieldsOf(data);
|
|
179
|
+
const key = spec.key ?? spec.region ?? spec.x ?? spec.label ?? fields.find((f) => fieldKind(data, f) === 'category');
|
|
180
|
+
const valueF = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => f !== key && fieldKind(data, f) === 'number');
|
|
181
|
+
requireField(data, key, 'key');
|
|
182
|
+
requireField(data, valueF, 'value');
|
|
183
|
+
let features = fc.features.filter((f) => f.geometry && rings(f.geometry).length);
|
|
184
|
+
if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
|
|
185
|
+
let idx = featureIndex(features, spec.featureKey);
|
|
186
|
+
// Territorios opcionales (Puerto Rico en 'usa'): solo se dibujan si hay datos para ellos.
|
|
187
|
+
if (features.some((f) => f.properties?.optional)) {
|
|
188
|
+
const used = new Set(data.map((r) => idx.get(normKey(r[key]))).filter(Boolean));
|
|
189
|
+
features = features.filter((f) => !f.properties?.optional || used.has(f) || spec.territories === true);
|
|
190
|
+
idx = featureIndex(features, spec.featureKey);
|
|
191
|
+
}
|
|
192
|
+
const buckets = new Map();
|
|
193
|
+
const unmatched = [];
|
|
194
|
+
for (const r of data) {
|
|
195
|
+
const f = idx.get(normKey(r[key]));
|
|
196
|
+
if (!f) { if (r[key] !== undefined && r[key] !== null && r[key] !== '') unmatched.push(String(r[key])); continue; }
|
|
197
|
+
if (!buckets.has(f)) buckets.set(f, []);
|
|
198
|
+
buckets.get(f).push(num(r[valueF]));
|
|
199
|
+
}
|
|
200
|
+
const values = new Map();
|
|
201
|
+
for (const [f, vs] of buckets) { const v = aggregate(vs, spec.aggregate || 'mean'); if (Number.isFinite(v)) values.set(f, v); }
|
|
202
|
+
if (Array.isArray(spec._unmatched)) spec._unmatched.push(...new Set(unmatched));
|
|
203
|
+
const ranked = [...values.entries()].sort((a, b) => b[1] - a[1]);
|
|
204
|
+
const rank = new Map(ranked.map(([f], i) => [f, i + 1]));
|
|
205
|
+
const projName = spec.projection ?? fc.projection ?? (/^(world|mundo|countries|paises|países)$/i.test(String(spec.geo)) ? 'equalEarth' : null);
|
|
206
|
+
const lonSpan = (() => { let a = Infinity, b = -Infinity; for (const f of features) for (const r of rings(f.geometry)) for (const p of r) { if (p[0] < a) a = p[0]; if (p[0] > b) b = p[0]; } return b - a; })();
|
|
207
|
+
const raw = (projName && projections[projName] ? projections[projName] : lonSpan > 100 ? equalEarth : mercator)();
|
|
208
|
+
// `dashed`: por nombre de región (con los mismos alias) o por un campo de los datos.
|
|
209
|
+
const dm = dashedMatcher(spec, data, key, null, normKey);
|
|
210
|
+
const dashed = new Set(dm ? features.filter((f) => featureKeys(f).some((k) => dm(k))) : []);
|
|
211
|
+
return { fc, features, values, rank, ranked, unmatched: [...new Set(unmatched)], key, valueF, raw, data, dashed };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Columna de etiquetas para mapas altos: una fila por región a la altura de
|
|
216
|
+
* su centroide (separadas si chocan), con línea guía, barra y valor.
|
|
217
|
+
*/
|
|
218
|
+
function labelColumn(items, x, y0, y1, w, theme, fmt, { bars = true, maxV }) {
|
|
219
|
+
const out = [];
|
|
220
|
+
const n = items.length;
|
|
221
|
+
const rowH = Math.max(12.5, Math.min(18, (y1 - y0) / Math.max(1, n)));
|
|
222
|
+
const fs = rowH < 14 ? 10.5 : 11.5;
|
|
223
|
+
// Separación vertical (orden norte → sur se conserva).
|
|
224
|
+
const rows = items.map((it) => ({ ...it, y: it.cy })).sort((a, b) => a.cy - b.cy);
|
|
225
|
+
for (let i = 0; i < rows.length; i++) rows[i].y = Math.max(rows[i].y, i ? rows[i - 1].y + rowH : y0 + rowH / 2);
|
|
226
|
+
for (let i = rows.length - 1; i >= 0; i--) { const lim = i === rows.length - 1 ? y1 - rowH / 2 : rows[i + 1].y - rowH; if (rows[i].y > lim) rows[i].y = lim; }
|
|
227
|
+
for (let i = 0; i < rows.length; i++) if (rows[i].y < y0 + rowH / 2 + i * rowH) rows[i].y = y0 + rowH / 2 + i * rowH;
|
|
228
|
+
const nameW = Math.min(w * (bars ? 0.46 : 0.68), Math.max(...rows.map((r) => measure(r.name, fs))));
|
|
229
|
+
const valW = Math.max(...rows.map((r) => measure(r.label, fs, 600)));
|
|
230
|
+
const barX = x + nameW + 8;
|
|
231
|
+
const diverging = rows.some((r) => r.v < 0);
|
|
232
|
+
const barMax = Math.max(0, w - nameW - valW * (diverging ? 2 : 1) - (diverging ? 26 : 18));
|
|
233
|
+
const showBars = bars && barMax >= 36;
|
|
234
|
+
const negMax = diverging ? Math.max(0, ...rows.filter((r) => r.v < 0).map((r) => -r.v)) : 0;
|
|
235
|
+
const posMax = Math.max(0, ...rows.filter((r) => r.v > 0).map((r) => r.v));
|
|
236
|
+
// Con valores negativos, eje en cero y barras hacia ambos lados.
|
|
237
|
+
const zeroX = diverging ? barX + valW + 6 + (barMax * negMax) / (negMax + posMax || 1) : barX;
|
|
238
|
+
const scaleW = diverging ? barMax / (negMax + posMax || 1) : barMax / (maxV || 1);
|
|
239
|
+
if (showBars && diverging) out.push(el('line', { x1: zeroX, x2: zeroX, y1: y0, y2: y1, stroke: theme.axis, strokeWidth: 1 }));
|
|
240
|
+
for (const r of rows) {
|
|
241
|
+
const parts = [];
|
|
242
|
+
// Línea guía: horizontal desde el centroide y quiebre suave a la fila.
|
|
243
|
+
parts.push(el('path', { d: `M${r.cx.toFixed(1)},${r.cy.toFixed(1)}L${(x - 14).toFixed(1)},${r.cy.toFixed(1)}L${(x - 4).toFixed(1)},${r.y.toFixed(1)}`, fill: 'none', stroke: theme.muted, strokeWidth: 0.6, opacity: 0.55 }));
|
|
244
|
+
parts.push(el('circle', { cx: r.cx, cy: r.cy, r: 1.8, fill: theme.ink, opacity: 0.7 }));
|
|
245
|
+
parts.push(text(x, r.y + fs * 0.36, truncate(r.name, nameW, fs), { fill: r.missing ? theme.muted : theme.lede, fontSize: fs }));
|
|
246
|
+
if (r.missing) parts.push(text(showBars ? barX : x + nameW + 8, r.y + fs * 0.36, r.label, { fill: theme.muted, fontSize: fs }));
|
|
247
|
+
else if (showBars) {
|
|
248
|
+
const bw = Math.max(2, Math.abs(r.v) * scaleW);
|
|
249
|
+
const bx = r.v < 0 ? zeroX - bw : zeroX;
|
|
250
|
+
parts.push(el('rect', { x: bx, y: r.y - 3.5, width: bw, height: 7, rx: 2, fill: r.barColor || r.color }));
|
|
251
|
+
parts.push(text(r.v < 0 ? bx - 6 : bx + bw + 6, r.y + fs * 0.36, r.label, { fill: theme.ink, fontSize: fs, fontWeight: 600, textAnchor: r.v < 0 ? 'end' : 'start' }));
|
|
252
|
+
} else parts.push(text(x + nameW + 8, r.y + fs * 0.36, r.label, { fill: theme.ink, fontSize: fs, fontWeight: 600 }));
|
|
253
|
+
out.push(g({ class: 'pa-m', 'data-tip': r.tip }, parts));
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Etiquetas sobre el mapa (mapas anchos): solo las elegidas y sin choques. */
|
|
259
|
+
function mapLabels(items, theme, placedBoxes = []) {
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const it of items) {
|
|
262
|
+
const w = Math.max(measure(it.name, 11, 600), measure(it.label, 11));
|
|
263
|
+
const box = [it.cx - w / 2 - 2, it.cy - 13, it.cx + w / 2 + 2, it.cy + 13];
|
|
264
|
+
if (placedBoxes.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
|
|
265
|
+
placedBoxes.push(box);
|
|
266
|
+
const halo = { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round', textAnchor: 'middle' };
|
|
267
|
+
out.push(g({ class: 'pa-map-label', 'pointer-events': 'none' }, [
|
|
268
|
+
text(it.cx, it.cy - 2, it.name, { fill: theme.ink, fontSize: 11, fontWeight: 600, ...halo }),
|
|
269
|
+
text(it.cx, it.cy + 11, it.label, { fill: theme.lede, fontSize: 11, ...halo }),
|
|
270
|
+
]));
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function chooseLabeled(m, spec, locale, defaultN) {
|
|
276
|
+
const hl = spec.highlight === undefined ? [] : [].concat(spec.highlight).map(normKey);
|
|
277
|
+
const isHl = (f) => hl.length && featureIndexHas(f, hl);
|
|
278
|
+
if (Array.isArray(spec.labels)) { const want = spec.labels.map(normKey); return m.features.filter((f) => featureIndexHas(f, want)); }
|
|
279
|
+
const n = typeof spec.labels === 'number' ? spec.labels : spec.labels === false ? 0 : defaultN;
|
|
280
|
+
const top = m.ranked.slice(0, n).map(([f]) => f);
|
|
281
|
+
return [...new Set([...m.features.filter(isHl), ...top])];
|
|
282
|
+
}
|
|
283
|
+
function featureKeys(f) {
|
|
284
|
+
const p = f.properties || {};
|
|
285
|
+
return [p.code, p.num, p.id, p.iso3, p.fips, p.name, p.nameEn, ...(p.aliases || [])].filter((v) => v !== undefined && v !== null).map(normKey);
|
|
286
|
+
}
|
|
287
|
+
function featureIndexHas(f, keys) {
|
|
288
|
+
const own = featureKeys(f);
|
|
289
|
+
return keys.some((k) => own.includes(k));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Siglas sobre el mapa (estados de EE. UU. u otra geometría con `abbr`): la
|
|
294
|
+
* sigla va dentro si cabe, con el valor debajo cuando hay espacio. Las
|
|
295
|
+
* marcadas `callout` (estados chicos) van en una columna a la derecha con
|
|
296
|
+
* línea guía, ordenadas por altura.
|
|
297
|
+
*/
|
|
298
|
+
function abbrLabels(items, proj, theme, { calloutX, showValues, top, bottom }) {
|
|
299
|
+
const inside = [], callouts = [];
|
|
300
|
+
const fs = Math.max(8.5, Math.min(11, proj.bounds.w / 62));
|
|
301
|
+
for (const it of items) {
|
|
302
|
+
const p = it.f.properties || {};
|
|
303
|
+
if (!p.abbr) continue;
|
|
304
|
+
const at = p.labelAt ? proj(p.labelAt) : [it.cx, it.cy];
|
|
305
|
+
if (p.callout && calloutX) { callouts.push({ ...it, ax: it.cx, ay: it.cy }); continue; }
|
|
306
|
+
const s = Math.sqrt(it.area);
|
|
307
|
+
const aw = measure(p.abbr, fs, 600);
|
|
308
|
+
if (aw + 3 > s * 0.95) {
|
|
309
|
+
// Islas chicas (Hawái, Puerto Rico): la sigla al lado, fuera de la forma.
|
|
310
|
+
if (!proj.inset || !proj.inset(p.labelAt || it.f.geometry && rings(it.f.geometry)[0][0])) continue;
|
|
311
|
+
let x1 = -Infinity;
|
|
312
|
+
for (const r of rings(it.f.geometry)) for (const pt of r) x1 = Math.max(x1, proj(pt)[0]);
|
|
313
|
+
inside.push(text(x1 + 5, at[1] + fs * 0.36, p.abbr, { fill: theme.lede, fontSize: fs, fontWeight: 600 }));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const ink = it.dashedShape ? theme.ink : textOn(it.color, theme);
|
|
317
|
+
const val = showValues && !it.missing && s > fs * 3.4 && measure(it.label, fs - 1) + 4 < s * 0.92;
|
|
318
|
+
const y0 = at[1] + (val ? -1 : fs * 0.36);
|
|
319
|
+
const halo = it.dashedShape ? { stroke: theme.surface, strokeWidth: 2.5, paintOrder: 'stroke', strokeLinejoin: 'round' } : {};
|
|
320
|
+
inside.push(text(at[0], y0, p.abbr, { fill: ink, fontSize: fs, fontWeight: 600, textAnchor: 'middle', ...halo }));
|
|
321
|
+
if (val) inside.push(text(at[0], y0 + fs + 0.5, it.label, { fill: ink, fontSize: fs - 1, textAnchor: 'middle', opacity: 0.88, ...halo }));
|
|
322
|
+
}
|
|
323
|
+
const out = [g({ class: 'pa-map-label pa-zoom-hide', 'pointer-events': 'none' }, inside)];
|
|
324
|
+
if (callouts.length) {
|
|
325
|
+
const rowH = fs + 5.5;
|
|
326
|
+
const rows = callouts.sort((a, b) => a.ay - b.ay).map((c) => ({ ...c, y: c.ay }));
|
|
327
|
+
for (let i = 0; i < rows.length; i++) rows[i].y = Math.max(rows[i].y, i ? rows[i - 1].y + rowH : top + rowH / 2);
|
|
328
|
+
for (let i = rows.length - 1; i >= 0; i--) { const lim = i === rows.length - 1 ? bottom - rowH / 2 : rows[i + 1].y - rowH; if (rows[i].y > lim) rows[i].y = lim; }
|
|
329
|
+
const parts = rows.map((r) => {
|
|
330
|
+
const p = r.f.properties;
|
|
331
|
+
const ax = r.ax.toFixed(1), ay = r.ay.toFixed(1);
|
|
332
|
+
return g({ class: 'pa-m', 'data-tip': r.tip, 'data-color': r.color }, [
|
|
333
|
+
el('path', { d: `M${ax},${ay}L${(calloutX - 12).toFixed(1)},${r.y.toFixed(1)}L${(calloutX - 3).toFixed(1)},${r.y.toFixed(1)}`, fill: 'none', stroke: theme.muted, strokeWidth: 0.6, opacity: 0.7 }),
|
|
334
|
+
el('circle', { cx: ax, cy: ay, r: 1.6, fill: theme.ink, opacity: 0.75 }),
|
|
335
|
+
el('rect', { x: calloutX, y: r.y - 4.5, width: 9, height: 9, rx: 2, ...(r.dashedShape ? { fill: r.fill, stroke: r.stroke, strokeWidth: 1, strokeDasharray: DASH.legend } : { fill: r.missing ? r.fill : r.color }) }),
|
|
336
|
+
text(calloutX + 13, r.y + fs * 0.36, p.abbr, { fill: theme.ink, fontSize: fs, fontWeight: 600 }),
|
|
337
|
+
showValues ? text(calloutX + 18 + measure(p.abbr, fs, 600), r.y + fs * 0.36, r.label, { fill: theme.lede, fontSize: fs }) : '',
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
out.push(g({ class: 'pa-callouts pa-zoom-hide' }, parts));
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Recuadros (Alaska, Hawái, Puerto Rico): un marco fino y discontinuo. */
|
|
346
|
+
function insetFrames(features, proj, theme) {
|
|
347
|
+
if (!proj.inset) return '';
|
|
348
|
+
const boxes = new Map();
|
|
349
|
+
for (const f of features) for (const r of rings(f.geometry)) {
|
|
350
|
+
const k = proj.inset(r[0]);
|
|
351
|
+
if (!k) continue;
|
|
352
|
+
const b = boxes.get(k) || [Infinity, Infinity, -Infinity, -Infinity];
|
|
353
|
+
for (const pt of r) { const [x, y] = proj(pt); b[0] = Math.min(b[0], x); b[1] = Math.min(b[1], y); b[2] = Math.max(b[2], x); b[3] = Math.max(b[3], y); }
|
|
354
|
+
boxes.set(k, b);
|
|
355
|
+
}
|
|
356
|
+
const pad = 7;
|
|
357
|
+
return g({ class: 'pa-insets', 'pointer-events': 'none' }, [...boxes.values()].map((b) => el('rect', { x: b[0] - pad, y: b[1] - pad, width: b[2] - b[0] + 2 * pad, height: b[3] - b[1] + 2 * pad, rx: 6, fill: 'none', stroke: theme.axis, strokeWidth: 0.8, strokeDasharray: '2 3' })));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function baseChart(spec, theme, kind) {
|
|
361
|
+
const locale = spec.locale || 'es';
|
|
362
|
+
const m = prepare(spec, locale);
|
|
363
|
+
const vals = [...m.values.values()];
|
|
364
|
+
const fmt = makeFormat(spec.format, locale, vals);
|
|
365
|
+
const W = spec.width || 720;
|
|
366
|
+
const aspect = aspectOf(m.features, m.raw);
|
|
367
|
+
const tall = aspect > 1.6 && m.features.length <= 40;
|
|
368
|
+
// Alto por defecto según la forma del territorio.
|
|
369
|
+
const defH = tall ? (W < 560 ? 660 : 740) : Math.round(W * Math.min(1.1, aspect) + 150);
|
|
370
|
+
const frame = createFrame(spec, theme, { height: defH });
|
|
371
|
+
let area = frame.layout([]);
|
|
372
|
+
const gid = 'pa-map-' + Math.abs(hash(String(spec.title) + kind + W)).toString(36);
|
|
373
|
+
const missingAny = spec.missing !== 'plain' && m.features.some((f) => !m.values.has(f));
|
|
374
|
+
const body = [];
|
|
375
|
+
let sc = null;
|
|
376
|
+
const H = hatcher(theme, gid);
|
|
377
|
+
const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
|
|
378
|
+
const dashedKey = m.dashed.size && spec.legend !== false ? { label: dashedLabel, fill: H.fill(theme.muted), stroke: H.stroke(theme.muted) } : null;
|
|
379
|
+
if (kind === 'choropleth' && vals.length) {
|
|
380
|
+
sc = colorScale(vals, spec, theme);
|
|
381
|
+
const lg = legendBar(sc, area.x, area.y, area.w, theme, fmt.tick ? (v) => fmt(v) : fmt, { title: spec.legendTitle ?? humanize(m.valueF), missing: missingAny, locale, gid, dashed: dashedKey });
|
|
382
|
+
body.push(lg.svg);
|
|
383
|
+
area = { ...area, y: area.y + lg.h + 8, h: area.h - lg.h - 8 };
|
|
384
|
+
}
|
|
385
|
+
// Mapa de símbolos ancho: la leyenda de tamaños va arriba, en fila (abajo chocaría con Alaska o la Antártida).
|
|
386
|
+
let rMax = null;
|
|
387
|
+
if (kind === 'symbol') {
|
|
388
|
+
const N = m.values.size;
|
|
389
|
+
const wEst = area.w, hEst = wEst * Math.min(1.1, aspect);
|
|
390
|
+
rMax = spec.maxRadius ?? Math.max(8, Math.min(30, Math.sqrt(tall ? (area.h / aspect) * area.h : wEst * hEst) / (tall ? 9 : N > 30 ? 22 : 16)));
|
|
391
|
+
}
|
|
392
|
+
let lx = area.x;
|
|
393
|
+
const rowItems = [];
|
|
394
|
+
if (kind === 'symbol' && !tall && vals.length) {
|
|
395
|
+
const maxV = Math.max(...vals.map(Math.abs), 0) || 1;
|
|
396
|
+
const size = theme.type?.axis ?? 11.5;
|
|
397
|
+
const base = area.y + 2 * rMax + 2;
|
|
398
|
+
for (const v of [maxV / 16, maxV / 4, maxV]) {
|
|
399
|
+
const r = Math.sqrt(v / maxV) * rMax;
|
|
400
|
+
rowItems.push(el('circle', { cx: lx + r, cy: base - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 0.8 }));
|
|
401
|
+
rowItems.push(text(lx + 2 * r + 5, base - 1, fmt(v), { fill: theme.muted, fontSize: size }));
|
|
402
|
+
lx += 2 * r + 12 + measure(fmt(v), size);
|
|
403
|
+
}
|
|
404
|
+
lx += 10;
|
|
405
|
+
}
|
|
406
|
+
if (dashedKey && !(kind === 'choropleth' && vals.length)) {
|
|
407
|
+
const size = theme.type?.axis ?? 11.5;
|
|
408
|
+
const ky = rowItems.length ? area.y + 2 * rMax - 9 : area.y + 4.6;
|
|
409
|
+
rowItems.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
|
|
410
|
+
el('rect', { x: lx + 0.6, y: ky, width: 9.8, height: 9.8, rx: 2, fill: dashedKey.fill, stroke: dashedKey.stroke, strokeWidth: 1.1, strokeDasharray: DASH.legend }),
|
|
411
|
+
text(lx + 15, ky + 8.4, dashedLabel, { fill: theme.lede, fontSize: size }),
|
|
412
|
+
]));
|
|
413
|
+
}
|
|
414
|
+
if (rowItems.length) {
|
|
415
|
+
const h = kind === 'symbol' && !tall ? 2 * rMax + 12 : 26;
|
|
416
|
+
body.push(g({ class: 'pa-legend pa-size-legend' }, rowItems));
|
|
417
|
+
area = { ...area, y: area.y + h, h: area.h - h };
|
|
418
|
+
}
|
|
419
|
+
// Columna de siglas para estados chicos (EE. UU.): se reserva a la derecha.
|
|
420
|
+
const callouts = kind === 'choropleth' && !tall && spec.labels !== false ? m.features.filter((f) => f.properties?.callout && f.properties?.abbr) : [];
|
|
421
|
+
const fsC = Math.max(8.5, Math.min(11, (area.w * 0.9) / 62));
|
|
422
|
+
const calloutW = callouts.length
|
|
423
|
+
? 18 + Math.max(...callouts.map((f) => measure(f.properties.abbr, fsC, 600) + (kind === 'choropleth' && m.values.has(f) ? measure(fmt(m.values.get(f)), fsC) + 4 : 0)))
|
|
424
|
+
: 0;
|
|
425
|
+
// Mapa ancho: el alto se ajusta al ancho si el agente no fijó `height`.
|
|
426
|
+
if (!tall && spec.height === undefined) {
|
|
427
|
+
const mapH = (area.w - calloutW) * Math.min(1.1, aspect);
|
|
428
|
+
frame.setHeight(area.y + mapH + frame.chrome);
|
|
429
|
+
area = { ...area, h: frame.bottom - area.y };
|
|
430
|
+
}
|
|
431
|
+
let box;
|
|
432
|
+
if (tall) {
|
|
433
|
+
const colMin = W < 480 ? 0.56 : 0.5;
|
|
434
|
+
let mapW = Math.min(area.w * (1 - colMin), area.h / aspect);
|
|
435
|
+
box = [area.x, area.y, area.x + mapW, area.y + area.h];
|
|
436
|
+
} else box = [area.x, area.y, area.x + area.w - calloutW, area.y + area.h];
|
|
437
|
+
const proj = fit(m.features, m.raw, box, { align: tall ? 'left' : 'center' });
|
|
438
|
+
const calloutX = calloutW ? proj.bounds.x + proj.bounds.w + 14 : 0;
|
|
439
|
+
return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const rankText = (r, n, locale) => (String(locale).startsWith('en') ? `#${r} of ${n}` : `${r}.º de ${n}`);
|
|
443
|
+
|
|
444
|
+
/** Capa del mapa con recorte: el navegador la escala y desplaza (zoom). */
|
|
445
|
+
function zoomLayer(B, children) {
|
|
446
|
+
const { area, gid } = B;
|
|
447
|
+
const box = [area.x, area.y, area.w, area.h].map((v) => Math.round(v * 10) / 10);
|
|
448
|
+
return [
|
|
449
|
+
el('clipPath', { id: `${gid}-clip` }, el('rect', { x: box[0], y: box[1] - 2, width: box[2], height: box[3] + 4 })),
|
|
450
|
+
// Fondo transparente: la rueda, el arrastre y el pellizco funcionan también sobre el mar.
|
|
451
|
+
g({ class: 'pa-zoom-clip', 'clip-path': `url(#${gid}-clip)` }, [el('rect', { x: box[0], y: box[1], width: box[2], height: box[3], fill: 'transparent' }), g({ class: 'pa-zoom', 'data-zoom': box.join(',') }, children)]),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function choropleth(spec, theme) {
|
|
456
|
+
const B = baseChart(spec, theme, 'choropleth');
|
|
457
|
+
const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX } = B;
|
|
458
|
+
const hl = spec.highlight === undefined ? null : [].concat(spec.highlight).map(normKey);
|
|
459
|
+
const isHl = (f) => hl && featureIndexHas(f, hl);
|
|
460
|
+
const N = m.values.size;
|
|
461
|
+
const regions = [], dashedRegions = [], outlines = [];
|
|
462
|
+
const items = [];
|
|
463
|
+
for (const f of m.features) {
|
|
464
|
+
const v = m.values.get(f);
|
|
465
|
+
const has = v !== undefined;
|
|
466
|
+
const base = has ? sc.color(v) : null;
|
|
467
|
+
const isDashed = m.dashed.has(f);
|
|
468
|
+
const name = nameOf(f, locale);
|
|
469
|
+
const tipTxt = (has ? `${name}\n${fmt(v)} · ${rankText(m.rank.get(f), N, locale)}` : `${name}\n${T(locale, 'Sin dato', 'No data')}`) + (isDashed ? `\n${dashedLabel}` : '');
|
|
470
|
+
const d = pathD(f.geometry, proj);
|
|
471
|
+
const common = { class: 'pa-m', d, strokeLinejoin: 'round', opacity: hl && !isHl(f) ? 0.5 : null, 'data-tip': tipTxt, 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? name) };
|
|
472
|
+
let fill = has ? base : spec.missing === 'plain' ? theme.tint : `url(#${gid}-nd)`;
|
|
473
|
+
let stroke = null;
|
|
474
|
+
if (isDashed && has) {
|
|
475
|
+
// Rayado del color de su clase + borde discontinuo: se lee igual en la escala.
|
|
476
|
+
const a = dashedAttrs(H, base, { width: 1.2 });
|
|
477
|
+
fill = a.fill; stroke = a.stroke;
|
|
478
|
+
dashedRegions.push(el('path', { ...common, ...a }));
|
|
479
|
+
} else if (isDashed) {
|
|
480
|
+
stroke = theme.muted;
|
|
481
|
+
dashedRegions.push(el('path', { ...common, fill, stroke, strokeWidth: 1.1, strokeDasharray: DASH.outline, 'data-dashed': '1', 'data-color': fill }));
|
|
482
|
+
} else regions.push(el('path', { ...common, fill, stroke: theme.surface, strokeWidth: 0.75, 'data-color': fill }));
|
|
483
|
+
if (isHl(f)) outlines.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', 'pointer-events': 'none' }));
|
|
484
|
+
const c = centroid(f.geometry, proj);
|
|
485
|
+
items.push({ f, name, v, cx: c.x, cy: c.y, area: c.area, label: has ? fmt(v) : '—', color: has ? base : theme.tint, fill, stroke, dashedShape: isDashed, missing: !has, tip: tipTxt });
|
|
486
|
+
}
|
|
487
|
+
const layer = [g({ class: 'pa-regions' }, regions), dashedRegions.length ? g({ class: 'pa-regions pa-dashed' }, dashedRegions) : '', g({ class: 'pa-outlines' }, outlines), insetFrames(m.features, proj, theme)];
|
|
488
|
+
const after = [];
|
|
489
|
+
if (tall && spec.labels !== false) {
|
|
490
|
+
const x = proj.bounds.x + proj.bounds.w + 26;
|
|
491
|
+
const maxV = Math.max(...[...m.values.values()].map(Math.abs));
|
|
492
|
+
after.push(...labelColumn(items, x, area.y, area.y + area.h, area.x + area.w - x, theme, fmt, { bars: spec.bars !== false, maxV }));
|
|
493
|
+
} else if (spec.labels !== false && m.features.some((f) => f.properties?.abbr) && !Array.isArray(spec.labels) && typeof spec.labels !== 'number') {
|
|
494
|
+
const [lab, ...rest] = abbrLabels(items, proj, theme, { calloutX, showValues: spec.values !== false, top: area.y, bottom: area.y + area.h });
|
|
495
|
+
layer.push(lab);
|
|
496
|
+
after.push(...rest);
|
|
497
|
+
} else {
|
|
498
|
+
const chosen = new Set(chooseLabeled(m, spec, locale, m.features.length <= 30 ? m.features.length : 0));
|
|
499
|
+
const ordered = items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.area - a.area);
|
|
500
|
+
layer.push(g({ class: 'pa-zoom-hide' }, mapLabels(ordered, theme)));
|
|
501
|
+
}
|
|
502
|
+
body.push(...(tall ? layer : zoomLayer(B, layer)), ...after);
|
|
503
|
+
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map'));
|
|
504
|
+
return frame.finish(body, { desc, type: 'choropleth', defs: noDataPattern(`${gid}-nd`, theme) + H.defs() });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Mapa de símbolos: círculos de área proporcional en el centroide. */
|
|
508
|
+
export function symbolMap(spec, theme) {
|
|
509
|
+
const B = baseChart(spec, theme, 'symbol');
|
|
510
|
+
const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax } = B;
|
|
511
|
+
const N = m.values.size;
|
|
512
|
+
const maxV = Math.max(...[...m.values.values()].map(Math.abs), 0) || 1;
|
|
513
|
+
const color = (spec.colors && Object.values(spec.colors)[0]) || theme.categorical[0];
|
|
514
|
+
const layer = [g({ class: 'pa-regions pa-base' }, m.features.map((f) => el('path', { d: pathD(f.geometry, proj), fill: theme.tint, stroke: theme.surface, strokeWidth: 0.75, strokeLinejoin: 'round', 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? nameOf(f, locale)) }))), insetFrames(m.features, proj, theme)];
|
|
515
|
+
const items = m.features.map((f) => {
|
|
516
|
+
const c = centroid(f.geometry, proj);
|
|
517
|
+
const v = m.values.get(f);
|
|
518
|
+
const name = nameOf(f, locale);
|
|
519
|
+
const isDashed = m.dashed.has(f);
|
|
520
|
+
const at = f.properties?.labelAt ? proj(f.properties.labelAt) : [c.x, c.y];
|
|
521
|
+
return { f, name, v, cx: at[0], cy: at[1], area: c.area, r: v === undefined ? 0 : Math.sqrt(Math.abs(v) / maxV) * rMax, label: v === undefined ? '—' : fmt(v), color, missing: v === undefined, dashedShape: isDashed,
|
|
522
|
+
tip: (v === undefined ? `${name}\n${T(locale, 'Sin dato', 'No data')}` : `${name}\n${fmt(v)} · ${rankText(m.rank.get(f), N, locale)}`) + (isDashed ? `\n${dashedLabel}` : '') };
|
|
523
|
+
});
|
|
524
|
+
const circles = items.filter((it) => !it.missing).sort((a, b) => b.r - a.r)
|
|
525
|
+
.map((it) => el('circle', { class: 'pa-m', cx: it.cx, cy: it.cy, r: Math.max(1.5, it.r), 'data-tip': it.tip, 'data-color': color,
|
|
526
|
+
...(it.dashedShape ? { ...dashedAttrs(H, color, { width: 1.3 }) } : { fill: color, fillOpacity: 0.75, stroke: theme.surface, strokeWidth: 1.5 }) }));
|
|
527
|
+
layer.push(g({ class: 'pa-symbols' }, circles));
|
|
528
|
+
// Leyenda de tamaños (tres círculos anidados) en la esquina libre.
|
|
529
|
+
const sizes = [maxV, maxV / 4, maxV / 16].map((v) => ({ v, r: Math.sqrt(v / maxV) * rMax }));
|
|
530
|
+
const lx = tall ? proj.bounds.x + proj.bounds.w + 26 : area.x + 4, ly = tall ? area.y + area.h - 4 : area.y + area.h - 4;
|
|
531
|
+
const leg = sizes.map((s) => [
|
|
532
|
+
el('circle', { cx: lx + rMax, cy: ly - s.r, r: s.r, fill: 'none', stroke: theme.muted, strokeWidth: 0.75 }),
|
|
533
|
+
el('line', { x1: lx + rMax, x2: lx + rMax * 2 + 8, y1: ly - 2 * s.r, y2: ly - 2 * s.r, stroke: theme.muted, strokeWidth: 0.5 }),
|
|
534
|
+
text(lx + rMax * 2 + 11, ly - 2 * s.r + 3.5, fmt(s.v), { fill: theme.muted, fontSize: 10.5 }),
|
|
535
|
+
]);
|
|
536
|
+
if (tall && spec.labels !== false) {
|
|
537
|
+
const x = proj.bounds.x + proj.bounds.w + 26;
|
|
538
|
+
const legH = rMax * 2 + 12;
|
|
539
|
+
body.push(...layer, ...labelColumn(items.map((it) => ({ ...it, barColor: color })), x, area.y, area.y + area.h - legH, area.x + area.w - x, theme, fmt, { bars: spec.bars !== false, maxV }));
|
|
540
|
+
} else {
|
|
541
|
+
const chosen = new Set(chooseLabeled(m, spec, locale, Math.min(5, m.values.size)));
|
|
542
|
+
layer.push(g({ class: 'pa-zoom-hide' }, mapLabels(items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.v - a.v).map((it) => ({ ...it, cy: it.cy - it.r - 10 })), theme)));
|
|
543
|
+
body.push(...zoomLayer(B, layer));
|
|
544
|
+
}
|
|
545
|
+
if (tall) body.push(g({ class: 'pa-legend pa-size-legend' }, leg));
|
|
546
|
+
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map'));
|
|
547
|
+
return frame.finish(body, { desc, type: 'symbolmap', defs: H.defs() });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function describeMap(m, fmt, locale, what) {
|
|
551
|
+
const en = String(locale).startsWith('en');
|
|
552
|
+
if (!m.ranked.length) return `${what}.`;
|
|
553
|
+
const [hiF, hiV] = m.ranked[0], [loF, loV] = m.ranked[m.ranked.length - 1];
|
|
554
|
+
let s = en
|
|
555
|
+
? `${what} of ${humanize(m.valueF)} across ${m.values.size} areas. Highest: ${nameOf(hiF, locale)} (${fmt(hiV)}). Lowest: ${nameOf(loF, locale)} (${fmt(loV)}).`
|
|
556
|
+
: `${what} de ${humanize(m.valueF).toLowerCase()} en ${m.values.size} territorios. Mayor: ${nameOf(hiF, locale)} (${fmt(hiV)}). Menor: ${nameOf(loF, locale)} (${fmt(loV)}).`;
|
|
557
|
+
if (m.unmatched.length) s += en ? ` Unmatched: ${m.unmatched.slice(0, 8).join(', ')}.` : ` Sin coincidencia: ${m.unmatched.slice(0, 8).join(', ')}.`;
|
|
558
|
+
return s;
|
|
559
|
+
}
|