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
package/src/dashed.js CHANGED
@@ -29,7 +29,7 @@ export function dashedMatcher(spec, data, keyField, seriesField, norm = String)
29
29
  if (d === undefined || d === null || d === false) return null;
30
30
  if (d === true) return () => true;
31
31
  const row0 = data && data[0];
32
- const field = typeof d === 'object' && !Array.isArray(d) ? d.field : typeof d === 'string' && row0 && d in row0 ? d : null;
32
+ const field = typeof d === 'object' && !Array.isArray(d) ? d.field : typeof d === 'string' && row0 && (data || []).some((r) => r && typeof r === 'object' && d in r) ? d : null;
33
33
  if (field) {
34
34
  const eq = typeof d === 'object' ? d.equals ?? d.value : undefined;
35
35
  const pairs = new Set();
package/src/data.js CHANGED
@@ -113,8 +113,8 @@ export function aggregate(values, how = 'sum') {
113
113
  if (!v.length) return NaN;
114
114
  switch (how) {
115
115
  case 'mean': case 'avg': return v.reduce((a, b) => a + b, 0) / v.length;
116
- case 'max': return Math.max(...v);
117
- case 'min': return Math.min(...v);
116
+ case 'max': return v.reduce((a, b) => (b > a ? b : a), -Infinity); // sin spread: no desborda con 150 mil filas
117
+ case 'min': return v.reduce((a, b) => (b < a ? b : a), Infinity);
118
118
  case 'count': return v.length;
119
119
  case 'last': return v[v.length - 1];
120
120
  case 'first': return v[0];
package/src/format.js CHANGED
@@ -24,7 +24,7 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
24
24
  if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
25
25
  const { group, dec } = SEP[loc(locale)];
26
26
  const neg = v < 0;
27
- let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, decimals)).split('.');
27
+ let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))).split('.');
28
28
  int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
29
29
  if (trim) frac = frac.replace(/0+$/, '');
30
30
  const out = frac ? int + dec + frac : int;
@@ -34,10 +34,11 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
34
34
  /** Decimales "inteligentes" según la magnitud. */
35
35
  export function smartDecimals(v) {
36
36
  const a = Math.abs(v);
37
- if (a === 0 || a >= 100) return 0;
37
+ if (a === 0 || !Number.isFinite(a) || a >= 100) return 0;
38
38
  if (a >= 10) return 1;
39
39
  if (a >= 1) return 2;
40
- return Math.min(4, 1 - Math.floor(Math.log10(a)) + 1);
40
+ // Bajo 1: tres cifras significativas (0,123 · 0,00012), nunca "0".
41
+ return Math.min(20, 2 - Math.floor(Math.log10(a)));
41
42
  }
42
43
 
43
44
  export function formatCompact(v, locale = 'es', decimals) {
@@ -102,7 +103,9 @@ export function makeFormat(fmt, locale = 'es', values = []) {
102
103
  return pre + s + suf;
103
104
  };
104
105
  f.type = type;
105
- f.tick = (step) => tickFormat(step, o, type, l, pre, suf, max, d);
106
+ // `ticks` (opcional): las marcas del eje, o la mayor |marca|. Fija una sola
107
+ // unidad compacta para todo el eje (sin "500 M" junto a "1.000,0 M").
108
+ f.tick = (step, ticks) => tickFormat(step, o, type, l, pre, suf, max, d, ticks);
106
109
  return f;
107
110
  }
108
111
 
@@ -113,7 +116,7 @@ function currencyPrefix(cur, l) {
113
116
  }
114
117
 
115
118
  /** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
116
- function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
119
+ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
117
120
  // Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
118
121
  const decFor = (s) => {
119
122
  const a = Math.abs(s) || 1;
@@ -127,14 +130,19 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
127
130
  const scale = o.scale ?? (max > 1.5 ? 1 : 100);
128
131
  core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
129
132
  } else if (type === 'compact' || max >= 1e4 && type !== 'integer') {
130
- core = (v) => {
131
- const a = Math.abs(v);
132
- const unit = a >= 1e12 ? 1e12 : a >= 1e9 ? 1e9 : a >= 1e6 ? 1e6 : a >= 1e3 ? 1e3 : 1;
133
- if (unit === 1) return formatNumber(v, decFor(step), l);
134
- return formatCompact(v, l, decFor(step / unit));
135
- };
133
+ // Una sola unidad por eje, la de la mayor marca, entre las que tiene el idioma
134
+ // (en español no hay "mil millones": 1.000 M). Sin ceros finales.
135
+ let top;
136
+ if (Array.isArray(ticks)) top = ticks.reduce((m, t) => (Number.isFinite(t) ? Math.max(m, Math.abs(t)) : m), 0);
137
+ else if (Number.isFinite(ticks)) top = Math.abs(ticks);
138
+ else top = step > 0 ? Math.ceil(max / step - 1e-9) * step : max;
139
+ const [unit, sufx] = COMPACT[l].find(([n]) => top >= n) || [1, ''];
140
+ core = unit === 1
141
+ ? (v) => formatNumber(v, decFor(step), l)
142
+ : (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
136
143
  } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
137
144
  return (v) => {
145
+ if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
138
146
  const s = core(v);
139
147
  if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
140
148
  return pre + s + suf;
@@ -144,25 +152,49 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
144
152
  // ---------------------------------------------------------------------------
145
153
  // Fechas
146
154
 
147
- const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/;
155
+ const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?/i;
148
156
 
149
- /** Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC). */
157
+ /**
158
+ * Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC).
159
+ * Sin zona horaria, la hora se toma tal cual (como UTC); con `Z` o `±hh:mm`
160
+ * se respeta el desfase, así datos con zonas distintas se ordenan bien.
161
+ */
150
162
  export function parseDate(v) {
151
163
  if (v instanceof Date) return v.getTime();
152
164
  if (typeof v === 'string') {
153
165
  const m = ISO.exec(v.trim());
154
- if (m) return Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
166
+ if (m) {
167
+ const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
168
+ let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
169
+ const z = m[8];
170
+ if (z && z.toUpperCase() !== 'Z') {
171
+ const sign = z[0] === '-' ? -1 : 1;
172
+ const hh = +z.slice(1, 3), mm = +(z.slice(1).replace(':', '').slice(2, 4) || 0);
173
+ t -= sign * (hh * 60 + mm) * 6e4;
174
+ }
175
+ return t;
176
+ }
155
177
  }
156
178
  return NaN;
157
179
  }
158
180
 
159
181
  export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
160
182
 
183
+ /**
184
+ * Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
185
+ * En 'hour'/'minute', `withYear`: true → fecha completa + "HH:mm";
186
+ * 'day' → fecha sin año + hora; false → solo "HH:mm".
187
+ */
161
188
  export function formatDate(ms, unit, locale = 'es', withYear = true) {
162
189
  const d = new Date(ms);
163
190
  const l = loc(locale);
164
191
  const y = d.getUTCFullYear();
165
192
  const m = MONTHS[l][d.getUTCMonth()];
193
+ if (unit === 'hour' || unit === 'minute') {
194
+ const hm = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
195
+ if (withYear === false) return hm;
196
+ return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
197
+ }
166
198
  if (unit === 'year') return String(y);
167
199
  if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
168
200
  if (unit === 'month') return withYear ? `${m} ${y}` : m;
package/src/frame.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Marco común de cada gráfico: título (un hecho), bajada, leyenda, área de
2
2
  // trazado y línea de fuente chica a la derecha. Nada de cajas ni filetes.
3
3
  import { el, esc, g, text, r2 } from './svg.js';
4
- import { measure, wrap } from './text.js';
4
+ import { measure, wrap, truncate } from './text.js';
5
5
  import { FONT, FONT_CSS } from './theme.js';
6
6
  import { ctx } from './context.js';
7
7
 
@@ -18,7 +18,11 @@ export function createFrame(spec, theme, defaults = {}) {
18
18
  const width = Math.max(240, +spec.width || defaults.width || 720);
19
19
  // Con textos más grandes (textScale), el alto por defecto crece para no aplastar los datos.
20
20
  const XS = ctx.textScale;
21
- let height = Math.max(160, +spec.height || Math.round((defaults.height || 420) * (1 + 0.55 * (XS - 1))));
21
+ // En gráficos anchos el alto por defecto crece con el ancho (hasta ~2,8:1),
22
+ // para que una lámina de 1500 px no quede como una franja vacía.
23
+ const h0 = defaults.height || 420;
24
+ const hW = defaults.fixedHeight ? h0 : Math.max(h0, Math.min(Math.round(h0 * 1.35), Math.round(width * 0.36)));
25
+ let height = Math.max(160, +spec.height || Math.round(hW * (1 + 0.55 * (XS - 1))));
22
26
  // Responsivo: en pantallas angostas, menos margen y títulos algo menores
23
27
  // (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
24
28
  const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
@@ -45,39 +49,66 @@ export function createFrame(spec, theme, defaults = {}) {
45
49
  if (subLines.length) y += subLines.length * slh + 2;
46
50
  if (titleLines.length || subLines.length) y += 10;
47
51
 
48
- const sourceH = spec.source ? Math.round(18 * Math.max(1, X * 0.95)) : 0;
52
+ const srcText = spec.source ? (/^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source) : '';
53
+ // Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
54
+ const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
55
+ const srcLH = Math.round(T.source * X * 1.35);
56
+ const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
57
+ const legendPos = legendPosition(spec, width);
58
+ let legendBottom = null; // leyenda inferior: se dibuja en finish(), cuando el alto es definitivo
49
59
 
50
60
  const frame = {
51
61
  width,
52
62
  pad,
53
63
  theme,
64
+ legendPosition: legendPos,
54
65
  get height() { return height; },
55
66
  top: y,
56
- get bottom() { return height - pad - sourceH; },
57
- /** Alto extra fuera del área de trazado (pie + margen). */
58
- chrome: pad + sourceH,
59
- /** Dibuja la leyenda y devuelve el área de trazado restante. */
67
+ get bottom() { return height - pad - sourceH - (legendBottom ? legendBottom.h + 12 : 0); },
68
+ /** Alto extra fuera del área de trazado (pie + margen + leyenda inferior). */
69
+ get chrome() { return pad + sourceH + (legendBottom ? legendBottom.h + 12 : 0); },
70
+ /**
71
+ * Dibuja la leyenda y devuelve el área de trazado restante. La leyenda va
72
+ * arriba por defecto; `legend: "bottom" | "left" | "right"` la mueve.
73
+ */
60
74
  layout(legend = [], opts = {}) {
61
75
  let ly = y;
62
- if (legend && legend.length) {
63
- const { svg, h } = drawLegend(legend, pad, ly, innerW, theme, { ...opts, size: T.legend });
76
+ const items = withExcluded(legend, spec, theme);
77
+ const extraBottom = Math.max(0, (XS - 1) * 22);
78
+ if (items.length && (legendPos === 'left' || legendPos === 'right')) {
79
+ const colW = Math.min(innerW * 0.32, Math.max(90, legendWidth(items, T.legend)));
80
+ const { svg } = drawLegend(items, legendPos === 'left' ? pad : width - pad - colW, ly + 2, colW, theme, { ...opts, size: T.legend, vertical: true });
81
+ header.push(svg);
82
+ const gap = 16;
83
+ return { x: legendPos === 'left' ? pad + colW + gap : pad, y: ly, w: innerW - colW - gap, h: Math.max(40, frame.bottom - ly - extraBottom) };
84
+ }
85
+ if (items.length && legendPos === 'bottom') {
86
+ const probe = drawLegend(items, pad, 0, innerW, theme, { ...opts, size: T.legend });
87
+ legendBottom = { items, opts, h: probe.h };
88
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
89
+ }
90
+ if (items.length) {
91
+ const { svg, h } = drawLegend(items, pad, ly, innerW, theme, { ...opts, size: T.legend });
64
92
  header.push(svg);
65
93
  ly += h + 10; // la leyenda pegada al gráfico que explica
66
94
  }
67
95
  // Banda extra bajo el área para etiquetas y títulos de eje agrandados.
68
- return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - Math.max(0, (XS - 1) * 22)) };
96
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
69
97
  },
70
98
  /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
71
99
  setHeight(h) { height = h; },
72
100
  finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
73
- if (spec.source) {
74
- const src = /^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source;
75
- parts.push(text(width - pad, height - pad - 2, src, { fill: theme.muted, fontSize: T.source, textAnchor: 'end' }));
101
+ if (legendBottom) {
102
+ const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
103
+ header.push(svg);
76
104
  }
77
- const id = 'pa' + Math.abs(hash((spec.title || '') + type + width)).toString(36);
105
+ srcLines.forEach((l, i) => parts.push(text(width - pad, height - pad - 2 - (srcLines.length - 1 - i) * srcLH, l, { fill: theme.muted, fontSize: T.source, textAnchor: 'end' })));
106
+ // Id estable pero distinto entre gráficos sin título del mismo tipo (aria-labelledby).
107
+ const id = 'pa' + Math.abs(hash((spec.title || '') + type + width + desc)).toString(36);
78
108
  const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
79
- const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}`
80
- + (/^(choropleth|symbolmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '');
109
+ const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}@media (prefers-reduced-motion:reduce){.pa-m{transition:none}}.pa-chart:focus-visible{outline:2px solid ${theme.categorical[0]};outline-offset:2px}`
110
+ + (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
111
+ + (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '');
81
112
  return el(
82
113
  'svg',
83
114
  {
@@ -92,6 +123,8 @@ export function createFrame(spec, theme, defaults = {}) {
92
123
  'data-pa-type': type,
93
124
  'data-pa-mode': theme.mode,
94
125
  'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
126
+ 'data-pa-top': r2(frame.top),
127
+ 'data-pa-tip': theme.tooltip && (theme.tooltip.bg || theme.tooltip.ink) ? `${theme.tooltip.bg || ''},${theme.tooltip.ink || ''}` : null,
95
128
  style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
96
129
  },
97
130
  [
@@ -110,40 +143,76 @@ export function createFrame(spec, theme, defaults = {}) {
110
143
  return frame;
111
144
  }
112
145
 
146
+ /** Posición de la leyenda: `legend: "top" | "bottom" | "left" | "right"` (o { position }). */
147
+ export function legendPosition(spec, width = 720) {
148
+ const v = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : spec.legendPosition;
149
+ const p = String(v || 'top').toLowerCase();
150
+ const pos = { arriba: 'top', abajo: 'bottom', izquierda: 'left', derecha: 'right' }[p] || p;
151
+ if (!['top', 'bottom', 'left', 'right'].includes(pos)) return 'top';
152
+ // En pantallas angostas una columna lateral roba demasiado ancho: va abajo.
153
+ if ((pos === 'left' || pos === 'right') && width < 520) return 'bottom';
154
+ return pos;
155
+ }
156
+
157
+ /** Entradas excluidas con shift + clic (spec.exclude): siguen en la leyenda, apagadas, para volver a sumarlas. */
158
+ function withExcluded(items, spec, theme) {
159
+ const ex = spec._excluded;
160
+ if (!ex || !ex.length || !items.length) return items;
161
+ const have = new Set(items.map((it) => String(it.value ?? it.label)));
162
+ const extra = ex.filter((v) => !have.has(String(v))).map((v) => ({ label: String(v), value: v, color: (spec.colors && spec.colors[v]) || theme.dim, shape: items[0]?.shape === 'line' ? 'line' : items[0]?.shape || 'square', off: true }));
163
+ return [...items, ...extra];
164
+ }
165
+
166
+ function legendWidth(items, size) {
167
+ const X = ctx.textScale;
168
+ return Math.max(...items.map((it) => (it.type === 'ramp' ? 220 : 18 * X + measure(String(it.label), size) + 12)));
169
+ }
170
+
113
171
  function drawLegend(items, x0, y0, maxW, theme, opts) {
114
172
  const out = [];
115
173
  const size = opts.size ?? SIZES.legend;
116
174
  const X = ctx.textScale;
117
- const rowH = Math.round(20 * X);
175
+ const rowH = Math.round((opts.vertical ? 22 : 20) * X);
176
+ const vertical = !!opts.vertical;
118
177
  let x = x0, y = y0, rows = 1;
119
178
  for (const it of items) {
120
179
  if (it.type === 'ramp') {
180
+ if (vertical && x > x0) { x = x0; y += rowH; rows++; }
121
181
  const w = Math.min(220, maxW);
122
182
  const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
123
183
  const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
124
184
  out.push(el('defs', {}, el('linearGradient', { id: gid }, stops)));
125
- if (it.title) { out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size })); x += measure(it.title, size) + 10; }
185
+ if (it.title) {
186
+ out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size }));
187
+ if (vertical) { y += rowH; rows++; } else x += measure(it.title, size) + 10;
188
+ }
126
189
  out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
127
190
  x += measure(it.min, SIZES.axis) + 6;
128
- out.push(el('rect', { x, y: y + 2, width: w * 0.6, height: 10, rx: 5, fill: `url(#${gid})` }));
129
- x += w * 0.6 + 6;
191
+ const rw = vertical ? Math.max(40, Math.min(w * 0.6, maxW - (x - x0) - measure(it.max, SIZES.axis) - 6)) : w * 0.6;
192
+ out.push(el('rect', { x, y: y + 2, width: rw, height: 10, rx: 5, fill: `url(#${gid})` }));
193
+ x += rw + 6;
130
194
  out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
131
195
  x += measure(it.max, SIZES.axis) + 18;
132
196
  continue;
133
197
  }
134
- const label = String(it.label);
198
+ const sw = 10 * Math.min(1.6, X);
199
+ let label = String(it.label);
200
+ if (vertical) label = truncate(label, maxW - sw - 10, size);
135
201
  const w = 18 * X + measure(label, size) + 18;
136
- if (x + w > x0 + maxW && x > x0) { x = x0; y += rowH; rows++; }
202
+ if ((vertical || x + w > x0 + maxW) && x > x0) { x = x0; y += rowH; rows++; }
137
203
  const cy = y + 6.5 * X;
138
- out.push(`<g class="pa-key" data-color="${esc(it.key ?? it.color)}" style="cursor:pointer">`);
204
+ const v = it.value !== undefined ? it.value : it.key ? undefined : it.label;
205
+ const val = v !== undefined ? ` data-value="${esc(String(v))}"` : '';
206
+ const faded = it.off ? 0.45 : it.active === false ? 0.5 : null;
207
+ out.push(`<g class="pa-key${it.off ? ' pa-key-off' : ''}" data-color="${esc(it.key ?? it.color)}"${val}${it.active ? ' aria-pressed="true"' : ''}${faded ? ` opacity="${faded}"` : ''} style="cursor:pointer">`);
139
208
  out.push(el('rect', { x: x - 3, y: y - 3, width: w - 10, height: rowH - 1, rx: 4, fill: 'transparent' }));
140
- const sw = 10 * Math.min(1.6, X);
141
209
  // Entradas punteadas: línea discontinua o cuadro rayado con borde discontinuo.
142
- if (it.shape === 'line') out.push(el('line', { x1: x, x2: x + sw * 1.2, y1: cy, y2: cy, stroke: it.color, strokeWidth: 2.5, strokeLinecap: it.dashed ? 'butt' : 'round', strokeDasharray: it.dashed ? '4 2.5' : null }));
210
+ if (it.off) out.push(el('rect', { x: x + 0.75, y: cy - sw / 2 + 0.75, width: sw - 1.5, height: sw - 1.5, rx: 2, fill: 'none', stroke: it.color, strokeWidth: 1.5 }));
211
+ else if (it.shape === 'line') out.push(el('line', { x1: x, x2: x + sw * 1.2, y1: cy, y2: cy, stroke: it.color, strokeWidth: 2.5, strokeLinecap: it.dashed ? 'butt' : 'round', strokeDasharray: it.dashed ? '4 2.5' : null }));
143
212
  else if (it.shape === 'square' && it.dashed) out.push(el('rect', { x: x + 0.6, y: cy - sw / 2 + 0.6, width: sw - 1.2, height: sw - 1.2, rx: 2, fill: it.fill || it.color, stroke: it.stroke || it.color, strokeWidth: 1.2, strokeDasharray: '3 2' }));
144
213
  else if (it.shape === 'square') out.push(el('rect', { x, y: cy - sw / 2, width: sw, height: sw, rx: 2.5, fill: it.color }));
145
214
  else out.push(el('circle', { cx: x + sw / 2, cy, r: sw / 2, fill: it.color }));
146
- out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size }));
215
+ out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size, textDecoration: it.off ? 'line-through' : null }));
147
216
  out.push('</g>');
148
217
  x += w;
149
218
  }
@@ -61,19 +61,69 @@ export function albersUsa() {
61
61
  return f;
62
62
  }
63
63
 
64
- export const projections = { mercator, equalEarth, 'equal-earth': equalEarth, albersUsa, 'albers-usa': albersUsa, albers: albersUsa };
64
+ /** Equirectangular (placa carré): lon/lat tal cual. Útil para mapas chicos o datos ya en grados. */
65
+ export function equirectangular() {
66
+ return ([lon, lat]) => [lon * RAD, -lat * RAD];
67
+ }
68
+
69
+ /**
70
+ * Coordenadas planas, sin proyectar: para GeoJSON en metros (UTM), en
71
+ * píxeles o dibujado a mano. `identity` deja el eje y hacia abajo (como
72
+ * SVG); `planar` lo invierte (y hacia arriba, como un plano cartesiano o UTM).
73
+ */
74
+ export function identity() { const f = ([x, y]) => [x, y]; f.continuous = true; return f; }
75
+ export function planar() { const f = ([x, y]) => [x, -y]; f.continuous = true; return f; }
76
+
77
+ /** Cónica de áreas iguales con paralelos y meridiano central propios. */
78
+ export function conicEqualArea({ parallels = [29.5, 45.5], rotate, center = [0, 0] } = {}) {
79
+ const lon0 = rotate !== undefined ? -[].concat(rotate)[0] : center[0];
80
+ const f = conic(-lon0, [lon0, center[1] ?? 0], parallels);
81
+ return ([lon, lat]) => f(lon, lat);
82
+ }
83
+
84
+ export const projections = {
85
+ mercator, equalEarth, 'equal-earth': equalEarth, albersUsa, 'albers-usa': albersUsa, albers: albersUsa,
86
+ equirectangular, 'plate-carree': equirectangular, latlon: equirectangular,
87
+ identity, none: identity, pixels: identity, planar, cartesian: planar, utm: planar,
88
+ conic: conicEqualArea, conicEqualArea, 'conic-equal-area': conicEqualArea,
89
+ };
90
+
91
+ /**
92
+ * Proyección desde el spec: un nombre ("mercator", "identity"…) o un objeto
93
+ * { type: "conic", parallels: [-30, -45], rotate: 72 }. Devuelve null si no
94
+ * se reconoce (se elige una automáticamente).
95
+ */
96
+ export function makeProjection(p) {
97
+ if (!p) return null;
98
+ if (typeof p === 'string') { const f = projections[p] || projections[p.toLowerCase()]; return f ? f() : null; }
99
+ if (typeof p === 'object') { const f = projections[p.type || p.name]; return f ? f(p) : null; }
100
+ return null;
101
+ }
65
102
 
66
- /** Recorre todos los anillos de una geometría. */
103
+ /**
104
+ * Todas las secuencias de puntos de una geometría (anillos de polígonos,
105
+ * líneas y puntos): sirve para encuadrar y para saber si la forma existe.
106
+ */
67
107
  export function rings(geom) {
68
- if (!geom) return [];
69
- if (geom.type === 'Polygon') return geom.coordinates;
70
- if (geom.type === 'MultiPolygon') return geom.coordinates.flat();
71
- return [];
108
+ if (!geom || !geom.type) return [];
109
+ const c = geom.coordinates;
110
+ switch (geom.type) {
111
+ case 'Polygon': case 'MultiLineString': return c || [];
112
+ case 'MultiPolygon': return (c || []).flat();
113
+ case 'LineString': case 'MultiPoint': return c && c.length ? [c] : [];
114
+ case 'Point': return c && c.length >= 2 ? [[c]] : [];
115
+ case 'GeometryCollection': return (geom.geometries || []).flatMap(rings);
116
+ default: return [];
117
+ }
72
118
  }
119
+ /** ¿La geometría es de puntos o de líneas (sin área)? */
120
+ export const isPointGeom = (geom) => !!geom && (geom.type === 'Point' || geom.type === 'MultiPoint');
121
+ export const isLineGeom = (geom) => !!geom && (geom.type === 'LineString' || geom.type === 'MultiLineString');
73
122
  export function polygonsOf(geom) {
74
123
  if (!geom) return [];
75
124
  if (geom.type === 'Polygon') return [geom.coordinates];
76
125
  if (geom.type === 'MultiPolygon') return geom.coordinates;
126
+ if (geom.type === 'GeometryCollection') return (geom.geometries || []).flatMap(polygonsOf);
77
127
  return [];
78
128
  }
79
129
 
@@ -114,9 +164,17 @@ const r1 = (v) => Math.round(v * 10) / 10;
114
164
 
115
165
  /**
116
166
  * Geometría → atributo d de SVG. Los anillos que saltan más de 180° de
117
- * longitud (antimeridiano) se cortan para no cruzar el mapa entero.
167
+ * longitud (antimeridiano) se cortan para no cruzar el mapa entero. Las
168
+ * líneas quedan abiertas y los puntos se dibujan como círculos de radio `r`.
118
169
  */
119
- export function pathD(geom, proj) {
170
+ export function pathD(geom, proj, { r = 4.5 } = {}) {
171
+ if (!geom) return '';
172
+ if (geom.type === 'GeometryCollection') return (geom.geometries || []).map((x) => pathD(x, proj, { r })).join('');
173
+ if (isPointGeom(geom)) {
174
+ const pts = geom.type === 'Point' ? [geom.coordinates] : geom.coordinates || [];
175
+ return pts.map((p) => { const [x, y] = proj(p); return `M${r1(x - r)},${r1(y)}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0`; }).join('');
176
+ }
177
+ const open = isLineGeom(geom);
120
178
  let d = '';
121
179
  const cut = !proj.continuous;
122
180
  for (const ring of rings(geom)) {
@@ -130,13 +188,20 @@ export function pathD(geom, proj) {
130
188
  d += (started ? 'L' : 'M') + X + ',' + Y;
131
189
  started = true; lx = X; ly = Y; n++;
132
190
  }
133
- if (n) d += 'Z';
191
+ if (n && !open) d += 'Z';
134
192
  }
135
193
  return d;
136
194
  }
137
195
 
138
196
  /** Área (en px²) y centroide del polígono proyectado más grande. */
139
197
  export function centroid(geom, proj) {
198
+ if (!polygonsOf(geom).length) {
199
+ // Puntos y líneas: el promedio de sus vértices (área chica fija para las etiquetas).
200
+ const pts = rings(geom).flat().map(proj);
201
+ if (!pts.length) return { x: 0, y: 0, area: 0 };
202
+ const mid = isLineGeom(geom) ? pts[Math.floor(pts.length / 2)] : [pts.reduce((a, p) => a + p[0], 0) / pts.length, pts.reduce((a, p) => a + p[1], 0) / pts.length];
203
+ return { x: mid[0], y: mid[1], area: 0, point: true };
204
+ }
140
205
  let best = null;
141
206
  for (const poly of polygonsOf(geom)) {
142
207
  const pts = poly[0].map(proj);
@@ -1,31 +1,110 @@
1
1
  // Registro de geometrías. El núcleo no trae mapas pesados: se registran al
2
2
  // importar 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o se pasa un
3
3
  // GeoJSON propio en spec.geo.
4
+ //
5
+ // `geo` acepta, además de un nombre registrado:
6
+ // · un GeoJSON FeatureCollection, un Feature, un arreglo de Features o una geometría suelta
7
+ // · un TopoJSON (Topology); `geoObject` elige el objeto (por defecto, el primero)
8
+ // · en el navegador, una URL (render() la descarga; ver runtime.js)
4
9
  import { PlotError } from '../data.js';
5
10
 
6
11
  const REG = new Map();
7
12
 
8
- export function registerGeo(name, fc, aliases = []) {
9
- if (!fc || fc.type !== 'FeatureCollection') throw new PlotError(`La geometría "${name}" debe ser un GeoJSON FeatureCollection.`);
13
+ export function registerGeo(name, geo, aliases = []) {
14
+ const fc = toFeatureCollection(geo);
10
15
  for (const n of [name, ...aliases]) REG.set(String(n).toLowerCase(), fc);
11
16
  return fc;
12
17
  }
13
18
 
14
- export function getGeo(geo) {
15
- if (geo && typeof geo === 'object') {
16
- if (geo.type === 'FeatureCollection') return geo;
17
- if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
18
- throw new PlotError('`geo` debe ser un nombre registrado o un GeoJSON FeatureCollection.');
19
+ /** ¿Parece una URL o una ruta a un archivo .json / .geojson / .topojson? */
20
+ export const isGeoUrl = (s) => typeof s === 'string' && (/^(https?:)?\/\//i.test(s) || /^(\.{0,2}\/|data:)/.test(s) || /\.(geo|topo)?json(\?.*)?$/i.test(s));
21
+
22
+ export function getGeo(geo, objectName) {
23
+ if (geo && typeof geo === 'object') return toFeatureCollection(geo, objectName);
24
+ if (isGeoUrl(geo)) {
25
+ throw new PlotError(
26
+ `"${geo}" es una URL o un archivo: plot() es sincrónico y no descarga nada.`,
27
+ "En Node, lee el archivo y pásalo como objeto: geo: JSON.parse(fs.readFileSync('mapa.geojson', 'utf8')). En el navegador, render(el, spec) sí acepta la URL."
28
+ );
19
29
  }
20
30
  const fc = REG.get(String(geo || '').toLowerCase());
21
31
  if (!fc) {
22
32
  const known = [...new Set(REG.keys())];
23
33
  throw new PlotError(
24
34
  `No hay una geometría registrada con el nombre "${geo}".`,
25
- known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
35
+ known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON o TopoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
26
36
  );
27
37
  }
28
38
  return fc;
29
39
  }
30
40
 
31
41
  export const registeredGeo = () => [...new Set(REG.keys())];
42
+
43
+ const GEOMS = new Set(['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']);
44
+ const cache = new WeakMap();
45
+
46
+ /** Cualquier forma de GeoJSON / TopoJSON → FeatureCollection. */
47
+ export function toFeatureCollection(geo, objectName) {
48
+ if (!geo || typeof geo !== 'object') throw new PlotError('`geo` debe ser un nombre registrado, un GeoJSON o un TopoJSON.', 'geo: "chile" | "usa" | "world" | { type: "FeatureCollection", features: [...] }');
49
+ if (Array.isArray(geo)) return { type: 'FeatureCollection', features: geo.map(asFeature) };
50
+ if (geo.type === 'FeatureCollection') {
51
+ if (!Array.isArray(geo.features)) throw new PlotError('El FeatureCollection no tiene `features`.');
52
+ return geo.features.every((f) => f && f.type === 'Feature') ? geo : { ...geo, features: geo.features.map(asFeature) };
53
+ }
54
+ if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
55
+ if (GEOMS.has(geo.type)) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: geo }] };
56
+ if (geo.type === 'Topology') {
57
+ const key = objectName ?? '';
58
+ const memo = cache.get(geo) || {};
59
+ if (!memo[key]) { memo[key] = topoFeatures(geo, objectName); cache.set(geo, memo); }
60
+ return memo[key];
61
+ }
62
+ throw new PlotError(`No reconozco este \`geo\` (type: "${geo.type}").`, 'Usa un GeoJSON (FeatureCollection, Feature o geometría) o un TopoJSON (type: "Topology").');
63
+ }
64
+
65
+ function asFeature(f) {
66
+ if (f && f.type === 'Feature') return f;
67
+ if (f && GEOMS.has(f.type)) return { type: 'Feature', properties: {}, geometry: f };
68
+ throw new PlotError('Cada elemento de `geo` debe ser un Feature de GeoJSON.');
69
+ }
70
+
71
+ // ── TopoJSON (decodificador mínimo, sin dependencias) ──────────────────────
72
+ function topoFeatures(topo, objectName) {
73
+ const names = Object.keys(topo.objects || {});
74
+ if (!names.length) throw new PlotError('El TopoJSON no tiene objetos.');
75
+ const name = objectName ?? names[0];
76
+ const obj = topo.objects[name];
77
+ if (!obj) throw new PlotError(`El TopoJSON no tiene el objeto "${name}".`, `Objetos disponibles: ${names.join(', ')}. Indícalo con \`geoObject\`.`);
78
+ const tf = topo.transform;
79
+ // Arcos absolutos (deshace la cuantización y la codificación delta).
80
+ const arcs = (topo.arcs || []).map((arc) => {
81
+ if (!tf) return arc.map((p) => p.slice(0, 2));
82
+ let x = 0, y = 0;
83
+ return arc.map((p) => { x += p[0]; y += p[1]; return [x * tf.scale[0] + tf.translate[0], y * tf.scale[1] + tf.translate[1]]; });
84
+ });
85
+ const point = (p) => (tf ? [p[0] * tf.scale[0] + tf.translate[0], p[1] * tf.scale[1] + tf.translate[1]] : p.slice(0, 2));
86
+ const line = (ids) => {
87
+ const out = [];
88
+ for (const i of ids) {
89
+ const a = i < 0 ? arcs[~i].slice().reverse() : arcs[i];
90
+ if (!a) continue;
91
+ for (let k = out.length ? 1 : 0; k < a.length; k++) out.push(a[k]);
92
+ }
93
+ return out;
94
+ };
95
+ const ring = (ids) => { const r = line(ids); if (r.length && (r[0][0] !== r[r.length - 1][0] || r[0][1] !== r[r.length - 1][1])) r.push(r[0]); return r; };
96
+ const geom = (o) => {
97
+ switch (o.type) {
98
+ case 'Point': return { type: 'Point', coordinates: point(o.coordinates) };
99
+ case 'MultiPoint': return { type: 'MultiPoint', coordinates: o.coordinates.map(point) };
100
+ case 'LineString': return { type: 'LineString', coordinates: line(o.arcs) };
101
+ case 'MultiLineString': return { type: 'MultiLineString', coordinates: o.arcs.map(line) };
102
+ case 'Polygon': return { type: 'Polygon', coordinates: o.arcs.map(ring) };
103
+ case 'MultiPolygon': return { type: 'MultiPolygon', coordinates: o.arcs.map((p) => p.map(ring)) };
104
+ default: return null;
105
+ }
106
+ };
107
+ const feat = (o) => ({ type: 'Feature', id: o.id, properties: { ...(o.properties || {}), ...(o.id !== undefined && (o.properties || {}).id === undefined ? { id: o.id } : {}) }, geometry: geom(o) });
108
+ const list = obj.type === 'GeometryCollection' ? obj.geometries.map(feat) : [feat(obj)];
109
+ return { type: 'FeatureCollection', features: list.filter((f) => f.geometry) };
110
+ }