plotagents 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
@@ -8,12 +8,12 @@
8
8
  import { el, g, text } from '../svg.js';
9
9
  import { measure, truncate } from '../text.js';
10
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';
11
+ import { ramp, textOn, colorMap } from '../theme.js';
12
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, aggregate, PlotError } from '../data.js';
13
13
  import { createFrame, hash } from '../frame.js';
14
14
  import { niceStep } from '../scale.js';
15
15
  import { getGeo } from '../geo/registry.js';
16
- import { mercator, equalEarth, projections, fit, aspectOf, pathD, centroid, rings } from '../geo/projection.js';
16
+ import { mercator, equalEarth, planar, projections, makeProjection, fit, aspectOf, pathD, centroid, rings, isPointGeom, isLineGeom } from '../geo/projection.js';
17
17
  import { dashedMatcher, hatcher, dashedAttrs, dashedDefaultLabel, DASH } from '../dashed.js';
18
18
 
19
19
  const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
@@ -32,7 +32,8 @@ function featureIndex(features, featureKey) {
32
32
  const add = (k, f) => { const n = normKey(k); if (n && !idx.has(n)) idx.set(n, f); };
33
33
  for (const f of features) {
34
34
  const p = f.properties || {};
35
- if (featureKey) { add(p[featureKey], f); continue; }
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);
36
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);
37
38
  for (const a of p.aliases || []) add(a, f);
38
39
  }
@@ -41,9 +42,42 @@ function featureIndex(features, featureKey) {
41
42
 
42
43
  const nameOf = (f, locale) => {
43
44
  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
+ return (String(locale).startsWith('en') ? p.nameEn || p.name : p.name || p.nameEn) || p.NAME || p.nombre || String(p.code ?? p.id ?? f.id ?? '');
45
46
  };
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
+
47
81
  /** Redondeo "bonito" de un corte de clase (2 cifras relativas al rango). */
48
82
  function niceRound(v, range) {
49
83
  if (!range) return v;
@@ -89,7 +123,7 @@ export function colorScale(values, spec, theme) {
89
123
  breaks = [...new Set(breaks)].filter((b) => b > min && b <= max);
90
124
  }
91
125
  const n = breaks.length + 1;
92
- const t0 = theme.mode === 'dark' ? 0.26 : 0.16;
126
+ const t0 = theme.cmap ? 0 : theme.mode === 'dark' ? 0.26 : 0.16;
93
127
  const colors = Array.from({ length: n }, (_, i) => ramp(stops, n === 1 ? 0.6 : t0 + ((1 - t0) * i) / (n - 1)));
94
128
  return classed(breaks, colors, min, max);
95
129
  }
@@ -173,21 +207,40 @@ const noDataPattern = (id, theme) =>
173
207
 
174
208
  /** Datos + geometría emparejados. */
175
209
  function prepare(spec, locale) {
176
- const fc = getGeo(spec.geo ?? 'chile');
210
+ const fc = getGeo(spec.geo ?? 'chile', spec.geoObject);
177
211
  const data = ensureData(spec);
178
212
  const fields = fieldsOf(data);
179
213
  const key = spec.key ?? spec.region ?? spec.x ?? spec.label ?? fields.find((f) => fieldKind(data, f) === 'category');
180
214
  const valueF = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => f !== key && fieldKind(data, f) === 'number');
181
215
  requireField(data, key, 'key');
182
216
  requireField(data, valueF, 'value');
183
- let features = fc.features.filter((f) => f.geometry && rings(f.geometry).length);
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);
184
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);
185
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
+ }
186
238
  // Territorios opcionales (Puerto Rico en 'usa'): solo se dibujan si hay datos para ellos.
187
239
  if (features.some((f) => f.properties?.optional)) {
188
240
  const used = new Set(data.map((r) => idx.get(normKey(r[key]))).filter(Boolean));
189
241
  features = features.filter((f) => !f.properties?.optional || used.has(f) || spec.territories === true);
190
- idx = featureIndex(features, spec.featureKey);
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);
191
244
  }
192
245
  const buckets = new Map();
193
246
  const unmatched = [];
@@ -195,20 +248,129 @@ function prepare(spec, locale) {
195
248
  const f = idx.get(normKey(r[key]));
196
249
  if (!f) { if (r[key] !== undefined && r[key] !== null && r[key] !== '') unmatched.push(String(r[key])); continue; }
197
250
  if (!buckets.has(f)) buckets.set(f, []);
198
- buckets.get(f).push(num(r[valueF]));
251
+ buckets.get(f).push(categorical ? r[valueF] : num(r[valueF]));
199
252
  }
200
253
  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); }
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
+ }
202
259
  if (Array.isArray(spec._unmatched)) spec._unmatched.push(...new Set(unmatched));
203
- const ranked = [...values.entries()].sort((a, b) => b[1] - a[1]);
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]);
204
265
  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)();
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());
208
273
  // `dashed`: por nombre de región (con los mismos alias) o por un campo de los datos.
209
274
  const dm = dashedMatcher(spec, data, key, null, normKey);
210
275
  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 };
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 };
212
374
  }
213
375
 
214
376
  /**
@@ -360,11 +522,37 @@ function insetFrames(features, proj, theme) {
360
522
  function baseChart(spec, theme, kind) {
361
523
  const locale = spec.locale || 'es';
362
524
  const m = prepare(spec, locale);
363
- const vals = [...m.values.values()];
364
- const fmt = makeFormat(spec.format, locale, vals);
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;
365
529
  const W = spec.width || 720;
366
- const aspect = aspectOf(m.features, m.raw);
367
- const tall = aspect > 1.6 && m.features.length <= 40;
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;
368
556
  // Alto por defecto según la forma del territorio.
369
557
  const defH = tall ? (W < 560 ? 660 : 740) : Math.round(W * Math.min(1.1, aspect) + 150);
370
558
  const frame = createFrame(spec, theme, { height: defH });
@@ -376,7 +564,13 @@ function baseChart(spec, theme, kind) {
376
564
  const H = hatcher(theme, gid);
377
565
  const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
378
566
  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) {
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) {
380
574
  sc = colorScale(vals, spec, theme);
381
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 });
382
576
  body.push(lg.svg);
@@ -403,7 +597,30 @@ function baseChart(spec, theme, kind) {
403
597
  }
404
598
  lx += 10;
405
599
  }
406
- if (dashedKey && !(kind === 'choropleth' && vals.length)) {
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))) {
407
624
  const size = theme.type?.axis ?? 11.5;
408
625
  const ky = rowItems.length ? area.y + 2 * rMax - 9 : area.y + 4.6;
409
626
  rowItems.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
@@ -434,9 +651,13 @@ function baseChart(spec, theme, kind) {
434
651
  let mapW = Math.min(area.w * (1 - colMin), area.h / aspect);
435
652
  box = [area.x, area.y, area.x + mapW, area.y + area.h];
436
653
  } 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' });
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' });
438
659
  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 };
660
+ return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax, layers, markers };
440
661
  }
441
662
 
442
663
  const rankText = (r, n, locale) => (String(locale).startsWith('en') ? `#${r} of ${n}` : `${r}.º de ${n}`);
@@ -454,7 +675,9 @@ function zoomLayer(B, children) {
454
675
 
455
676
  export function choropleth(spec, theme) {
456
677
  const B = baseChart(spec, theme, 'choropleth');
457
- const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX } = B;
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;
458
681
  const hl = spec.highlight === undefined ? null : [].concat(spec.highlight).map(normKey);
459
682
  const isHl = (f) => hl && featureIndexHas(f, hl);
460
683
  const N = m.values.size;
@@ -466,8 +689,9 @@ export function choropleth(spec, theme) {
466
689
  const base = has ? sc.color(v) : null;
467
690
  const isDashed = m.dashed.has(f);
468
691
  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);
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);
471
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) };
472
696
  let fill = has ? base : spec.missing === 'plain' ? theme.tint : `url(#${gid}-nd)`;
473
697
  let stroke = null;
@@ -479,39 +703,45 @@ export function choropleth(spec, theme) {
479
703
  } else if (isDashed) {
480
704
  stroke = theme.muted;
481
705
  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 }));
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 }));
483
708
  if (isHl(f)) outlines.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', 'pointer-events': 'none' }));
484
709
  const c = centroid(f.geometry, proj);
485
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 });
486
711
  }
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)];
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));
488
716
  const after = [];
489
717
  if (tall && spec.labels !== false) {
490
718
  const x = proj.bounds.x + proj.bounds.w + 26;
491
719
  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 }));
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 }));
493
721
  } else if (spec.labels !== false && m.features.some((f) => f.properties?.abbr) && !Array.isArray(spec.labels) && typeof spec.labels !== 'number') {
494
722
  const [lab, ...rest] = abbrLabels(items, proj, theme, { calloutX, showValues: spec.values !== false, top: area.y, bottom: area.y + area.h });
495
723
  layer.push(lab);
496
724
  after.push(...rest);
497
725
  } else {
498
726
  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)));
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)));
501
730
  }
502
731
  body.push(...(tall ? layer : zoomLayer(B, layer)), ...after);
503
- const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map'));
732
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map')) + describeExtras(markers, layers, locale);
504
733
  return frame.finish(body, { desc, type: 'choropleth', defs: noDataPattern(`${gid}-nd`, theme) + H.defs() });
505
734
  }
506
735
 
507
736
  /** Mapa de símbolos: círculos de área proporcional en el centroide. */
508
737
  export function symbolMap(spec, theme) {
509
738
  const B = baseChart(spec, theme, 'symbol');
510
- const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax } = B;
739
+ const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax, layers, markers } = B;
511
740
  const N = m.values.size;
512
741
  const maxV = Math.max(...[...m.values.values()].map(Math.abs), 0) || 1;
513
742
  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)];
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))];
515
745
  const items = m.features.map((f) => {
516
746
  const c = centroid(f.geometry, proj);
517
747
  const v = m.values.get(f);
@@ -525,6 +755,7 @@ export function symbolMap(spec, theme) {
525
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,
526
756
  ...(it.dashedShape ? { ...dashedAttrs(H, color, { width: 1.3 }) } : { fill: color, fillOpacity: 0.75, stroke: theme.surface, strokeWidth: 1.5 }) }));
527
757
  layer.push(g({ class: 'pa-symbols' }, circles));
758
+ if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, []));
528
759
  // Leyenda de tamaños (tres círculos anidados) en la esquina libre.
529
760
  const sizes = [maxV, maxV / 4, maxV / 16].map((v) => ({ v, r: Math.sqrt(v / maxV) * rMax }));
530
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;
@@ -543,12 +774,29 @@ export function symbolMap(spec, theme) {
543
774
  body.push(...zoomLayer(B, layer));
544
775
  }
545
776
  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'));
777
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map')) + describeExtras(markers, layers, locale);
547
778
  return frame.finish(body, { desc, type: 'symbolmap', defs: H.defs() });
548
779
  }
549
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
+
550
790
  function describeMap(m, fmt, locale, what) {
551
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
+ }
552
800
  if (!m.ranked.length) return `${what}.`;
553
801
  const [hiF, hiV] = m.ranked[0], [loF, loV] = m.ranked[m.ranked.length - 1];
554
802
  let s = en