plotagents 0.5.0 → 0.8.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 +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. 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);
@@ -24,7 +24,8 @@ export function normKey(s) {
24
24
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
25
25
  .toLowerCase()
26
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, '');
27
+ .replace(/[^a-z0-9]/g, '')
28
+ .replace(/^0+(?=\d)/, ''); // códigos con cero a la izquierda: "08" = 8, "004" = 4
28
29
  }
29
30
 
30
31
  function featureIndex(features, featureKey) {
@@ -32,7 +33,8 @@ function featureIndex(features, featureKey) {
32
33
  const add = (k, f) => { const n = normKey(k); if (n && !idx.has(n)) idx.set(n, f); };
33
34
  for (const f of features) {
34
35
  const p = f.properties || {};
35
- if (featureKey) { add(p[featureKey], f); continue; }
36
+ if (featureKey) { add(featureKey === 'id' && p.id === undefined ? f.id : p[featureKey], f); continue; }
37
+ if (f.id !== undefined && f.id !== null) add(f.id, f);
36
38
  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
39
  for (const a of p.aliases || []) add(a, f);
38
40
  }
@@ -41,9 +43,42 @@ function featureIndex(features, featureKey) {
41
43
 
42
44
  const nameOf = (f, locale) => {
43
45
  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 ?? '');
46
+ 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
47
  };
46
48
 
49
+ // Propiedades que suelen traer el nombre en GeoJSON de otras fuentes (INE, IGM, Natural Earth, GADM, censos).
50
+ 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'];
51
+ const primitive = (v) => typeof v === 'string' || typeof v === 'number';
52
+
53
+ /**
54
+ * GeoJSON ajeno: busca la propiedad cuyos valores calzan con más filas de
55
+ * los datos (p. ej. "CUT_REG", "NOM_COM" o "GEOID"). Devuelve null si
56
+ * ninguna mejora lo que ya calzaba.
57
+ */
58
+ function bestProperty(features, keys, already) {
59
+ const want = new Set(keys.map(normKey).filter(Boolean));
60
+ if (!want.size) return null;
61
+ const props = new Set();
62
+ for (const f of features.slice(0, 80)) for (const [k, v] of Object.entries(f.properties || {})) if (primitive(v)) props.add(k);
63
+ let best = null, bestN = already;
64
+ for (const k of props) {
65
+ let n = 0;
66
+ const seen = new Set(features.map((f) => normKey(f.properties?.[k])));
67
+ for (const w of want) if (seen.has(w)) n++;
68
+ if (n > bestN) { best = k; bestN = n; }
69
+ }
70
+ return best;
71
+ }
72
+
73
+ /** Copia las features con `name` resuelto (nameKey explícito o adivinado). */
74
+ function withNames(features, nameKey) {
75
+ return features.map((f) => {
76
+ const p = f.properties || {};
77
+ const v = p[nameKey];
78
+ return primitive(v) && p.name !== v ? { ...f, properties: { ...p, name: String(v) } } : f;
79
+ });
80
+ }
81
+
47
82
  /** Redondeo "bonito" de un corte de clase (2 cifras relativas al rango). */
48
83
  function niceRound(v, range) {
49
84
  if (!range) return v;
@@ -89,7 +124,7 @@ export function colorScale(values, spec, theme) {
89
124
  breaks = [...new Set(breaks)].filter((b) => b > min && b <= max);
90
125
  }
91
126
  const n = breaks.length + 1;
92
- const t0 = theme.mode === 'dark' ? 0.26 : 0.16;
127
+ const t0 = theme.cmap ? 0 : theme.mode === 'dark' ? 0.26 : 0.16;
93
128
  const colors = Array.from({ length: n }, (_, i) => ramp(stops, n === 1 ? 0.6 : t0 + ((1 - t0) * i) / (n - 1)));
94
129
  return classed(breaks, colors, min, max);
95
130
  }
@@ -173,21 +208,41 @@ const noDataPattern = (id, theme) =>
173
208
 
174
209
  /** Datos + geometría emparejados. */
175
210
  function prepare(spec, locale) {
176
- const fc = getGeo(spec.geo ?? 'chile');
211
+ const fc = getGeo(spec.geo ?? 'chile', spec.geoObject);
177
212
  const data = ensureData(spec);
178
213
  const fields = fieldsOf(data);
179
214
  const key = spec.key ?? spec.region ?? spec.x ?? spec.label ?? fields.find((f) => fieldKind(data, f) === 'category');
180
215
  const valueF = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => f !== key && fieldKind(data, f) === 'number');
181
216
  requireField(data, key, 'key');
182
217
  requireField(data, valueF, 'value');
183
- let features = fc.features.filter((f) => f.geometry && rings(f.geometry).length);
218
+ // Valores de texto ("Izquierda", "Derecha"…): un color por categoría.
219
+ const categorical = spec.scale === 'categorical' || (data.length > 0 && fieldKind(data, valueF) === 'category');
220
+ let features = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
184
221
  if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
222
+ if (!features.length) throw new PlotError(T(locale, 'El `geo` no tiene geometrías para dibujar.', 'The `geo` has no geometries to draw.'), T(locale, 'Pasa un GeoJSON con al menos un Feature con geometría.', 'Pass a GeoJSON with at least one Feature that has a geometry.'));
223
+ // Nombres: `nameKey` o la primera propiedad conocida (NOMBRE, NAME_1, ADMIN…).
224
+ const hasName = (f) => { const p = f.properties || {}; return primitive(p.name) || primitive(p.nameEn) || primitive(p.NAME) || primitive(p.nombre); };
225
+ 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; };
226
+ const nameKey = spec.nameKey ?? (features.length && !features.some(hasName) ? NAME_PROPS.find((k) => features.some((f) => primitive(f.properties?.[k]))) ?? spec.featureKey ?? firstText() : null);
227
+ if (nameKey) features = withNames(features, nameKey);
185
228
  let idx = featureIndex(features, spec.featureKey);
229
+ // Sin `featureKey`: si las filas no calzan con las claves de siempre, se busca la propiedad que sí calza.
230
+ let autoKey = null;
231
+ if (!spec.featureKey && data.length) {
232
+ const keys = data.map((r) => r[key]).filter(primitive);
233
+ const matched = keys.filter((k) => idx.has(normKey(k))).length;
234
+ if (matched < new Set(keys.map(normKey)).size) {
235
+ autoKey = bestProperty(features, keys, new Set(keys.filter((k) => idx.has(normKey(k))).map(normKey)).size);
236
+ if (autoKey) for (const [k, f] of featureIndex(features, autoKey)) if (!idx.has(k)) idx.set(k, f);
237
+ if (autoKey && !nameKey && !features.some(hasName)) features = withNames(features, autoKey), idx = new Map([...featureIndex(features, null), ...featureIndex(features, autoKey)]);
238
+ }
239
+ }
186
240
  // Territorios opcionales (Puerto Rico en 'usa'): solo se dibujan si hay datos para ellos.
187
241
  if (features.some((f) => f.properties?.optional)) {
188
242
  const used = new Set(data.map((r) => idx.get(normKey(r[key]))).filter(Boolean));
189
243
  features = features.filter((f) => !f.properties?.optional || used.has(f) || spec.territories === true);
190
- idx = featureIndex(features, spec.featureKey);
244
+ idx = featureIndex(features, spec.featureKey ?? autoKey);
245
+ if (!spec.featureKey && autoKey) for (const [k, f] of featureIndex(features, null)) if (!idx.has(k)) idx.set(k, f);
191
246
  }
192
247
  const buckets = new Map();
193
248
  const unmatched = [];
@@ -195,20 +250,129 @@ function prepare(spec, locale) {
195
250
  const f = idx.get(normKey(r[key]));
196
251
  if (!f) { if (r[key] !== undefined && r[key] !== null && r[key] !== '') unmatched.push(String(r[key])); continue; }
197
252
  if (!buckets.has(f)) buckets.set(f, []);
198
- buckets.get(f).push(num(r[valueF]));
253
+ buckets.get(f).push(categorical ? r[valueF] : num(r[valueF]));
199
254
  }
200
255
  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); }
256
+ 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]; };
257
+ for (const [f, vs] of buckets) {
258
+ const v = categorical ? mode(vs) : aggregate(vs, spec.aggregate || 'mean');
259
+ if (categorical ? v !== undefined : Number.isFinite(v)) values.set(f, v);
260
+ }
202
261
  if (Array.isArray(spec._unmatched)) spec._unmatched.push(...new Set(unmatched));
203
- const ranked = [...values.entries()].sort((a, b) => b[1] - a[1]);
262
+ // Categorías ordenadas por cuántas áreas cubren (o el orden dado en `order`).
263
+ const count = new Map();
264
+ for (const v of values.values()) count.set(v, (count.get(v) || 0) + 1);
265
+ 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;
266
+ const ranked = categorical ? [] : [...values.entries()].sort((a, b) => b[1] - a[1]);
204
267
  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)();
268
+ // Proyección: la pedida; si no, una según la geometría (coordenadas fuera de
269
+ // lon/lat → plano cartesiano, p. ej. UTM; mundo → Equal Earth; resto → Mercator).
270
+ const projName = typeof spec.projection === 'string' ? spec.projection : fc.projection ?? (/^(world|mundo|countries|paises|países)$/i.test(String(spec.geo)) ? 'equalEarth' : null);
271
+ let lon0 = Infinity, lon1 = -Infinity, latMax = 0;
272
+ 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]); }
273
+ const projected = Math.max(Math.abs(lon0), Math.abs(lon1)) > 180.5 || latMax > 90.5;
274
+ const raw = makeProjection(spec.projection) ?? (projName && projections[projName] ? projections[projName]() : projected ? planar() : lon1 - lon0 > 100 ? equalEarth() : mercator());
208
275
  // `dashed`: por nombre de región (con los mismos alias) o por un campo de los datos.
209
276
  const dm = dashedMatcher(spec, data, key, null, normKey);
210
277
  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 };
278
+ return { fc, features, values, rank, ranked, unmatched: [...new Set(unmatched)], key, valueF, raw, data, dashed, categorical, categories };
279
+ }
280
+
281
+ // ── Capas extra: marcadores (lon/lat) y GeoJSON superpuesto ─────────────────
282
+ const pickF = (fields, names) => names.find((n) => fields.includes(n));
283
+
284
+ /** `markers`: [{ lon, lat, label, value }] o { data, lon, lat, label, size, color, legend }. */
285
+ function markerList(spec, theme) {
286
+ const mk = spec.markers;
287
+ if (!mk) return null;
288
+ const o = Array.isArray(mk) ? {} : mk;
289
+ const rows = ensureData({ data: Array.isArray(mk) ? mk : mk.data || [] });
290
+ const fields = fieldsOf(rows);
291
+ const lonF = o.lon ?? pickF(fields, ['lon', 'lng', 'long', 'longitude', 'longitud', 'x']);
292
+ const latF = o.lat ?? pickF(fields, ['lat', 'latitude', 'latitud', 'y']);
293
+ const labelF = o.label ?? pickF(fields, ['label', 'name', 'nombre', 'city', 'ciudad', 'title']);
294
+ const sizeF = o.size ?? pickF(fields, ['size', 'value', 'valor']);
295
+ const colorF = typeof o.color === 'string' && fields.includes(o.color) ? o.color : pickF(fields, ['color']);
296
+ if (!lonF || !latF) throw new PlotError('Los marcadores necesitan longitud y latitud.', 'markers: [{ lon: -73.05, lat: -36.82, label: "Concepción" }]');
297
+ 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 }))
298
+ .filter((m) => Number.isFinite(m.lon) && Number.isFinite(m.lat));
299
+ const maxS = Math.max(0, ...items.map((m) => Math.abs(m.size)).filter(Number.isFinite));
300
+ 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;
301
+ 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 };
302
+ }
303
+
304
+ /** `layers`: [{ geo, stroke, fill, width, dash, opacity, label, below }]. */
305
+ function layerList(spec, theme) {
306
+ return [].concat(spec.layers || []).filter(Boolean).map((L, i) => {
307
+ const fc = getGeo(L.geo ?? L.data, L.object ?? L.geoObject);
308
+ const fs = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
309
+ const lines = fs.every((f) => isLineGeom(f.geometry));
310
+ 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' };
311
+ });
312
+ }
313
+
314
+ function drawLayer(L, proj, theme, locale) {
315
+ return g({ class: 'pa-layer', 'pointer-events': L.label || L.tooltip !== false ? null : 'none' }, L.features.map((f) => {
316
+ const pt = isPointGeom(f.geometry);
317
+ const nm = nameOf(f, locale);
318
+ const tipT = [L.label, nm && nm !== String(f.id ?? '') ? nm : null].filter(Boolean).join('\n');
319
+ return el('path', {
320
+ class: tipT ? 'pa-m' : null, 'data-tip': tipT || null, d: pathD(f.geometry, proj, { r: L.radius ?? 3 }),
321
+ fill: pt ? (L.fill !== 'none' ? L.fill : L.stroke) : L.fill, fillOpacity: L.fillOpacity ?? null,
322
+ stroke: pt ? theme.surface : L.stroke, strokeWidth: pt ? 1 : L.width, strokeDasharray: L.dash === true ? '5 4' : L.dash || null,
323
+ strokeLinejoin: 'round', strokeLinecap: 'round', opacity: L.opacity ?? null, 'data-color': L.stroke,
324
+ });
325
+ }));
326
+ }
327
+
328
+ function drawMarkers(mk, proj, theme, fmt, placed) {
329
+ const dots = [], labs = [];
330
+ const halo = { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' };
331
+ for (const m of [...mk.items].sort((a, b) => b.r - a.r)) {
332
+ const [x, y] = proj([m.lon, m.lat]);
333
+ m.x = x; m.y = y;
334
+ const c = m.color || mk.color;
335
+ const tipT = [m.label, Number.isFinite(m.size) && mk.sizeF ? fmt(m.size) : null].filter((v) => v !== undefined && v !== null && v !== '').join('\n');
336
+ 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 }));
337
+ }
338
+ 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]);
339
+ if (mk.labels) for (const m of mk.items) {
340
+ if (m.label === undefined || m.label === null || m.label === '') continue;
341
+ const w = measure(String(m.label), 11, 600);
342
+ const hit = (box) => placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
343
+ // A la derecha del punto; si choca, a la izquierda; si no, encima.
344
+ const cands = [
345
+ { 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' },
346
+ { 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' },
347
+ { 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' },
348
+ ];
349
+ const c = cands.find((k) => !hit(k.box));
350
+ if (!c) continue;
351
+ placed.push(c.box);
352
+ labs.push(text(c.x, c.y, String(m.label), { fill: theme.ink, fontSize: 11, fontWeight: 600, textAnchor: c.anchor, ...halo }));
353
+ }
354
+ return [g({ class: 'pa-markers' }, dots), g({ class: 'pa-map-label pa-marker-labels', 'pointer-events': 'none' }, labs)];
355
+ }
356
+
357
+ /** Leyenda de categorías (mapas con valores de texto): cuadros enlazados con las regiones. */
358
+ function catLegend(sc, x, y, maxW, theme, { title, missing, locale, gid, dashed }) {
359
+ const out = [];
360
+ const size = theme.type?.axis ?? 11.5;
361
+ let lx = x, ly = y, rows = 1;
362
+ if (title) { out.push(text(lx, ly + 9.5, title, { fill: theme.lede, fontSize: 12, fontWeight: 500 })); lx += measure(title, 12, 500) + 14; }
363
+ const item = (label, attrs, key) => {
364
+ const w = 14 + measure(label, size) + 16;
365
+ if (lx + w > x + maxW && lx > x) { lx = x; ly += 20; rows++; }
366
+ out.push(g({ class: 'pa-key', 'data-color': key, style: 'cursor:pointer' }, [
367
+ el('rect', { x: lx, y: ly, width: 10, height: 10, rx: 2, ...attrs }),
368
+ text(lx + 14, ly + 9, label, { fill: theme.lede, fontSize: size }),
369
+ ]));
370
+ lx += w;
371
+ };
372
+ for (const c of sc.cats) item(c, { fill: sc.color(c) }, sc.color(c));
373
+ if (missing) item(T(locale, 'Sin dato', 'No data'), { fill: `url(#${gid}-nd)`, stroke: theme.axis, strokeWidth: 0.5 }, `url(#${gid}-nd)`);
374
+ if (dashed) item(dashed.label, { fill: dashed.fill, stroke: dashed.stroke, strokeWidth: 1.1, strokeDasharray: DASH.legend }, 'dashed');
375
+ return { svg: g({ class: 'pa-legend pa-map-legend' }, out), h: rows * 20 + 2 };
212
376
  }
213
377
 
214
378
  /**
@@ -360,11 +524,37 @@ function insetFrames(features, proj, theme) {
360
524
  function baseChart(spec, theme, kind) {
361
525
  const locale = spec.locale || 'es';
362
526
  const m = prepare(spec, locale);
363
- const vals = [...m.values.values()];
364
- const fmt = makeFormat(spec.format, locale, vals);
527
+ 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.'));
528
+ const vals = m.categorical ? [] : [...m.values.values()];
529
+ const numFmt = makeFormat(spec.format, locale, vals);
530
+ const fmt = m.categorical ? (v) => (typeof v === 'number' ? numFmt(v) : String(v ?? '')) : numFmt;
365
531
  const W = spec.width || 720;
366
- const aspect = aspectOf(m.features, m.raw);
367
- const tall = aspect > 1.6 && m.features.length <= 40;
532
+ const layers = layerList(spec, theme);
533
+ const markers = markerList(spec, theme);
534
+ // `fitTo`: encuadra unas regiones (nombres o códigos), una caja [lon0, lat0, lon1, lat1] o "markers".
535
+ let fitFeats = m.features;
536
+ if (spec.fitTo !== undefined && spec.fitTo !== null) {
537
+ const ft = spec.fitTo;
538
+ if (Array.isArray(ft) && ft.length === 4 && ft.every((v) => typeof v === 'number')) {
539
+ const [a, b, c, d] = ft;
540
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[a, b], [c, b], [c, d], [a, d], [a, b]]] } }];
541
+ } else if (ft === 'markers' && markers?.items.length) {
542
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: markers.items.map((x) => [x.lon, x.lat]) } }];
543
+ } else {
544
+ const want = [].concat(ft).map(normKey);
545
+ const hit = m.features.filter((f) => featureIndexHas(f, want));
546
+ if (hit.length) fitFeats = hit;
547
+ }
548
+ // Un margen del 6 % para que lo encuadrado no toque el borde.
549
+ if (fitFeats !== m.features) {
550
+ let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity;
551
+ 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]); }
552
+ const px = (c - a) * 0.06 || 0.5, py = (d - b) * 0.06 || 0.5;
553
+ fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: [[a - px, b - py], [c + px, d + py], [a - px, d + py], [c + px, b - py]] } }];
554
+ }
555
+ }
556
+ const aspect = aspectOf(fitFeats, m.raw);
557
+ const tall = aspect > 1.6 && m.features.length <= 40 && fitFeats === m.features && spec.tall !== false;
368
558
  // Alto por defecto según la forma del territorio.
369
559
  const defH = tall ? (W < 560 ? 660 : 740) : Math.round(W * Math.min(1.1, aspect) + 150);
370
560
  const frame = createFrame(spec, theme, { height: defH });
@@ -376,7 +566,13 @@ function baseChart(spec, theme, kind) {
376
566
  const H = hatcher(theme, gid);
377
567
  const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
378
568
  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) {
569
+ if (kind === 'choropleth' && m.categorical && m.values.size) {
570
+ const cmap = colorMap(m.categories, theme, spec.colors);
571
+ sc = { mode: 'categories', cats: m.categories, color: (v) => cmap.get(String(v)) || theme.other };
572
+ 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 });
573
+ body.push(lg.svg);
574
+ area = { ...area, y: area.y + lg.h + 8, h: area.h - lg.h - 8 };
575
+ } else if (kind === 'choropleth' && vals.length) {
380
576
  sc = colorScale(vals, spec, theme);
381
577
  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
578
  body.push(lg.svg);
@@ -403,7 +599,30 @@ function baseChart(spec, theme, kind) {
403
599
  }
404
600
  lx += 10;
405
601
  }
406
- if (dashedKey && !(kind === 'choropleth' && vals.length)) {
602
+ // Claves de las capas y marcadores con nombre (`label` / `legend`).
603
+ {
604
+ const size = theme.type?.axis ?? 11.5;
605
+ const ky = rowItems.length ? area.y + 2 * (rMax || 0) - 9 : area.y + 4.6;
606
+ for (const L of layers) {
607
+ if (!L.label) continue;
608
+ const pts = L.features.every((f) => isPointGeom(f.geometry)), poly = !pts && !L.features.every((f) => isLineGeom(f.geometry));
609
+ rowItems.push(g({ class: 'pa-key', 'data-color': L.stroke, style: 'cursor:pointer' }, [
610
+ pts ? el('circle', { cx: lx + 5, cy: ky + 4.9, r: 3.5, fill: L.fill !== 'none' ? L.fill : L.stroke })
611
+ : 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 })
612
+ : 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' }),
613
+ text(lx + (pts || poly ? 15 : 19), ky + 8.4, L.label, { fill: theme.lede, fontSize: size }),
614
+ ]));
615
+ lx += (pts || poly ? 15 : 19) + measure(L.label, size) + 16;
616
+ }
617
+ if (markers?.legend) {
618
+ rowItems.push(g({ class: 'pa-key', 'data-color': markers.color, style: 'cursor:pointer' }, [
619
+ el('circle', { cx: lx + 5, cy: ky + 4.9, r: 4.5, fill: markers.color, stroke: theme.surface, strokeWidth: 1 }),
620
+ text(lx + 15, ky + 8.4, markers.legend, { fill: theme.lede, fontSize: size }),
621
+ ]));
622
+ lx += 15 + measure(markers.legend, size) + 16;
623
+ }
624
+ }
625
+ if (dashedKey && !(kind === 'choropleth' && (vals.length || m.categorical))) {
407
626
  const size = theme.type?.axis ?? 11.5;
408
627
  const ky = rowItems.length ? area.y + 2 * rMax - 9 : area.y + 4.6;
409
628
  rowItems.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
@@ -434,9 +653,13 @@ function baseChart(spec, theme, kind) {
434
653
  let mapW = Math.min(area.w * (1 - colMin), area.h / aspect);
435
654
  box = [area.x, area.y, area.x + mapW, area.y + area.h];
436
655
  } 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' });
656
+ // Puntos y marcadores en el borde: margen para el círculo y su etiqueta.
657
+ const inset = m.features.some((f) => isPointGeom(f.geometry)) ? (spec.pointRadius ?? 5) + 18 : markers?.items.length ? 12 : 0;
658
+ const pts = m.features.some((f) => isPointGeom(f.geometry));
659
+ if (inset) box = [box[0] + inset * (tall ? 0 : 1), box[1] + inset + (pts ? 12 : 0), box[2] - inset, box[3] - Math.min(inset, 10)];
660
+ const proj = fit(fitFeats, m.raw, box, { align: tall ? 'left' : 'center' });
438
661
  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 };
662
+ return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax, layers, markers };
440
663
  }
441
664
 
442
665
  const rankText = (r, n, locale) => (String(locale).startsWith('en') ? `#${r} of ${n}` : `${r}.º de ${n}`);
@@ -454,7 +677,9 @@ function zoomLayer(B, children) {
454
677
 
455
678
  export function choropleth(spec, theme) {
456
679
  const B = baseChart(spec, theme, 'choropleth');
457
- const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX } = B;
680
+ const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX, layers, markers } = B;
681
+ const borderC = spec.borderColor ?? theme.surface, borderW = spec.borderWidth ?? 0.75;
682
+ const ptR = spec.pointRadius ?? 5;
458
683
  const hl = spec.highlight === undefined ? null : [].concat(spec.highlight).map(normKey);
459
684
  const isHl = (f) => hl && featureIndexHas(f, hl);
460
685
  const N = m.values.size;
@@ -466,8 +691,9 @@ export function choropleth(spec, theme) {
466
691
  const base = has ? sc.color(v) : null;
467
692
  const isDashed = m.dashed.has(f);
468
693
  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);
694
+ 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}` : '');
695
+ const d = pathD(f.geometry, proj, { r: ptR });
696
+ const isLine = isLineGeom(f.geometry);
471
697
  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
698
  let fill = has ? base : spec.missing === 'plain' ? theme.tint : `url(#${gid}-nd)`;
473
699
  let stroke = null;
@@ -479,39 +705,45 @@ export function choropleth(spec, theme) {
479
705
  } else if (isDashed) {
480
706
  stroke = theme.muted;
481
707
  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 }));
708
+ } 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 }));
709
+ 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
710
  if (isHl(f)) outlines.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', 'pointer-events': 'none' }));
484
711
  const c = centroid(f.geometry, proj);
485
712
  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
713
  }
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)];
714
+ 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),
715
+ ...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
716
+ const placed = [];
717
+ if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, placed));
488
718
  const after = [];
489
719
  if (tall && spec.labels !== false) {
490
720
  const x = proj.bounds.x + proj.bounds.w + 26;
491
721
  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 }));
722
+ 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
723
  } else if (spec.labels !== false && m.features.some((f) => f.properties?.abbr) && !Array.isArray(spec.labels) && typeof spec.labels !== 'number') {
494
724
  const [lab, ...rest] = abbrLabels(items, proj, theme, { calloutX, showValues: spec.values !== false, top: area.y, bottom: area.y + area.h });
495
725
  layer.push(lab);
496
726
  after.push(...rest);
497
727
  } else {
498
728
  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)));
729
+ const ordered = items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.area - a.area)
730
+ .map((it) => (isPointGeom(it.f.geometry) ? { ...it, cy: it.cy - ptR - 12 } : it));
731
+ layer.push(g({ class: 'pa-zoom-hide' }, mapLabels(ordered, theme, placed)));
501
732
  }
502
733
  body.push(...(tall ? layer : zoomLayer(B, layer)), ...after);
503
- const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map'));
734
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map')) + describeExtras(markers, layers, locale);
504
735
  return frame.finish(body, { desc, type: 'choropleth', defs: noDataPattern(`${gid}-nd`, theme) + H.defs() });
505
736
  }
506
737
 
507
738
  /** Mapa de símbolos: círculos de área proporcional en el centroide. */
508
739
  export function symbolMap(spec, theme) {
509
740
  const B = baseChart(spec, theme, 'symbol');
510
- const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax } = B;
741
+ const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax, layers, markers } = B;
511
742
  const N = m.values.size;
512
743
  const maxV = Math.max(...[...m.values.values()].map(Math.abs), 0) || 1;
513
744
  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)];
745
+ 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),
746
+ ...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
515
747
  const items = m.features.map((f) => {
516
748
  const c = centroid(f.geometry, proj);
517
749
  const v = m.values.get(f);
@@ -525,6 +757,7 @@ export function symbolMap(spec, theme) {
525
757
  .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
758
  ...(it.dashedShape ? { ...dashedAttrs(H, color, { width: 1.3 }) } : { fill: color, fillOpacity: 0.75, stroke: theme.surface, strokeWidth: 1.5 }) }));
527
759
  layer.push(g({ class: 'pa-symbols' }, circles));
760
+ if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, []));
528
761
  // Leyenda de tamaños (tres círculos anidados) en la esquina libre.
529
762
  const sizes = [maxV, maxV / 4, maxV / 16].map((v) => ({ v, r: Math.sqrt(v / maxV) * rMax }));
530
763
  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 +776,29 @@ export function symbolMap(spec, theme) {
543
776
  body.push(...zoomLayer(B, layer));
544
777
  }
545
778
  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'));
779
+ const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map')) + describeExtras(markers, layers, locale);
547
780
  return frame.finish(body, { desc, type: 'symbolmap', defs: H.defs() });
548
781
  }
549
782
 
783
+ function describeExtras(markers, layers, locale) {
784
+ const en = String(locale).startsWith('en');
785
+ let s = '';
786
+ if (markers?.items.length) s += en ? ` ${markers.items.length} markers${markers.legend ? ` (${markers.legend})` : ''}.` : ` ${markers.items.length} marcadores${markers.legend ? ` (${markers.legend})` : ''}.`;
787
+ const named = layers.filter((L) => L.label).map((L) => L.label);
788
+ if (named.length) s += en ? ` Layers: ${named.join(', ')}.` : ` Capas: ${named.join(', ')}.`;
789
+ return s;
790
+ }
791
+
550
792
  function describeMap(m, fmt, locale, what) {
551
793
  const en = String(locale).startsWith('en');
794
+ if (m.categorical) {
795
+ const cnt = new Map();
796
+ for (const v of m.values.values()) cnt.set(v, (cnt.get(v) || 0) + 1);
797
+ const parts = m.categories.map((c) => `${c} (${cnt.get(c)})`).join(', ');
798
+ 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}.`;
799
+ if (m.unmatched.length) s += en ? ` Unmatched: ${m.unmatched.slice(0, 8).join(', ')}.` : ` Sin coincidencia: ${m.unmatched.slice(0, 8).join(', ')}.`;
800
+ return s;
801
+ }
552
802
  if (!m.ranked.length) return `${what}.`;
553
803
  const [hiF, hiV] = m.ranked[0], [loF, loV] = m.ranked[m.ranked.length - 1];
554
804
  let s = en