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.
Files changed (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
@@ -0,0 +1,807 @@
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, colorMap } from '../theme.js';
12
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, aggregate, PlotError } 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, planar, projections, makeProjection, fit, aspectOf, pathD, centroid, rings, isPointGeom, isLineGeom } 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(featureKey === 'id' && p.id === undefined ? f.id : p[featureKey], f); continue; }
36
+ if (f.id !== undefined && f.id !== null) add(f.id, f);
37
+ 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);
38
+ for (const a of p.aliases || []) add(a, f);
39
+ }
40
+ return idx;
41
+ }
42
+
43
+ const nameOf = (f, locale) => {
44
+ const p = f.properties || {};
45
+ return (String(locale).startsWith('en') ? p.nameEn || p.name : p.name || p.nameEn) || p.NAME || p.nombre || String(p.code ?? p.id ?? f.id ?? '');
46
+ };
47
+
48
+ // Propiedades que suelen traer el nombre en GeoJSON de otras fuentes (INE, IGM, Natural Earth, GADM, censos).
49
+ const NAME_PROPS = ['name', 'nameEn', 'NAME', 'nombre', 'Nombre', 'NOMBRE', 'name_en', 'NAME_EN', 'NAME_1', 'NAME_2', 'NAME_3', 'ADMIN', 'admin', 'NOM_REG', 'NOM_REGION', 'Region', 'REGION', 'region', 'NOM_PROV', 'NOM_COM', 'NOM_COMUNA', 'COMUNA', 'Comuna', 'comuna', 'provincia', 'PROVINCIA', 'label', 'title', 'NAMELSAD', 'GEN', 'NUTS_NAME', 'shapeName'];
50
+ const primitive = (v) => typeof v === 'string' || typeof v === 'number';
51
+
52
+ /**
53
+ * GeoJSON ajeno: busca la propiedad cuyos valores calzan con más filas de
54
+ * los datos (p. ej. "CUT_REG", "NOM_COM" o "GEOID"). Devuelve null si
55
+ * ninguna mejora lo que ya calzaba.
56
+ */
57
+ function bestProperty(features, keys, already) {
58
+ const want = new Set(keys.map(normKey).filter(Boolean));
59
+ if (!want.size) return null;
60
+ const props = new Set();
61
+ for (const f of features.slice(0, 80)) for (const [k, v] of Object.entries(f.properties || {})) if (primitive(v)) props.add(k);
62
+ let best = null, bestN = already;
63
+ for (const k of props) {
64
+ let n = 0;
65
+ const seen = new Set(features.map((f) => normKey(f.properties?.[k])));
66
+ for (const w of want) if (seen.has(w)) n++;
67
+ if (n > bestN) { best = k; bestN = n; }
68
+ }
69
+ return best;
70
+ }
71
+
72
+ /** Copia las features con `name` resuelto (nameKey explícito o adivinado). */
73
+ function withNames(features, nameKey) {
74
+ return features.map((f) => {
75
+ const p = f.properties || {};
76
+ const v = p[nameKey];
77
+ return primitive(v) && p.name !== v ? { ...f, properties: { ...p, name: String(v) } } : f;
78
+ });
79
+ }
80
+
81
+ /** Redondeo "bonito" de un corte de clase (2 cifras relativas al rango). */
82
+ function niceRound(v, range) {
83
+ if (!range) return v;
84
+ const step = 10 ** (Math.floor(Math.log10(range)) - 1);
85
+ return Math.round(v / step) * step;
86
+ }
87
+
88
+ function quantile(sorted, p) {
89
+ const h = (sorted.length - 1) * p, lo = Math.floor(h), hi = Math.ceil(h);
90
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
91
+ }
92
+
93
+ /** Escala de color: clases (cuantiles, intervalos iguales o cortes dados) o continua. */
94
+ export function colorScale(values, spec, theme) {
95
+ const v = values.filter(Number.isFinite).sort((a, b) => a - b);
96
+ const min = v[0], max = v[v.length - 1], range = max - min;
97
+ const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
98
+ const mode = Array.isArray(spec.scale) || Array.isArray(spec.thresholds) ? 'thresholds' : spec.scale || 'quantile';
99
+ if (mid !== undefined) {
100
+ const stops = theme.diverging;
101
+ const dev = Math.max(Math.abs(max - mid), Math.abs(min - mid)) || 1;
102
+ if (mode === 'continuous') return { mode, min: mid - dev, max: mid + dev, mid, color: (x) => ramp(stops, 0.5 + (x - mid) / dev / 2), stops };
103
+ // Clases simétricas: el paso "bonito" más chico que cubre la desviación
104
+ // con a lo más `classes/2` clases por lado (sin cortes fuera de los datos).
105
+ const perSide = Math.max(2, Math.floor((spec.classes ?? 6) / 2));
106
+ const mag = 10 ** Math.floor(Math.log10(dev / perSide));
107
+ const step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((st) => Math.ceil(dev / st - 1e-9) <= perSide) || 10 * mag;
108
+ const k = Math.max(1, Math.ceil(dev / step - 1e-9));
109
+ const breaks = Array.from({ length: 2 * k - 1 }, (_, j) => +(mid + step * (j + 1 - k)).toPrecision(12));
110
+ const n = 2 * k;
111
+ const colors = Array.from({ length: n }, (_, i) => ramp(stops, 0.5 + ((i + 0.5 - k) / k) * 0.5));
112
+ // Extremos simétricos (mid ± k·paso): la leyenda se lee igual hacia ambos lados.
113
+ return { ...classed(breaks, colors, Math.min(min, +(mid - k * step).toPrecision(12)), Math.max(max, +(mid + k * step).toPrecision(12))), mid };
114
+ }
115
+ const stops = theme.sequential;
116
+ if (mode === 'continuous') return { mode, min, max, color: (x) => ramp(stops.slice(1), (x - min) / (range || 1)), stops: stops.slice(1) };
117
+ let breaks;
118
+ if (mode === 'thresholds') breaks = [...(Array.isArray(spec.scale) ? spec.scale : spec.thresholds)].map(Number).sort((a, b) => a - b);
119
+ else {
120
+ const n = Math.max(2, Math.min(9, spec.classes ?? Math.min(5, v.length)));
121
+ if (mode === 'equal') breaks = Array.from({ length: n - 1 }, (_, k) => niceRound(min + (range * (k + 1)) / n, range));
122
+ else breaks = Array.from({ length: n - 1 }, (_, k) => niceRound(quantile(v, (k + 1) / n), range));
123
+ breaks = [...new Set(breaks)].filter((b) => b > min && b <= max);
124
+ }
125
+ const n = breaks.length + 1;
126
+ const t0 = theme.cmap ? 0 : theme.mode === 'dark' ? 0.26 : 0.16;
127
+ const colors = Array.from({ length: n }, (_, i) => ramp(stops, n === 1 ? 0.6 : t0 + ((1 - t0) * i) / (n - 1)));
128
+ return classed(breaks, colors, min, max);
129
+ }
130
+
131
+ function classed(breaks, colors, min, max) {
132
+ const cls = (x) => { let i = 0; while (i < breaks.length && x >= breaks[i]) i++; return i; };
133
+ return { mode: 'classes', breaks, colors, min, max, cls, color: (x) => colors[cls(x)] };
134
+ }
135
+
136
+ /** Leyenda escalonada (o degradado), pegada al mapa. Devuelve { svg, h }. */
137
+ function legendBar(sc, x, y, maxW, theme, fmt, { title, missing, locale, gid, dashed }) {
138
+ const out = [];
139
+ let lx = x;
140
+ const size = theme.type?.axis ?? 11.5;
141
+ if (title) {
142
+ const tw = Math.min(measure(title, 12, 500), maxW * 0.4);
143
+ out.push(text(lx, y + 13, truncate(title, tw, 12, 500), { fill: theme.lede, fontSize: 12, fontWeight: 500 }));
144
+ lx += tw + 12;
145
+ }
146
+ // Anchos con su separación: cuadro + texto (+ espacio hasta la siguiente clave).
147
+ const missW = missing ? measure(T(locale, 'Sin dato', 'No data'), size) + 14 + (dashed ? 14 : 0) : 0;
148
+ const dashW = dashed ? measure(dashed.label, size) + 16 : 0;
149
+ // Si "Sin dato" y la clave punteada no caben junto a la escala, bajan a una segunda fila.
150
+ const extra = missW + dashW ? 14 + missW + dashW : 0;
151
+ const wrapX = 140 + extra > x + maxW - lx;
152
+ const barW = Math.max(120, Math.min(360, x + maxW - lx - (wrapX ? 0 : extra)));
153
+ const by = y + 4;
154
+ if (sc.mode === 'classes') {
155
+ const n = sc.colors.length, gap = 2;
156
+ const sw = (barW - gap * (n - 1)) / n;
157
+ // Cada clase es una clave conectada: al pasar encima se destacan sus regiones.
158
+ sc.colors.forEach((c, i) => out.push(g({ class: 'pa-key', 'data-color': c, style: 'cursor:pointer' }, [
159
+ el('rect', { x: lx + i * (sw + gap), y: by - 4, width: sw + gap, height: 18, fill: 'transparent' }),
160
+ el('rect', { x: lx + i * (sw + gap), y: by, width: sw, height: 10, rx: i === 0 || i === n - 1 ? 3 : 1, fill: c }),
161
+ ])));
162
+ // Etiquetas en los cortes; los extremos (mín. y máx.) en las puntas.
163
+ // Los extremos solo se rotulan si caen fuera de los cortes (con cortes fijos, un año puede no llegar a la última clase).
164
+ const labels = [
165
+ ...(sc.min < sc.breaks[0] || !sc.breaks.length ? [{ x: lx, v: sc.min, anchor: 'start' }] : []),
166
+ ...sc.breaks.map((b, i) => ({ x: lx + (i + 1) * (sw + gap) - gap / 2, v: b, anchor: 'middle' })),
167
+ ...(sc.max >= sc.breaks[sc.breaks.length - 1] || !sc.breaks.length ? [{ x: lx + barW, v: sc.max, anchor: 'end' }] : []),
168
+ ];
169
+ let last = -Infinity;
170
+ for (const L of labels) {
171
+ const s = fmt(L.v), w = measure(s, size);
172
+ const left = L.anchor === 'start' ? L.x : L.anchor === 'end' ? L.x - w : L.x - w / 2;
173
+ if (left < last + 6) continue;
174
+ out.push(text(L.x, by + 24, s, { fill: theme.muted, fontSize: size, textAnchor: L.anchor }));
175
+ last = left + w;
176
+ }
177
+ } else {
178
+ const id = gid + '-grad';
179
+ const stops = Array.from({ length: 9 }, (_, i) => el('stop', { offset: `${i * 12.5}%`, stopColor: sc.color(sc.min + ((sc.max - sc.min) * i) / 8) }));
180
+ out.push(el('defs', {}, el('linearGradient', { id }, stops)));
181
+ out.push(el('rect', { x: lx, y: by, width: barW, height: 10, rx: 3, fill: `url(#${id})` }));
182
+ out.push(text(lx, by + 24, fmt(sc.min), { fill: theme.muted, fontSize: size }));
183
+ if (sc.mid !== undefined) out.push(text(lx + barW / 2, by + 24, fmt(sc.mid), { fill: theme.muted, fontSize: size, textAnchor: 'middle' }));
184
+ out.push(text(lx + barW, by + 24, fmt(sc.max), { fill: theme.muted, fontSize: size, textAnchor: 'end' }));
185
+ }
186
+ let mx = wrapX ? x : lx + barW + 14;
187
+ const ey = wrapX ? by + 32 : by;
188
+ if (missing) {
189
+ out.push(g({ class: 'pa-key', 'data-color': `url(#${gid}-nd)`, style: 'cursor:pointer' }, [
190
+ el('rect', { x: mx, y: ey, width: 10, height: 10, rx: 2, fill: `url(#${gid}-nd)`, stroke: theme.axis, strokeWidth: 0.5 }),
191
+ text(mx + 14, ey + 9, T(locale, 'Sin dato', 'No data'), { fill: theme.muted, fontSize: size }),
192
+ ]));
193
+ mx += missW;
194
+ }
195
+ if (dashed) {
196
+ out.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
197
+ 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 }),
198
+ text(mx + 15, ey + 9, dashed.label, { fill: theme.lede, fontSize: size }),
199
+ ]));
200
+ }
201
+ return { svg: g({ class: 'pa-legend pa-map-legend' }, out), h: wrapX && (missing || dashed) ? 50 : 34 };
202
+ }
203
+
204
+ /** Patrón rayado para regiones sin dato. */
205
+ const noDataPattern = (id, theme) =>
206
+ `<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>`;
207
+
208
+ /** Datos + geometría emparejados. */
209
+ function prepare(spec, locale) {
210
+ const fc = getGeo(spec.geo ?? 'chile', spec.geoObject);
211
+ const data = ensureData(spec);
212
+ const fields = fieldsOf(data);
213
+ const key = spec.key ?? spec.region ?? spec.x ?? spec.label ?? fields.find((f) => fieldKind(data, f) === 'category');
214
+ const valueF = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => f !== key && fieldKind(data, f) === 'number');
215
+ requireField(data, key, 'key');
216
+ requireField(data, valueF, 'value');
217
+ // Valores de texto ("Izquierda", "Derecha"…): un color por categoría.
218
+ const categorical = spec.scale === 'categorical' || (data.length > 0 && fieldKind(data, valueF) === 'category');
219
+ let features = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
220
+ if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
221
+ // Nombres: `nameKey` o la primera propiedad conocida (NOMBRE, NAME_1, ADMIN…).
222
+ const hasName = (f) => { const p = f.properties || {}; return primitive(p.name) || primitive(p.nameEn) || primitive(p.NAME) || primitive(p.nombre); };
223
+ const firstText = () => { for (const f of features.slice(0, 20)) for (const [k, v] of Object.entries(f.properties || {})) if (typeof v === 'string' && v.trim()) return k; return null; };
224
+ const nameKey = spec.nameKey ?? (features.length && !features.some(hasName) ? NAME_PROPS.find((k) => features.some((f) => primitive(f.properties?.[k]))) ?? spec.featureKey ?? firstText() : null);
225
+ if (nameKey) features = withNames(features, nameKey);
226
+ let idx = featureIndex(features, spec.featureKey);
227
+ // Sin `featureKey`: si las filas no calzan con las claves de siempre, se busca la propiedad que sí calza.
228
+ let autoKey = null;
229
+ if (!spec.featureKey && data.length) {
230
+ const keys = data.map((r) => r[key]).filter(primitive);
231
+ const matched = keys.filter((k) => idx.has(normKey(k))).length;
232
+ if (matched < new Set(keys.map(normKey)).size) {
233
+ autoKey = bestProperty(features, keys, new Set(keys.filter((k) => idx.has(normKey(k))).map(normKey)).size);
234
+ if (autoKey) for (const [k, f] of featureIndex(features, autoKey)) if (!idx.has(k)) idx.set(k, f);
235
+ if (autoKey && !nameKey && !features.some(hasName)) features = withNames(features, autoKey), idx = new Map([...featureIndex(features, null), ...featureIndex(features, autoKey)]);
236
+ }
237
+ }
238
+ // Territorios opcionales (Puerto Rico en 'usa'): solo se dibujan si hay datos para ellos.
239
+ if (features.some((f) => f.properties?.optional)) {
240
+ const used = new Set(data.map((r) => idx.get(normKey(r[key]))).filter(Boolean));
241
+ features = features.filter((f) => !f.properties?.optional || used.has(f) || spec.territories === true);
242
+ idx = featureIndex(features, spec.featureKey ?? autoKey);
243
+ if (!spec.featureKey && autoKey) for (const [k, f] of featureIndex(features, null)) if (!idx.has(k)) idx.set(k, f);
244
+ }
245
+ const buckets = new Map();
246
+ const unmatched = [];
247
+ for (const r of data) {
248
+ const f = idx.get(normKey(r[key]));
249
+ if (!f) { if (r[key] !== undefined && r[key] !== null && r[key] !== '') unmatched.push(String(r[key])); continue; }
250
+ if (!buckets.has(f)) buckets.set(f, []);
251
+ buckets.get(f).push(categorical ? r[valueF] : num(r[valueF]));
252
+ }
253
+ const values = new Map();
254
+ const mode = (vs) => { const c = new Map(); for (const v of vs) if (primitive(v) && v !== '') c.set(String(v), (c.get(String(v)) || 0) + 1); return [...c].sort((a, b) => b[1] - a[1])[0]?.[0]; };
255
+ for (const [f, vs] of buckets) {
256
+ const v = categorical ? mode(vs) : aggregate(vs, spec.aggregate || 'mean');
257
+ if (categorical ? v !== undefined : Number.isFinite(v)) values.set(f, v);
258
+ }
259
+ if (Array.isArray(spec._unmatched)) spec._unmatched.push(...new Set(unmatched));
260
+ // Categorías ordenadas por cuántas áreas cubren (o el orden dado en `order`).
261
+ const count = new Map();
262
+ for (const v of values.values()) count.set(v, (count.get(v) || 0) + 1);
263
+ const categories = categorical ? (Array.isArray(spec.order) ? spec.order.map(String).filter((c) => count.has(c)).concat([...count.keys()].filter((c) => !spec.order.map(String).includes(c))) : [...count.keys()].sort((a, b) => count.get(b) - count.get(a))) : null;
264
+ const ranked = categorical ? [] : [...values.entries()].sort((a, b) => b[1] - a[1]);
265
+ const rank = new Map(ranked.map(([f], i) => [f, i + 1]));
266
+ // Proyección: la pedida; si no, una según la geometría (coordenadas fuera de
267
+ // lon/lat → plano cartesiano, p. ej. UTM; mundo → Equal Earth; resto → Mercator).
268
+ const projName = typeof spec.projection === 'string' ? spec.projection : fc.projection ?? (/^(world|mundo|countries|paises|países)$/i.test(String(spec.geo)) ? 'equalEarth' : null);
269
+ let lon0 = Infinity, lon1 = -Infinity, latMax = 0;
270
+ for (const f of features) for (const r of rings(f.geometry)) for (const p of r) { if (p[0] < lon0) lon0 = p[0]; if (p[0] > lon1) lon1 = p[0]; if (Math.abs(p[1]) > latMax) latMax = Math.abs(p[1]); }
271
+ const projected = Math.max(Math.abs(lon0), Math.abs(lon1)) > 180.5 || latMax > 90.5;
272
+ const raw = makeProjection(spec.projection) ?? (projName && projections[projName] ? projections[projName]() : projected ? planar() : lon1 - lon0 > 100 ? equalEarth() : mercator());
273
+ // `dashed`: por nombre de región (con los mismos alias) o por un campo de los datos.
274
+ const dm = dashedMatcher(spec, data, key, null, normKey);
275
+ const dashed = new Set(dm ? features.filter((f) => featureKeys(f).some((k) => dm(k))) : []);
276
+ return { fc, features, values, rank, ranked, unmatched: [...new Set(unmatched)], key, valueF, raw, data, dashed, categorical, categories };
277
+ }
278
+
279
+ // ── Capas extra: marcadores (lon/lat) y GeoJSON superpuesto ─────────────────
280
+ const pickF = (fields, names) => names.find((n) => fields.includes(n));
281
+
282
+ /** `markers`: [{ lon, lat, label, value }] o { data, lon, lat, label, size, color, legend }. */
283
+ function markerList(spec, theme) {
284
+ const mk = spec.markers;
285
+ if (!mk) return null;
286
+ const o = Array.isArray(mk) ? {} : mk;
287
+ const rows = ensureData({ data: Array.isArray(mk) ? mk : mk.data || [] });
288
+ const fields = fieldsOf(rows);
289
+ const lonF = o.lon ?? pickF(fields, ['lon', 'lng', 'long', 'longitude', 'longitud', 'x']);
290
+ const latF = o.lat ?? pickF(fields, ['lat', 'latitude', 'latitud', 'y']);
291
+ const labelF = o.label ?? pickF(fields, ['label', 'name', 'nombre', 'city', 'ciudad', 'title']);
292
+ const sizeF = o.size ?? pickF(fields, ['size', 'value', 'valor']);
293
+ const colorF = typeof o.color === 'string' && fields.includes(o.color) ? o.color : pickF(fields, ['color']);
294
+ if (!lonF || !latF) throw new PlotError('Los marcadores necesitan longitud y latitud.', 'markers: [{ lon: -73.05, lat: -36.82, label: "Concepción" }]');
295
+ const items = rows.map((r) => ({ lon: num(r[lonF]), lat: num(r[latF]), label: labelF ? r[labelF] : undefined, size: sizeF ? num(r[sizeF]) : NaN, color: (colorF && r[colorF]) || (typeof o.color === 'string' && !fields.includes(o.color) ? o.color : null), row: r }))
296
+ .filter((m) => Number.isFinite(m.lon) && Number.isFinite(m.lat));
297
+ const maxS = Math.max(0, ...items.map((m) => Math.abs(m.size)).filter(Number.isFinite));
298
+ for (const m of items) m.r = Number.isFinite(m.size) && maxS ? Math.max(3, Math.sqrt(Math.abs(m.size) / maxS) * (o.maxRadius ?? 14)) : o.radius ?? 4.5;
299
+ return { items, color: (typeof o.color === 'string' && !fields.includes(o.color) && o.color) || theme.ink, legend: o.legend ?? o.title ?? null, labels: o.labels !== false, sizeF };
300
+ }
301
+
302
+ /** `layers`: [{ geo, stroke, fill, width, dash, opacity, label, below }]. */
303
+ function layerList(spec, theme) {
304
+ return [].concat(spec.layers || []).filter(Boolean).map((L, i) => {
305
+ const fc = getGeo(L.geo ?? L.data, L.object ?? L.geoObject);
306
+ const fs = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
307
+ const lines = fs.every((f) => isLineGeom(f.geometry));
308
+ return { ...L, features: fs, i, stroke: L.stroke ?? L.color ?? (lines ? theme.ink : theme.lede), width: L.width ?? L.strokeWidth ?? (lines ? 1.5 : 1), fill: L.fill ?? 'none' };
309
+ });
310
+ }
311
+
312
+ function drawLayer(L, proj, theme, locale) {
313
+ return g({ class: 'pa-layer', 'pointer-events': L.label || L.tooltip !== false ? null : 'none' }, L.features.map((f) => {
314
+ const pt = isPointGeom(f.geometry);
315
+ const nm = nameOf(f, locale);
316
+ const tipT = [L.label, nm && nm !== String(f.id ?? '') ? nm : null].filter(Boolean).join('\n');
317
+ return el('path', {
318
+ class: tipT ? 'pa-m' : null, 'data-tip': tipT || null, d: pathD(f.geometry, proj, { r: L.radius ?? 3 }),
319
+ fill: pt ? (L.fill !== 'none' ? L.fill : L.stroke) : L.fill, fillOpacity: L.fillOpacity ?? null,
320
+ stroke: pt ? theme.surface : L.stroke, strokeWidth: pt ? 1 : L.width, strokeDasharray: L.dash === true ? '5 4' : L.dash || null,
321
+ strokeLinejoin: 'round', strokeLinecap: 'round', opacity: L.opacity ?? null, 'data-color': L.stroke,
322
+ });
323
+ }));
324
+ }
325
+
326
+ function drawMarkers(mk, proj, theme, fmt, placed) {
327
+ const dots = [], labs = [];
328
+ const halo = { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' };
329
+ for (const m of [...mk.items].sort((a, b) => b.r - a.r)) {
330
+ const [x, y] = proj([m.lon, m.lat]);
331
+ m.x = x; m.y = y;
332
+ const c = m.color || mk.color;
333
+ const tipT = [m.label, Number.isFinite(m.size) && mk.sizeF ? fmt(m.size) : null].filter((v) => v !== undefined && v !== null && v !== '').join('\n');
334
+ dots.push(el('circle', { class: 'pa-m', cx: x, cy: y, r: m.r, fill: c, fillOpacity: m.r > 6 ? 0.8 : 1, stroke: theme.surface, strokeWidth: 1.5, 'data-tip': tipT || null, 'data-color': c }));
335
+ }
336
+ for (const m of mk.items) placed.push([m.x - m.r - 2, m.y - m.r - 2, m.x + m.r + 2, m.y + m.r + 2]);
337
+ if (mk.labels) for (const m of mk.items) {
338
+ if (m.label === undefined || m.label === null || m.label === '') continue;
339
+ const w = measure(String(m.label), 11, 600);
340
+ const hit = (box) => placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
341
+ // A la derecha del punto; si choca, a la izquierda; si no, encima.
342
+ const cands = [
343
+ { box: [m.x + m.r + 3, m.y - 8, m.x + m.r + 6 + w, m.y + 8], x: m.x + m.r + 4, y: m.y + 4, anchor: 'start' },
344
+ { box: [m.x - m.r - 6 - w, m.y - 8, m.x - m.r - 3, m.y + 8], x: m.x - m.r - 4, y: m.y + 4, anchor: 'end' },
345
+ { box: [m.x - w / 2 - 2, m.y - m.r - 20, m.x + w / 2 + 2, m.y - m.r - 3], x: m.x, y: m.y - m.r - 7, anchor: 'middle' },
346
+ ];
347
+ const c = cands.find((k) => !hit(k.box));
348
+ if (!c) continue;
349
+ placed.push(c.box);
350
+ labs.push(text(c.x, c.y, String(m.label), { fill: theme.ink, fontSize: 11, fontWeight: 600, textAnchor: c.anchor, ...halo }));
351
+ }
352
+ return [g({ class: 'pa-markers' }, dots), g({ class: 'pa-map-label pa-marker-labels', 'pointer-events': 'none' }, labs)];
353
+ }
354
+
355
+ /** Leyenda de categorías (mapas con valores de texto): cuadros enlazados con las regiones. */
356
+ function catLegend(sc, x, y, maxW, theme, { title, missing, locale, gid, dashed }) {
357
+ const out = [];
358
+ const size = theme.type?.axis ?? 11.5;
359
+ let lx = x, ly = y, rows = 1;
360
+ if (title) { out.push(text(lx, ly + 9.5, title, { fill: theme.lede, fontSize: 12, fontWeight: 500 })); lx += measure(title, 12, 500) + 14; }
361
+ const item = (label, attrs, key) => {
362
+ const w = 14 + measure(label, size) + 16;
363
+ if (lx + w > x + maxW && lx > x) { lx = x; ly += 20; rows++; }
364
+ out.push(g({ class: 'pa-key', 'data-color': key, style: 'cursor:pointer' }, [
365
+ el('rect', { x: lx, y: ly, width: 10, height: 10, rx: 2, ...attrs }),
366
+ text(lx + 14, ly + 9, label, { fill: theme.lede, fontSize: size }),
367
+ ]));
368
+ lx += w;
369
+ };
370
+ for (const c of sc.cats) item(c, { fill: sc.color(c) }, sc.color(c));
371
+ if (missing) item(T(locale, 'Sin dato', 'No data'), { fill: `url(#${gid}-nd)`, stroke: theme.axis, strokeWidth: 0.5 }, `url(#${gid}-nd)`);
372
+ if (dashed) item(dashed.label, { fill: dashed.fill, stroke: dashed.stroke, strokeWidth: 1.1, strokeDasharray: DASH.legend }, 'dashed');
373
+ return { svg: g({ class: 'pa-legend pa-map-legend' }, out), h: rows * 20 + 2 };
374
+ }
375
+
376
+ /**
377
+ * Columna de etiquetas para mapas altos: una fila por región a la altura de
378
+ * su centroide (separadas si chocan), con línea guía, barra y valor.
379
+ */
380
+ function labelColumn(items, x, y0, y1, w, theme, fmt, { bars = true, maxV }) {
381
+ const out = [];
382
+ const n = items.length;
383
+ const rowH = Math.max(12.5, Math.min(18, (y1 - y0) / Math.max(1, n)));
384
+ const fs = rowH < 14 ? 10.5 : 11.5;
385
+ // Separación vertical (orden norte → sur se conserva).
386
+ const rows = items.map((it) => ({ ...it, y: it.cy })).sort((a, b) => a.cy - b.cy);
387
+ 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);
388
+ 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; }
389
+ 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;
390
+ const nameW = Math.min(w * (bars ? 0.46 : 0.68), Math.max(...rows.map((r) => measure(r.name, fs))));
391
+ const valW = Math.max(...rows.map((r) => measure(r.label, fs, 600)));
392
+ const barX = x + nameW + 8;
393
+ const diverging = rows.some((r) => r.v < 0);
394
+ const barMax = Math.max(0, w - nameW - valW * (diverging ? 2 : 1) - (diverging ? 26 : 18));
395
+ const showBars = bars && barMax >= 36;
396
+ const negMax = diverging ? Math.max(0, ...rows.filter((r) => r.v < 0).map((r) => -r.v)) : 0;
397
+ const posMax = Math.max(0, ...rows.filter((r) => r.v > 0).map((r) => r.v));
398
+ // Con valores negativos, eje en cero y barras hacia ambos lados.
399
+ const zeroX = diverging ? barX + valW + 6 + (barMax * negMax) / (negMax + posMax || 1) : barX;
400
+ const scaleW = diverging ? barMax / (negMax + posMax || 1) : barMax / (maxV || 1);
401
+ if (showBars && diverging) out.push(el('line', { x1: zeroX, x2: zeroX, y1: y0, y2: y1, stroke: theme.axis, strokeWidth: 1 }));
402
+ for (const r of rows) {
403
+ const parts = [];
404
+ // Línea guía: horizontal desde el centroide y quiebre suave a la fila.
405
+ 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 }));
406
+ parts.push(el('circle', { cx: r.cx, cy: r.cy, r: 1.8, fill: theme.ink, opacity: 0.7 }));
407
+ parts.push(text(x, r.y + fs * 0.36, truncate(r.name, nameW, fs), { fill: r.missing ? theme.muted : theme.lede, fontSize: fs }));
408
+ if (r.missing) parts.push(text(showBars ? barX : x + nameW + 8, r.y + fs * 0.36, r.label, { fill: theme.muted, fontSize: fs }));
409
+ else if (showBars) {
410
+ const bw = Math.max(2, Math.abs(r.v) * scaleW);
411
+ const bx = r.v < 0 ? zeroX - bw : zeroX;
412
+ parts.push(el('rect', { x: bx, y: r.y - 3.5, width: bw, height: 7, rx: 2, fill: r.barColor || r.color }));
413
+ 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' }));
414
+ } else parts.push(text(x + nameW + 8, r.y + fs * 0.36, r.label, { fill: theme.ink, fontSize: fs, fontWeight: 600 }));
415
+ out.push(g({ class: 'pa-m', 'data-tip': r.tip }, parts));
416
+ }
417
+ return out;
418
+ }
419
+
420
+ /** Etiquetas sobre el mapa (mapas anchos): solo las elegidas y sin choques. */
421
+ function mapLabels(items, theme, placedBoxes = []) {
422
+ const out = [];
423
+ for (const it of items) {
424
+ const w = Math.max(measure(it.name, 11, 600), measure(it.label, 11));
425
+ const box = [it.cx - w / 2 - 2, it.cy - 13, it.cx + w / 2 + 2, it.cy + 13];
426
+ if (placedBoxes.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
427
+ placedBoxes.push(box);
428
+ const halo = { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round', textAnchor: 'middle' };
429
+ out.push(g({ class: 'pa-map-label', 'pointer-events': 'none' }, [
430
+ text(it.cx, it.cy - 2, it.name, { fill: theme.ink, fontSize: 11, fontWeight: 600, ...halo }),
431
+ text(it.cx, it.cy + 11, it.label, { fill: theme.lede, fontSize: 11, ...halo }),
432
+ ]));
433
+ }
434
+ return out;
435
+ }
436
+
437
+ function chooseLabeled(m, spec, locale, defaultN) {
438
+ const hl = spec.highlight === undefined ? [] : [].concat(spec.highlight).map(normKey);
439
+ const isHl = (f) => hl.length && featureIndexHas(f, hl);
440
+ if (Array.isArray(spec.labels)) { const want = spec.labels.map(normKey); return m.features.filter((f) => featureIndexHas(f, want)); }
441
+ const n = typeof spec.labels === 'number' ? spec.labels : spec.labels === false ? 0 : defaultN;
442
+ const top = m.ranked.slice(0, n).map(([f]) => f);
443
+ return [...new Set([...m.features.filter(isHl), ...top])];
444
+ }
445
+ function featureKeys(f) {
446
+ const p = f.properties || {};
447
+ 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);
448
+ }
449
+ function featureIndexHas(f, keys) {
450
+ const own = featureKeys(f);
451
+ return keys.some((k) => own.includes(k));
452
+ }
453
+
454
+ /**
455
+ * Siglas sobre el mapa (estados de EE. UU. u otra geometría con `abbr`): la
456
+ * sigla va dentro si cabe, con el valor debajo cuando hay espacio. Las
457
+ * marcadas `callout` (estados chicos) van en una columna a la derecha con
458
+ * línea guía, ordenadas por altura.
459
+ */
460
+ function abbrLabels(items, proj, theme, { calloutX, showValues, top, bottom }) {
461
+ const inside = [], callouts = [];
462
+ const fs = Math.max(8.5, Math.min(11, proj.bounds.w / 62));
463
+ for (const it of items) {
464
+ const p = it.f.properties || {};
465
+ if (!p.abbr) continue;
466
+ const at = p.labelAt ? proj(p.labelAt) : [it.cx, it.cy];
467
+ if (p.callout && calloutX) { callouts.push({ ...it, ax: it.cx, ay: it.cy }); continue; }
468
+ const s = Math.sqrt(it.area);
469
+ const aw = measure(p.abbr, fs, 600);
470
+ if (aw + 3 > s * 0.95) {
471
+ // Islas chicas (Hawái, Puerto Rico): la sigla al lado, fuera de la forma.
472
+ if (!proj.inset || !proj.inset(p.labelAt || it.f.geometry && rings(it.f.geometry)[0][0])) continue;
473
+ let x1 = -Infinity;
474
+ for (const r of rings(it.f.geometry)) for (const pt of r) x1 = Math.max(x1, proj(pt)[0]);
475
+ inside.push(text(x1 + 5, at[1] + fs * 0.36, p.abbr, { fill: theme.lede, fontSize: fs, fontWeight: 600 }));
476
+ continue;
477
+ }
478
+ const ink = it.dashedShape ? theme.ink : textOn(it.color, theme);
479
+ const val = showValues && !it.missing && s > fs * 3.4 && measure(it.label, fs - 1) + 4 < s * 0.92;
480
+ const y0 = at[1] + (val ? -1 : fs * 0.36);
481
+ const halo = it.dashedShape ? { stroke: theme.surface, strokeWidth: 2.5, paintOrder: 'stroke', strokeLinejoin: 'round' } : {};
482
+ inside.push(text(at[0], y0, p.abbr, { fill: ink, fontSize: fs, fontWeight: 600, textAnchor: 'middle', ...halo }));
483
+ if (val) inside.push(text(at[0], y0 + fs + 0.5, it.label, { fill: ink, fontSize: fs - 1, textAnchor: 'middle', opacity: 0.88, ...halo }));
484
+ }
485
+ const out = [g({ class: 'pa-map-label pa-zoom-hide', 'pointer-events': 'none' }, inside)];
486
+ if (callouts.length) {
487
+ const rowH = fs + 5.5;
488
+ const rows = callouts.sort((a, b) => a.ay - b.ay).map((c) => ({ ...c, y: c.ay }));
489
+ 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);
490
+ 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; }
491
+ const parts = rows.map((r) => {
492
+ const p = r.f.properties;
493
+ const ax = r.ax.toFixed(1), ay = r.ay.toFixed(1);
494
+ return g({ class: 'pa-m', 'data-tip': r.tip, 'data-color': r.color }, [
495
+ 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 }),
496
+ el('circle', { cx: ax, cy: ay, r: 1.6, fill: theme.ink, opacity: 0.75 }),
497
+ 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 }) }),
498
+ text(calloutX + 13, r.y + fs * 0.36, p.abbr, { fill: theme.ink, fontSize: fs, fontWeight: 600 }),
499
+ showValues ? text(calloutX + 18 + measure(p.abbr, fs, 600), r.y + fs * 0.36, r.label, { fill: theme.lede, fontSize: fs }) : '',
500
+ ]);
501
+ });
502
+ out.push(g({ class: 'pa-callouts pa-zoom-hide' }, parts));
503
+ }
504
+ return out;
505
+ }
506
+
507
+ /** Recuadros (Alaska, Hawái, Puerto Rico): un marco fino y discontinuo. */
508
+ function insetFrames(features, proj, theme) {
509
+ if (!proj.inset) return '';
510
+ const boxes = new Map();
511
+ for (const f of features) for (const r of rings(f.geometry)) {
512
+ const k = proj.inset(r[0]);
513
+ if (!k) continue;
514
+ const b = boxes.get(k) || [Infinity, Infinity, -Infinity, -Infinity];
515
+ 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); }
516
+ boxes.set(k, b);
517
+ }
518
+ const pad = 7;
519
+ 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' })));
520
+ }
521
+
522
+ function baseChart(spec, theme, kind) {
523
+ const locale = spec.locale || 'es';
524
+ const m = prepare(spec, locale);
525
+ if (m.categorical && kind === 'symbol') throw new PlotError(T(locale, `"${m.valueF}" tiene texto, no números: un mapa de símbolos necesita cantidades.`, `"${m.valueF}" holds text, not numbers: a symbol map needs quantities.`), T(locale, 'Usa type: "choropleth" para colorear por categoría.', 'Use type: "choropleth" to color by category.'));
526
+ const vals = m.categorical ? [] : [...m.values.values()];
527
+ const numFmt = makeFormat(spec.format, locale, vals);
528
+ const fmt = m.categorical ? (v) => (typeof v === 'number' ? numFmt(v) : String(v ?? '')) : numFmt;
529
+ const W = spec.width || 720;
530
+ const layers = layerList(spec, theme);
531
+ const markers = markerList(spec, theme);
532
+ // `fitTo`: encuadra unas regiones (nombres o códigos), una caja [lon0, lat0, lon1, lat1] o "markers".
533
+ let fitFeats = m.features;
534
+ if (spec.fitTo !== undefined && spec.fitTo !== null) {
535
+ const ft = spec.fitTo;
536
+ if (Array.isArray(ft) && ft.length === 4 && ft.every((v) => typeof v === 'number')) {
537
+ const [a, b, c, d] = ft;
538
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[a, b], [c, b], [c, d], [a, d], [a, b]]] } }];
539
+ } else if (ft === 'markers' && markers?.items.length) {
540
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: markers.items.map((x) => [x.lon, x.lat]) } }];
541
+ } else {
542
+ const want = [].concat(ft).map(normKey);
543
+ const hit = m.features.filter((f) => featureIndexHas(f, want));
544
+ if (hit.length) fitFeats = hit;
545
+ }
546
+ // Un margen del 6 % para que lo encuadrado no toque el borde.
547
+ if (fitFeats !== m.features) {
548
+ let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity;
549
+ for (const f of fitFeats) for (const r of rings(f.geometry)) for (const p of r) { a = Math.min(a, p[0]); b = Math.min(b, p[1]); c = Math.max(c, p[0]); d = Math.max(d, p[1]); }
550
+ const px = (c - a) * 0.06 || 0.5, py = (d - b) * 0.06 || 0.5;
551
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: [[a - px, b - py], [c + px, d + py], [a - px, d + py], [c + px, b - py]] } }];
552
+ }
553
+ }
554
+ const aspect = aspectOf(fitFeats, m.raw);
555
+ const tall = aspect > 1.6 && m.features.length <= 40 && fitFeats === m.features && spec.tall !== false;
556
+ // Alto por defecto según la forma del territorio.
557
+ const defH = tall ? (W < 560 ? 660 : 740) : Math.round(W * Math.min(1.1, aspect) + 150);
558
+ const frame = createFrame(spec, theme, { height: defH });
559
+ let area = frame.layout([]);
560
+ const gid = 'pa-map-' + Math.abs(hash(String(spec.title) + kind + W)).toString(36);
561
+ const missingAny = spec.missing !== 'plain' && m.features.some((f) => !m.values.has(f));
562
+ const body = [];
563
+ let sc = null;
564
+ const H = hatcher(theme, gid);
565
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
566
+ const dashedKey = m.dashed.size && spec.legend !== false ? { label: dashedLabel, fill: H.fill(theme.muted), stroke: H.stroke(theme.muted) } : null;
567
+ if (kind === 'choropleth' && m.categorical && m.values.size) {
568
+ const cmap = colorMap(m.categories, theme, spec.colors);
569
+ sc = { mode: 'categories', cats: m.categories, color: (v) => cmap.get(String(v)) || theme.other };
570
+ const lg = spec.legend === false ? { svg: '', h: 0 } : catLegend(sc, area.x, area.y, area.w, theme, { title: spec.legendTitle ?? null, missing: missingAny, locale, gid, dashed: dashedKey });
571
+ body.push(lg.svg);
572
+ area = { ...area, y: area.y + lg.h + 8, h: area.h - lg.h - 8 };
573
+ } else if (kind === 'choropleth' && vals.length) {
574
+ sc = colorScale(vals, spec, theme);
575
+ 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 });
576
+ body.push(lg.svg);
577
+ area = { ...area, y: area.y + lg.h + 8, h: area.h - lg.h - 8 };
578
+ }
579
+ // Mapa de símbolos ancho: la leyenda de tamaños va arriba, en fila (abajo chocaría con Alaska o la Antártida).
580
+ let rMax = null;
581
+ if (kind === 'symbol') {
582
+ const N = m.values.size;
583
+ const wEst = area.w, hEst = wEst * Math.min(1.1, aspect);
584
+ 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)));
585
+ }
586
+ let lx = area.x;
587
+ const rowItems = [];
588
+ if (kind === 'symbol' && !tall && vals.length) {
589
+ const maxV = Math.max(...vals.map(Math.abs), 0) || 1;
590
+ const size = theme.type?.axis ?? 11.5;
591
+ const base = area.y + 2 * rMax + 2;
592
+ for (const v of [maxV / 16, maxV / 4, maxV]) {
593
+ const r = Math.sqrt(v / maxV) * rMax;
594
+ rowItems.push(el('circle', { cx: lx + r, cy: base - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 0.8 }));
595
+ rowItems.push(text(lx + 2 * r + 5, base - 1, fmt(v), { fill: theme.muted, fontSize: size }));
596
+ lx += 2 * r + 12 + measure(fmt(v), size);
597
+ }
598
+ lx += 10;
599
+ }
600
+ // Claves de las capas y marcadores con nombre (`label` / `legend`).
601
+ {
602
+ const size = theme.type?.axis ?? 11.5;
603
+ const ky = rowItems.length ? area.y + 2 * (rMax || 0) - 9 : area.y + 4.6;
604
+ for (const L of layers) {
605
+ if (!L.label) continue;
606
+ const pts = L.features.every((f) => isPointGeom(f.geometry)), poly = !pts && !L.features.every((f) => isLineGeom(f.geometry));
607
+ rowItems.push(g({ class: 'pa-key', 'data-color': L.stroke, style: 'cursor:pointer' }, [
608
+ pts ? el('circle', { cx: lx + 5, cy: ky + 4.9, r: 3.5, fill: L.fill !== 'none' ? L.fill : L.stroke })
609
+ : poly ? el('rect', { x: lx + 0.5, y: ky + 0.5, width: 9.5, height: 9.5, rx: 2, fill: L.fill === 'none' ? 'none' : L.fill, stroke: L.stroke, strokeWidth: 1.2, strokeDasharray: L.dash === true ? '3 2' : L.dash || null })
610
+ : el('line', { x1: lx, x2: lx + 14, y1: ky + 4.9, y2: ky + 4.9, stroke: L.stroke, strokeWidth: Math.min(3, L.width + 0.5), strokeDasharray: L.dash === true ? '4 3' : L.dash || null, strokeLinecap: 'round' }),
611
+ text(lx + (pts || poly ? 15 : 19), ky + 8.4, L.label, { fill: theme.lede, fontSize: size }),
612
+ ]));
613
+ lx += (pts || poly ? 15 : 19) + measure(L.label, size) + 16;
614
+ }
615
+ if (markers?.legend) {
616
+ rowItems.push(g({ class: 'pa-key', 'data-color': markers.color, style: 'cursor:pointer' }, [
617
+ el('circle', { cx: lx + 5, cy: ky + 4.9, r: 4.5, fill: markers.color, stroke: theme.surface, strokeWidth: 1 }),
618
+ text(lx + 15, ky + 8.4, markers.legend, { fill: theme.lede, fontSize: size }),
619
+ ]));
620
+ lx += 15 + measure(markers.legend, size) + 16;
621
+ }
622
+ }
623
+ if (dashedKey && !(kind === 'choropleth' && (vals.length || m.categorical))) {
624
+ const size = theme.type?.axis ?? 11.5;
625
+ const ky = rowItems.length ? area.y + 2 * rMax - 9 : area.y + 4.6;
626
+ rowItems.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
627
+ 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 }),
628
+ text(lx + 15, ky + 8.4, dashedLabel, { fill: theme.lede, fontSize: size }),
629
+ ]));
630
+ }
631
+ if (rowItems.length) {
632
+ const h = kind === 'symbol' && !tall ? 2 * rMax + 12 : 26;
633
+ body.push(g({ class: 'pa-legend pa-size-legend' }, rowItems));
634
+ area = { ...area, y: area.y + h, h: area.h - h };
635
+ }
636
+ // Columna de siglas para estados chicos (EE. UU.): se reserva a la derecha.
637
+ const callouts = kind === 'choropleth' && !tall && spec.labels !== false ? m.features.filter((f) => f.properties?.callout && f.properties?.abbr) : [];
638
+ const fsC = Math.max(8.5, Math.min(11, (area.w * 0.9) / 62));
639
+ const calloutW = callouts.length
640
+ ? 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)))
641
+ : 0;
642
+ // Mapa ancho: el alto se ajusta al ancho si el agente no fijó `height`.
643
+ if (!tall && spec.height === undefined) {
644
+ const mapH = (area.w - calloutW) * Math.min(1.1, aspect);
645
+ frame.setHeight(area.y + mapH + frame.chrome);
646
+ area = { ...area, h: frame.bottom - area.y };
647
+ }
648
+ let box;
649
+ if (tall) {
650
+ const colMin = W < 480 ? 0.56 : 0.5;
651
+ let mapW = Math.min(area.w * (1 - colMin), area.h / aspect);
652
+ box = [area.x, area.y, area.x + mapW, area.y + area.h];
653
+ } else box = [area.x, area.y, area.x + area.w - calloutW, area.y + area.h];
654
+ // Puntos y marcadores en el borde: margen para el círculo y su etiqueta.
655
+ const inset = m.features.some((f) => isPointGeom(f.geometry)) ? (spec.pointRadius ?? 5) + 18 : markers?.items.length ? 12 : 0;
656
+ const pts = m.features.some((f) => isPointGeom(f.geometry));
657
+ if (inset) box = [box[0] + inset * (tall ? 0 : 1), box[1] + inset + (pts ? 12 : 0), box[2] - inset, box[3] - Math.min(inset, 10)];
658
+ const proj = fit(fitFeats, m.raw, box, { align: tall ? 'left' : 'center' });
659
+ const calloutX = calloutW ? proj.bounds.x + proj.bounds.w + 14 : 0;
660
+ return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax, layers, markers };
661
+ }
662
+
663
+ const rankText = (r, n, locale) => (String(locale).startsWith('en') ? `#${r} of ${n}` : `${r}.º de ${n}`);
664
+
665
+ /** Capa del mapa con recorte: el navegador la escala y desplaza (zoom). */
666
+ function zoomLayer(B, children) {
667
+ const { area, gid } = B;
668
+ const box = [area.x, area.y, area.w, area.h].map((v) => Math.round(v * 10) / 10);
669
+ return [
670
+ el('clipPath', { id: `${gid}-clip` }, el('rect', { x: box[0], y: box[1] - 2, width: box[2], height: box[3] + 4 })),
671
+ // Fondo transparente: la rueda, el arrastre y el pellizco funcionan también sobre el mar.
672
+ 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)]),
673
+ ];
674
+ }
675
+
676
+ export function choropleth(spec, theme) {
677
+ const B = baseChart(spec, theme, 'choropleth');
678
+ const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX, layers, markers } = B;
679
+ const borderC = spec.borderColor ?? theme.surface, borderW = spec.borderWidth ?? 0.75;
680
+ const ptR = spec.pointRadius ?? 5;
681
+ const hl = spec.highlight === undefined ? null : [].concat(spec.highlight).map(normKey);
682
+ const isHl = (f) => hl && featureIndexHas(f, hl);
683
+ const N = m.values.size;
684
+ const regions = [], dashedRegions = [], outlines = [];
685
+ const items = [];
686
+ for (const f of m.features) {
687
+ const v = m.values.get(f);
688
+ const has = v !== undefined;
689
+ const base = has ? sc.color(v) : null;
690
+ const isDashed = m.dashed.has(f);
691
+ const name = nameOf(f, locale);
692
+ const tipTxt = (has ? `${name}\n${fmt(v)}${m.categorical ? '' : ' · ' + rankText(m.rank.get(f), N, locale)}` : `${name}\n${T(locale, 'Sin dato', 'No data')}`) + (isDashed ? `\n${dashedLabel}` : '');
693
+ const d = pathD(f.geometry, proj, { r: ptR });
694
+ const isLine = isLineGeom(f.geometry);
695
+ 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) };
696
+ let fill = has ? base : spec.missing === 'plain' ? theme.tint : `url(#${gid}-nd)`;
697
+ let stroke = null;
698
+ if (isDashed && has) {
699
+ // Rayado del color de su clase + borde discontinuo: se lee igual en la escala.
700
+ const a = dashedAttrs(H, base, { width: 1.2 });
701
+ fill = a.fill; stroke = a.stroke;
702
+ dashedRegions.push(el('path', { ...common, ...a }));
703
+ } else if (isDashed) {
704
+ stroke = theme.muted;
705
+ dashedRegions.push(el('path', { ...common, fill, stroke, strokeWidth: 1.1, strokeDasharray: DASH.outline, 'data-dashed': '1', 'data-color': fill }));
706
+ } else if (isLine) regions.push(el('path', { ...common, fill: 'none', stroke: has ? base : theme.muted, strokeWidth: spec.lineWidth ?? 2.5, strokeLinecap: 'round', 'data-color': has ? base : fill }));
707
+ else regions.push(el('path', { ...common, fill, stroke: isPointGeom(f.geometry) ? theme.surface : borderC, strokeWidth: isPointGeom(f.geometry) ? 1.2 : borderW, 'data-color': fill }));
708
+ if (isHl(f)) outlines.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', 'pointer-events': 'none' }));
709
+ const c = centroid(f.geometry, proj);
710
+ 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 });
711
+ }
712
+ const layer = [...layers.filter((L) => L.below).map((L) => drawLayer(L, proj, theme, locale)), g({ class: 'pa-regions' }, regions), dashedRegions.length ? g({ class: 'pa-regions pa-dashed' }, dashedRegions) : '', g({ class: 'pa-outlines' }, outlines), insetFrames(m.features, proj, theme),
713
+ ...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
714
+ const placed = [];
715
+ if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, placed));
716
+ const after = [];
717
+ if (tall && spec.labels !== false) {
718
+ const x = proj.bounds.x + proj.bounds.w + 26;
719
+ const maxV = Math.max(...[...m.values.values()].map(Math.abs));
720
+ after.push(...labelColumn(items, x, area.y, area.y + area.h, area.x + area.w - x, theme, fmt, { bars: spec.bars !== false && !m.categorical, maxV }));
721
+ } else if (spec.labels !== false && m.features.some((f) => f.properties?.abbr) && !Array.isArray(spec.labels) && typeof spec.labels !== 'number') {
722
+ const [lab, ...rest] = abbrLabels(items, proj, theme, { calloutX, showValues: spec.values !== false, top: area.y, bottom: area.y + area.h });
723
+ layer.push(lab);
724
+ after.push(...rest);
725
+ } else {
726
+ const chosen = new Set(chooseLabeled(m, spec, locale, m.features.length <= 30 ? m.features.length : 0));
727
+ const ordered = items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.area - a.area)
728
+ .map((it) => (isPointGeom(it.f.geometry) ? { ...it, cy: it.cy - ptR - 12 } : it));
729
+ layer.push(g({ class: 'pa-zoom-hide' }, mapLabels(ordered, theme, placed)));
730
+ }
731
+ body.push(...(tall ? layer : zoomLayer(B, layer)), ...after);
732
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map')) + describeExtras(markers, layers, locale);
733
+ return frame.finish(body, { desc, type: 'choropleth', defs: noDataPattern(`${gid}-nd`, theme) + H.defs() });
734
+ }
735
+
736
+ /** Mapa de símbolos: círculos de área proporcional en el centroide. */
737
+ export function symbolMap(spec, theme) {
738
+ const B = baseChart(spec, theme, 'symbol');
739
+ const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax, layers, markers } = B;
740
+ const N = m.values.size;
741
+ const maxV = Math.max(...[...m.values.values()].map(Math.abs), 0) || 1;
742
+ const color = (spec.colors && Object.values(spec.colors)[0]) || theme.categorical[0];
743
+ const layer = [...layers.filter((L) => L.below).map((L) => drawLayer(L, proj, theme, locale)), g({ class: 'pa-regions pa-base' }, m.features.map((f) => el('path', { d: pathD(f.geometry, proj, { r: 2.5 }), fill: isLineGeom(f.geometry) ? 'none' : theme.tint, stroke: isLineGeom(f.geometry) ? theme.axis : spec.borderColor ?? theme.surface, strokeWidth: isLineGeom(f.geometry) ? 1.5 : spec.borderWidth ?? 0.75, strokeLinejoin: 'round', 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? nameOf(f, locale)) }))), insetFrames(m.features, proj, theme),
744
+ ...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
745
+ const items = m.features.map((f) => {
746
+ const c = centroid(f.geometry, proj);
747
+ const v = m.values.get(f);
748
+ const name = nameOf(f, locale);
749
+ const isDashed = m.dashed.has(f);
750
+ const at = f.properties?.labelAt ? proj(f.properties.labelAt) : [c.x, c.y];
751
+ 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,
752
+ 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}` : '') };
753
+ });
754
+ const circles = items.filter((it) => !it.missing).sort((a, b) => b.r - a.r)
755
+ .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,
756
+ ...(it.dashedShape ? { ...dashedAttrs(H, color, { width: 1.3 }) } : { fill: color, fillOpacity: 0.75, stroke: theme.surface, strokeWidth: 1.5 }) }));
757
+ layer.push(g({ class: 'pa-symbols' }, circles));
758
+ if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, []));
759
+ // Leyenda de tamaños (tres círculos anidados) en la esquina libre.
760
+ const sizes = [maxV, maxV / 4, maxV / 16].map((v) => ({ v, r: Math.sqrt(v / maxV) * rMax }));
761
+ 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;
762
+ const leg = sizes.map((s) => [
763
+ el('circle', { cx: lx + rMax, cy: ly - s.r, r: s.r, fill: 'none', stroke: theme.muted, strokeWidth: 0.75 }),
764
+ 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 }),
765
+ text(lx + rMax * 2 + 11, ly - 2 * s.r + 3.5, fmt(s.v), { fill: theme.muted, fontSize: 10.5 }),
766
+ ]);
767
+ if (tall && spec.labels !== false) {
768
+ const x = proj.bounds.x + proj.bounds.w + 26;
769
+ const legH = rMax * 2 + 12;
770
+ 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 }));
771
+ } else {
772
+ const chosen = new Set(chooseLabeled(m, spec, locale, Math.min(5, m.values.size)));
773
+ 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)));
774
+ body.push(...zoomLayer(B, layer));
775
+ }
776
+ if (tall) body.push(g({ class: 'pa-legend pa-size-legend' }, leg));
777
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map')) + describeExtras(markers, layers, locale);
778
+ return frame.finish(body, { desc, type: 'symbolmap', defs: H.defs() });
779
+ }
780
+
781
+ function describeExtras(markers, layers, locale) {
782
+ const en = String(locale).startsWith('en');
783
+ let s = '';
784
+ if (markers?.items.length) s += en ? ` ${markers.items.length} markers${markers.legend ? ` (${markers.legend})` : ''}.` : ` ${markers.items.length} marcadores${markers.legend ? ` (${markers.legend})` : ''}.`;
785
+ const named = layers.filter((L) => L.label).map((L) => L.label);
786
+ if (named.length) s += en ? ` Layers: ${named.join(', ')}.` : ` Capas: ${named.join(', ')}.`;
787
+ return s;
788
+ }
789
+
790
+ function describeMap(m, fmt, locale, what) {
791
+ const en = String(locale).startsWith('en');
792
+ if (m.categorical) {
793
+ const cnt = new Map();
794
+ for (const v of m.values.values()) cnt.set(v, (cnt.get(v) || 0) + 1);
795
+ const parts = m.categories.map((c) => `${c} (${cnt.get(c)})`).join(', ');
796
+ let s = en ? `${what} of ${humanize(m.valueF)} across ${m.values.size} areas: ${parts}.` : `${what} de ${humanize(m.valueF).toLowerCase()} en ${m.values.size} territorios: ${parts}.`;
797
+ if (m.unmatched.length) s += en ? ` Unmatched: ${m.unmatched.slice(0, 8).join(', ')}.` : ` Sin coincidencia: ${m.unmatched.slice(0, 8).join(', ')}.`;
798
+ return s;
799
+ }
800
+ if (!m.ranked.length) return `${what}.`;
801
+ const [hiF, hiV] = m.ranked[0], [loF, loV] = m.ranked[m.ranked.length - 1];
802
+ let s = en
803
+ ? `${what} of ${humanize(m.valueF)} across ${m.values.size} areas. Highest: ${nameOf(hiF, locale)} (${fmt(hiV)}). Lowest: ${nameOf(loF, locale)} (${fmt(loV)}).`
804
+ : `${what} de ${humanize(m.valueF).toLowerCase()} en ${m.values.size} territorios. Mayor: ${nameOf(hiF, locale)} (${fmt(hiV)}). Menor: ${nameOf(loF, locale)} (${fmt(loV)}).`;
805
+ if (m.unmatched.length) s += en ? ` Unmatched: ${m.unmatched.slice(0, 8).join(', ')}.` : ` Sin coincidencia: ${m.unmatched.slice(0, 8).join(', ')}.`;
806
+ return s;
807
+ }