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
package/src/format.js CHANGED
@@ -4,19 +4,29 @@
4
4
  const SEP = {
5
5
  es: { group: '.', dec: ',' },
6
6
  en: { group: ',', dec: '.' },
7
+ pt: { group: '.', dec: ',' },
8
+ de: { group: '.', dec: ',' },
9
+ fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
7
10
  };
8
11
 
9
12
  const COMPACT = {
10
13
  es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
11
14
  en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
15
+ pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
16
+ fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
17
+ de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
12
18
  };
13
19
 
14
20
  export const MONTHS = {
15
21
  es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
16
22
  en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
23
+ pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
24
+ fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
25
+ de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
17
26
  };
18
27
 
19
- const loc = (l) => (l && String(l).toLowerCase().startsWith('en') ? 'en' : 'es');
28
+ /** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
29
+ const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
20
30
 
21
31
  /** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
22
32
  export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
@@ -24,7 +34,8 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
24
34
  if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
25
35
  const { group, dec } = SEP[loc(locale)];
26
36
  const neg = v < 0;
27
- let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, decimals)).split('.');
37
+ const fixed = Math.abs(v) >= 1e21 ? BigInt(Math.abs(v)).toString() : Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))); // toFixed pasa a "1e+21"
38
+ let [int, frac = ''] = fixed.split('.');
28
39
  int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
29
40
  if (trim) frac = frac.replace(/0+$/, '');
30
41
  const out = frac ? int + dec + frac : int;
@@ -34,19 +45,26 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
34
45
  /** Decimales "inteligentes" según la magnitud. */
35
46
  export function smartDecimals(v) {
36
47
  const a = Math.abs(v);
37
- if (a === 0 || a >= 100) return 0;
48
+ if (a === 0 || !Number.isFinite(a) || a >= 100) return 0;
38
49
  if (a >= 10) return 1;
39
50
  if (a >= 1) return 2;
40
- return Math.min(4, 1 - Math.floor(Math.log10(a)) + 1);
51
+ // Bajo 1: tres cifras significativas (0,123 · 0,00012), nunca "0".
52
+ return Math.min(20, 2 - Math.floor(Math.log10(a)));
41
53
  }
42
54
 
43
55
  export function formatCompact(v, locale = 'es', decimals) {
44
56
  const l = loc(locale);
45
57
  const a = Math.abs(v);
46
- for (const [n, suf] of COMPACT[l]) {
58
+ const tbl = COMPACT[l];
59
+ for (let i = 0; i < tbl.length; i++) {
60
+ let [n, suf] = tbl[i];
47
61
  if (a >= n) {
48
- const s = v / n;
49
- const d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
62
+ let s = v / n;
63
+ let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
64
+ // 999.999 → "1.000 mil" no: al redondear llega a 1000, sube a la unidad siguiente (1 M).
65
+ if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
66
+ [n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
67
+ }
50
68
  return formatNumber(s, d, l, decimals === undefined) + suf;
51
69
  }
52
70
  }
@@ -102,24 +120,26 @@ export function makeFormat(fmt, locale = 'es', values = []) {
102
120
  return pre + s + suf;
103
121
  };
104
122
  f.type = type;
105
- f.tick = (step) => tickFormat(step, o, type, l, pre, suf, max, d);
123
+ // `ticks` (opcional): las marcas del eje, o la mayor |marca|. Fija una sola
124
+ // unidad compacta para todo el eje (sin "500 M" junto a "1.000,0 M").
125
+ f.tick = (step, ticks) => tickFormat(step, o, type, l, pre, suf, max, d, ticks);
106
126
  return f;
107
127
  }
108
128
 
109
- function currencyPrefix(cur, l) {
129
+ export function currencyPrefix(cur, l) {
110
130
  if (!cur) return '$';
111
131
  const c = String(cur).toUpperCase();
112
132
  return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
113
133
  }
114
134
 
115
135
  /** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
116
- function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
136
+ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
117
137
  // Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
118
138
  const decFor = (s) => {
119
139
  const a = Math.abs(s) || 1;
120
140
  let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
121
- while (d < 6 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
122
- return Math.min(6, d);
141
+ while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
142
+ return Math.min(12, d);
123
143
  };
124
144
  let core;
125
145
  if (type === 'year') core = (v) => String(Math.round(v));
@@ -127,15 +147,23 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
127
147
  const scale = o.scale ?? (max > 1.5 ? 1 : 100);
128
148
  core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
129
149
  } 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
- };
150
+ // Una sola unidad por eje, la de la mayor marca, entre las que tiene el idioma
151
+ // (en español no hay "mil millones": 1.000 M). Sin ceros finales.
152
+ let top;
153
+ if (Array.isArray(ticks)) top = ticks.reduce((m, t) => (Number.isFinite(t) ? Math.max(m, Math.abs(t)) : m), 0);
154
+ else if (Number.isFinite(ticks)) top = Math.abs(ticks);
155
+ else top = step > 0 ? Math.ceil(max / step - 1e-9) * step : max;
156
+ const [unit, sufx] = COMPACT[l].find(([n]) => top >= n) || [1, ''];
157
+ core = unit === 1
158
+ ? (v) => formatNumber(v, decFor(step), l)
159
+ : (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
136
160
  } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
161
+ // Una marca entera se escribe sin ",0" (600, no 600,0); las fraccionarias
162
+ // conservan sus decimales (0 · 0,5 · 1 · 1,5).
163
+ const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
137
164
  return (v) => {
138
- const s = core(v);
165
+ if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
166
+ const s = core(v).replace(zeroFrac, '');
139
167
  if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
140
168
  return pre + s + suf;
141
169
  };
@@ -144,28 +172,55 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
144
172
  // ---------------------------------------------------------------------------
145
173
  // Fechas
146
174
 
147
- const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/;
175
+ const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?/i;
148
176
 
149
- /** Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC). */
177
+ /**
178
+ * Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC).
179
+ * Sin zona horaria, la hora se toma tal cual (como UTC); con `Z` o `±hh:mm`
180
+ * se respeta el desfase, así datos con zonas distintas se ordenan bien.
181
+ */
150
182
  export function parseDate(v) {
151
183
  if (v instanceof Date) return v.getTime();
152
184
  if (typeof v === 'string') {
153
185
  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));
186
+ // Rangos válidos: "2020-21" (temporada) o "2024-13" no son fechas.
187
+ if (m && +m[2] >= 1 && +m[2] <= 12 && +(m[3] || 1) >= 1 && +(m[3] || 1) <= new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate() && +(m[4] || 0) < 24 && +(m[5] || 0) < 60 && +(m[6] || 0) < 60) {
188
+ const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
189
+ let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
190
+ const z = m[8];
191
+ if (z && z.toUpperCase() !== 'Z') {
192
+ const sign = z[0] === '-' ? -1 : 1;
193
+ const hh = +z.slice(1, 3), mm = +(z.slice(1).replace(':', '').slice(2, 4) || 0);
194
+ t -= sign * (hh * 60 + mm) * 6e4;
195
+ }
196
+ return t;
197
+ }
155
198
  }
156
199
  return NaN;
157
200
  }
158
201
 
159
- export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
202
+ export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
160
203
 
204
+ /**
205
+ * Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
206
+ * En 'hour'/'minute', `withYear`: true → fecha completa + "HH:mm";
207
+ * 'day' → fecha sin año + hora; false → solo "HH:mm".
208
+ */
161
209
  export function formatDate(ms, unit, locale = 'es', withYear = true) {
162
210
  const d = new Date(ms);
163
211
  const l = loc(locale);
164
212
  const y = d.getUTCFullYear();
165
213
  const m = MONTHS[l][d.getUTCMonth()];
214
+ if (unit === 'hour' || unit === 'minute') {
215
+ const hm = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
216
+ if (withYear === false) return hm;
217
+ return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
218
+ }
166
219
  if (unit === 'year') return String(y);
167
- if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
220
+ if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
168
221
  if (unit === 'month') return withYear ? `${m} ${y}` : m;
169
222
  const day = d.getUTCDate();
170
- return l === 'en' ? `${m} ${day}${withYear ? ', ' + y : ''}` : `${day} ${m}${withYear ? ' ' + y : ''}`;
223
+ if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
224
+ if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
225
+ return `${day} ${m}${withYear ? ' ' + y : ''}`;
171
226
  }
package/src/frame.js CHANGED
@@ -1,9 +1,10 @@
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
+ import { words } from './i18n.js';
7
8
 
8
9
  export const SIZES = {
9
10
  title: 22,
@@ -18,7 +19,11 @@ export function createFrame(spec, theme, defaults = {}) {
18
19
  const width = Math.max(240, +spec.width || defaults.width || 720);
19
20
  // Con textos más grandes (textScale), el alto por defecto crece para no aplastar los datos.
20
21
  const XS = ctx.textScale;
21
- let height = Math.max(160, +spec.height || Math.round((defaults.height || 420) * (1 + 0.55 * (XS - 1))));
22
+ // En gráficos anchos el alto por defecto crece con el ancho (hasta ~2,8:1),
23
+ // para que una lámina de 1500 px no quede como una franja vacía.
24
+ const h0 = defaults.height || 420;
25
+ const hW = defaults.fixedHeight ? h0 : Math.max(h0, Math.min(Math.round(h0 * 1.35), Math.round(width * 0.36)));
26
+ let height = Math.max(160, +spec.height || Math.round(hW * (1 + 0.55 * (XS - 1))));
22
27
  // Responsivo: en pantallas angostas, menos margen y títulos algo menores
23
28
  // (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
24
29
  const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
@@ -45,39 +50,67 @@ export function createFrame(spec, theme, defaults = {}) {
45
50
  if (subLines.length) y += subLines.length * slh + 2;
46
51
  if (titleLines.length || subLines.length) y += 10;
47
52
 
48
- const sourceH = spec.source ? Math.round(18 * Math.max(1, X * 0.95)) : 0;
53
+ const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
54
+ // Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
55
+ const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
56
+ const srcLH = Math.round(T.source * X * 1.35);
57
+ const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
58
+ const legendPos = legendPosition(spec, width);
59
+ let legendBottom = null; // leyenda inferior: se dibuja en finish(), cuando el alto es definitivo
49
60
 
50
61
  const frame = {
51
62
  width,
52
63
  pad,
53
64
  theme,
65
+ legendPosition: legendPos,
54
66
  get height() { return height; },
55
67
  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. */
68
+ get bottom() { return height - pad - sourceH - (legendBottom ? legendBottom.h + 12 : 0); },
69
+ /** Alto extra fuera del área de trazado (pie + margen + leyenda inferior). */
70
+ get chrome() { return pad + sourceH + (legendBottom ? legendBottom.h + 12 : 0); },
71
+ /**
72
+ * Dibuja la leyenda y devuelve el área de trazado restante. La leyenda va
73
+ * arriba por defecto; `legend: "bottom" | "left" | "right"` la mueve.
74
+ */
60
75
  layout(legend = [], opts = {}) {
61
76
  let ly = y;
62
- if (legend && legend.length) {
63
- const { svg, h } = drawLegend(legend, pad, ly, innerW, theme, { ...opts, size: T.legend });
77
+ const items = withExcluded(legend, spec, theme);
78
+ const extraBottom = Math.max(0, (XS - 1) * 22);
79
+ if (items.length && (legendPos === 'left' || legendPos === 'right')) {
80
+ const colW = Math.min(innerW * 0.32, Math.max(90, legendWidth(items, T.legend)));
81
+ const { svg } = drawLegend(items, legendPos === 'left' ? pad : width - pad - colW, ly + 2, colW, theme, { ...opts, size: T.legend, vertical: true });
82
+ header.push(svg);
83
+ const gap = 16;
84
+ return { x: legendPos === 'left' ? pad + colW + gap : pad, y: ly, w: innerW - colW - gap, h: Math.max(40, frame.bottom - ly - extraBottom) };
85
+ }
86
+ if (items.length && legendPos === 'bottom') {
87
+ const probe = drawLegend(items, pad, 0, innerW, theme, { ...opts, size: T.legend });
88
+ legendBottom = { items, opts, h: probe.h };
89
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
90
+ }
91
+ if (items.length) {
92
+ const { svg, h } = drawLegend(items, pad, ly, innerW, theme, { ...opts, size: T.legend });
64
93
  header.push(svg);
65
94
  ly += h + 10; // la leyenda pegada al gráfico que explica
66
95
  }
67
96
  // 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)) };
97
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
69
98
  },
70
99
  /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
71
100
  setHeight(h) { height = h; },
72
- 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
+ finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
102
+ if (legendBottom) {
103
+ const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
104
+ header.push(svg);
76
105
  }
77
- const id = 'pa' + Math.abs(hash((spec.title || '') + type + width)).toString(36);
106
+ 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' })));
107
+ // Id estable pero distinto entre gráficos sin título del mismo tipo (aria-labelledby).
108
+ const id = 'pa' + Math.abs(hash((spec.title || '') + type + width + desc)).toString(36);
78
109
  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}' : '');
110
+ 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}`
111
+ + (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
112
+ + (/^(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}' : '')
113
+ + css;
81
114
  return el(
82
115
  'svg',
83
116
  {
@@ -85,13 +118,16 @@ export function createFrame(spec, theme, defaults = {}) {
85
118
  viewBox: `0 0 ${width} ${r2(height)}`,
86
119
  width,
87
120
  height: r2(height),
88
- role: 'img',
89
- 'aria-labelledby': `${id}-t ${id}-d`,
121
+ role,
122
+ 'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
123
+ 'aria-describedby': role === 'img' ? null : `${id}-d`,
90
124
  fontFamily: spec.fontFamily || theme.font?.family || FONT,
91
125
  class: 'pa-chart',
92
126
  'data-pa-type': type,
93
127
  'data-pa-mode': theme.mode,
94
128
  'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
129
+ 'data-pa-top': r2(frame.top),
130
+ 'data-pa-tip': theme.tooltip && (theme.tooltip.bg || theme.tooltip.ink) ? `${theme.tooltip.bg || ''},${theme.tooltip.ink || ''}` : null,
95
131
  style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
96
132
  },
97
133
  [
@@ -110,40 +146,76 @@ export function createFrame(spec, theme, defaults = {}) {
110
146
  return frame;
111
147
  }
112
148
 
149
+ /** Posición de la leyenda: `legend: "top" | "bottom" | "left" | "right"` (o { position }). */
150
+ export function legendPosition(spec, width = 720) {
151
+ const v = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : spec.legendPosition;
152
+ const p = String(v || 'top').toLowerCase();
153
+ const pos = { arriba: 'top', abajo: 'bottom', izquierda: 'left', derecha: 'right' }[p] || p;
154
+ if (!['top', 'bottom', 'left', 'right'].includes(pos)) return 'top';
155
+ // En pantallas angostas una columna lateral roba demasiado ancho: va abajo.
156
+ if ((pos === 'left' || pos === 'right') && width < 520) return 'bottom';
157
+ return pos;
158
+ }
159
+
160
+ /** Entradas excluidas con shift + clic (spec.exclude): siguen en la leyenda, apagadas, para volver a sumarlas. */
161
+ function withExcluded(items, spec, theme) {
162
+ const ex = spec._excluded;
163
+ if (!ex || !ex.length || !items.length) return items;
164
+ const have = new Set(items.map((it) => String(it.value ?? it.label)));
165
+ 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 }));
166
+ return [...items, ...extra];
167
+ }
168
+
169
+ function legendWidth(items, size) {
170
+ const X = ctx.textScale;
171
+ return Math.max(...items.map((it) => (it.type === 'ramp' ? 220 : 18 * X + measure(String(it.label), size) + 12)));
172
+ }
173
+
113
174
  function drawLegend(items, x0, y0, maxW, theme, opts) {
114
175
  const out = [];
115
176
  const size = opts.size ?? SIZES.legend;
116
177
  const X = ctx.textScale;
117
- const rowH = Math.round(20 * X);
178
+ const rowH = Math.round((opts.vertical ? 22 : 20) * X);
179
+ const vertical = !!opts.vertical;
118
180
  let x = x0, y = y0, rows = 1;
119
181
  for (const it of items) {
120
182
  if (it.type === 'ramp') {
183
+ if (vertical && x > x0) { x = x0; y += rowH; rows++; }
121
184
  const w = Math.min(220, maxW);
122
185
  const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
123
186
  const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
124
187
  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; }
188
+ if (it.title) {
189
+ out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size }));
190
+ if (vertical) { y += rowH; rows++; } else x += measure(it.title, size) + 10;
191
+ }
126
192
  out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
127
193
  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;
194
+ const rw = vertical ? Math.max(40, Math.min(w * 0.6, maxW - (x - x0) - measure(it.max, SIZES.axis) - 6)) : w * 0.6;
195
+ out.push(el('rect', { x, y: y + 2, width: rw, height: 10, rx: 5, fill: `url(#${gid})` }));
196
+ x += rw + 6;
130
197
  out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
131
198
  x += measure(it.max, SIZES.axis) + 18;
132
199
  continue;
133
200
  }
134
- const label = String(it.label);
201
+ const sw = 10 * Math.min(1.6, X);
202
+ let label = String(it.label);
203
+ if (vertical) label = truncate(label, maxW - sw - 10, size);
135
204
  const w = 18 * X + measure(label, size) + 18;
136
- if (x + w > x0 + maxW && x > x0) { x = x0; y += rowH; rows++; }
205
+ if ((vertical || x + w > x0 + maxW) && x > x0) { x = x0; y += rowH; rows++; }
137
206
  const cy = y + 6.5 * X;
138
- out.push(`<g class="pa-key" data-color="${esc(it.key ?? it.color)}" style="cursor:pointer">`);
207
+ const v = it.value !== undefined ? it.value : it.key ? undefined : it.label;
208
+ const val = v !== undefined ? ` data-value="${esc(String(v))}"` : '';
209
+ const faded = it.off ? 0.45 : it.active === false ? 0.5 : null;
210
+ 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
211
  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
212
  // 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 }));
213
+ 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 }));
214
+ 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
215
  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
216
  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
217
  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 }));
218
+ out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size, textDecoration: it.off ? 'line-through' : null }));
147
219
  out.push('</g>');
148
220
  x += w;
149
221
  }
@@ -61,19 +61,71 @@ 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
+ // Sin distinguir mayúsculas ni guiones: "equalearth", "Equal-Earth" y "equalEarth" son lo mismo.
99
+ const find = (n) => { const k = String(n ?? '').toLowerCase().replace(/[-_\s]/g, ''); const hit = Object.keys(projections).find((x) => x.toLowerCase().replace(/[-_\s]/g, '') === k); return hit ? projections[hit] : null; };
100
+ if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
101
+ if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
102
+ return null;
103
+ }
65
104
 
66
- /** Recorre todos los anillos de una geometría. */
105
+ /**
106
+ * Todas las secuencias de puntos de una geometría (anillos de polígonos,
107
+ * líneas y puntos): sirve para encuadrar y para saber si la forma existe.
108
+ */
67
109
  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 [];
110
+ if (!geom || !geom.type) return [];
111
+ const c = geom.coordinates;
112
+ switch (geom.type) {
113
+ case 'Polygon': case 'MultiLineString': return c || [];
114
+ case 'MultiPolygon': return (c || []).flat();
115
+ case 'LineString': case 'MultiPoint': return c && c.length ? [c] : [];
116
+ case 'Point': return c && c.length >= 2 ? [[c]] : [];
117
+ case 'GeometryCollection': return (geom.geometries || []).flatMap(rings);
118
+ default: return [];
119
+ }
72
120
  }
121
+ /** ¿La geometría es de puntos o de líneas (sin área)? */
122
+ export const isPointGeom = (geom) => !!geom && (geom.type === 'Point' || geom.type === 'MultiPoint');
123
+ export const isLineGeom = (geom) => !!geom && (geom.type === 'LineString' || geom.type === 'MultiLineString');
73
124
  export function polygonsOf(geom) {
74
125
  if (!geom) return [];
75
126
  if (geom.type === 'Polygon') return [geom.coordinates];
76
127
  if (geom.type === 'MultiPolygon') return geom.coordinates;
128
+ if (geom.type === 'GeometryCollection') return (geom.geometries || []).flatMap(polygonsOf);
77
129
  return [];
78
130
  }
79
131
 
@@ -114,9 +166,17 @@ const r1 = (v) => Math.round(v * 10) / 10;
114
166
 
115
167
  /**
116
168
  * 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.
169
+ * longitud (antimeridiano) se cortan para no cruzar el mapa entero. Las
170
+ * líneas quedan abiertas y los puntos se dibujan como círculos de radio `r`.
118
171
  */
119
- export function pathD(geom, proj) {
172
+ export function pathD(geom, proj, { r = 4.5 } = {}) {
173
+ if (!geom) return '';
174
+ if (geom.type === 'GeometryCollection') return (geom.geometries || []).map((x) => pathD(x, proj, { r })).join('');
175
+ if (isPointGeom(geom)) {
176
+ const pts = geom.type === 'Point' ? [geom.coordinates] : geom.coordinates || [];
177
+ 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('');
178
+ }
179
+ const open = isLineGeom(geom);
120
180
  let d = '';
121
181
  const cut = !proj.continuous;
122
182
  for (const ring of rings(geom)) {
@@ -130,13 +190,20 @@ export function pathD(geom, proj) {
130
190
  d += (started ? 'L' : 'M') + X + ',' + Y;
131
191
  started = true; lx = X; ly = Y; n++;
132
192
  }
133
- if (n) d += 'Z';
193
+ if (n && !open) d += 'Z';
134
194
  }
135
195
  return d;
136
196
  }
137
197
 
138
198
  /** Área (en px²) y centroide del polígono proyectado más grande. */
139
199
  export function centroid(geom, proj) {
200
+ if (!polygonsOf(geom).length) {
201
+ // Puntos y líneas: el promedio de sus vértices (área chica fija para las etiquetas).
202
+ const pts = rings(geom).flat().map(proj);
203
+ if (!pts.length) return { x: 0, y: 0, area: 0 };
204
+ 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];
205
+ return { x: mid[0], y: mid[1], area: 0, point: true };
206
+ }
140
207
  let best = null;
141
208
  for (const poly of polygonsOf(geom)) {
142
209
  const pts = poly[0].map(proj);