plotagents 0.7.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 (53) hide show
  1. package/AGENTS.md +5 -1
  2. package/README.md +18 -1
  3. package/bin/plotagents.js +3 -1
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +1857 -110
  6. package/docs/charts.md +86 -1
  7. package/docs/es/agents.md +102 -0
  8. package/docs/es/api.md +172 -0
  9. package/docs/es/charts.md +436 -0
  10. package/docs/es/colorscales.md +46 -0
  11. package/docs/es/dashboards.md +79 -0
  12. package/docs/es/getting-started.md +142 -0
  13. package/docs/es/rendering.md +47 -0
  14. package/docs/es/themes.md +116 -0
  15. package/index.d.ts +92 -8
  16. package/package.json +3 -2
  17. package/schema.json +359 -9
  18. package/skills/plotagents/SKILL.md +105 -0
  19. package/src/charts/bar.js +13 -5
  20. package/src/charts/chord.js +3 -1
  21. package/src/charts/choropleth.js +3 -1
  22. package/src/charts/corr.js +512 -0
  23. package/src/charts/dist.js +2 -1
  24. package/src/charts/donut.js +3 -1
  25. package/src/charts/kpi.js +2 -2
  26. package/src/charts/line.js +19 -7
  27. package/src/charts/network.js +13 -8
  28. package/src/charts/pointmap.js +9 -6
  29. package/src/charts/regplot.js +9 -3
  30. package/src/charts/sankey.js +1 -1
  31. package/src/charts/scatter.js +4 -2
  32. package/src/charts/sunburst.js +2 -2
  33. package/src/charts/table.js +882 -0
  34. package/src/charts/treemap.js +2 -2
  35. package/src/charts/wordcloud.js +17 -3
  36. package/src/colorscales.js +12 -1
  37. package/src/dashboard.js +21 -8
  38. package/src/data.js +2 -0
  39. package/src/facet.js +4 -3
  40. package/src/format.js +36 -13
  41. package/src/frame.js +8 -5
  42. package/src/geo/projection.js +4 -2
  43. package/src/hierarchy.js +6 -0
  44. package/src/i18n.js +107 -0
  45. package/src/index.js +17 -4
  46. package/src/infer.js +9 -1
  47. package/src/lint.js +66 -4
  48. package/src/runtime.js +94 -6
  49. package/src/scale.js +3 -2
  50. package/src/schema.js +54 -11
  51. package/src/stats.js +5 -3
  52. package/src/svg.js +4 -1
  53. package/src/text.js +5 -0
@@ -1,4 +1,4 @@
1
- /*! plotagents 0.7.0 · MIT · https://github.com/carlosnavarreteliz/plotagents */
1
+ /*! plotagents 0.8.0 · MIT · https://github.com/carlosnavarreteliz/plotagents */
2
2
  (function (global) {
3
3
  'use strict';
4
4
  var __defs = {}, __cache = {};
@@ -31,8 +31,11 @@ const { registerGeo, getGeo, registeredGeo, toFeatureCollection } = __req("src/g
31
31
 
32
32
 
33
33
  const { heatmap } = __req("src/charts/heatmap.js");
34
+ const { corr, pairplot, correlations } = __req("src/charts/corr.js");
34
35
  const { sankey } = __req("src/charts/sankey.js");
35
36
  const { kpi } = __req("src/charts/kpi.js");
37
+ const { table } = __req("src/charts/table.js");
38
+ const { autoLocale, detectLocale } = __req("src/i18n.js");
36
39
  const { ensureData, PlotError, fieldKind, uniq, num } = __req("src/data.js");
37
40
  const { facet } = __req("src/facet.js");
38
41
  const { parseDate } = __req("src/format.js");
@@ -59,8 +62,11 @@ const RENDERERS = {
59
62
  donut: (s, t) => donut(s, t),
60
63
  pie: (s, t) => pie(s, t),
61
64
  heatmap: (s, t) => heatmap(s, t),
65
+ corr: (s, t) => corr(s, t),
66
+ pairplot: (s, t) => pairplot(s, t),
62
67
  sankey: (s, t) => sankey(s, t),
63
68
  kpi: (s, t) => kpi(s, t),
69
+ table: (s, t) => table(s, t),
64
70
  hist: (s, t) => hist(s, t),
65
71
  kde: (s, t) => kde(s, t),
66
72
  ecdf: (s, t) => ecdf(s, t),
@@ -92,7 +98,7 @@ function resolve(spec = {}) {
92
98
  const s = withDefaults(spec);
93
99
  for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
94
100
  if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
95
- let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
101
+ let type = s.type && s.type !== 'auto' ? String(s.type).trim().toLowerCase() : null;
96
102
  if (type) {
97
103
  const [t, extra] = normalizeType(type);
98
104
  type = t;
@@ -104,6 +110,9 @@ function resolve(spec = {}) {
104
110
  }
105
111
  if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
106
112
  s.type = type;
113
+ if (s.locale === 'auto' || (s.locale === undefined && type === 'table')) {
114
+ s.locale = autoLocale(s, Array.isArray(s.data) ? s.data : []);
115
+ }
107
116
  const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
108
117
  if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
109
118
  const vf = typeof s.colorScale === 'object' && s.colorScale.mid !== undefined ? null : s.value;
@@ -251,7 +260,7 @@ function safePlot(spec) {
251
260
  const svg = plot(spec);
252
261
  return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
253
262
  } catch (e) {
254
- const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
263
+ const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa los campos del spec contra el schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }];
255
264
  return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
256
265
  }
257
266
  }
@@ -289,7 +298,7 @@ function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
289
298
  ${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
290
299
  <style>
291
300
  :root{color-scheme:${dark ? 'dark' : 'light'}}
292
- body{margin:0;background:${bg};font-family:${esc(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
301
+ body{margin:0;background:${cssText(bg)};font-family:${cssText(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
293
302
  main{max-width:${columns > 1 ? 1500 : 820}px;margin:0 auto;padding:24px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,${columns > 1 ? 560 : 320}px),1fr));gap:24px}
294
303
  figure.pa{margin:0;min-width:0}
295
304
  figure.pa svg{width:100%;height:auto;display:block}
@@ -301,6 +310,9 @@ ${boot}
301
310
  </body></html>`;
302
311
  }
303
312
 
313
+ /** Texto dentro de <style>: las entidades HTML no se decodifican ahí (esc() rompería las comillas), así que se quitan los caracteres que cierran la regla o la etiqueta. */
314
+ const cssText = (v) => String(v ?? '').replace(/[<>{};\\&]/g, '');
315
+
304
316
  /** Código de la biblioteca en un archivo (para incrustar en HTML). */
305
317
  function bundleSource(file = 'plotagents.js') {
306
318
  try {
@@ -324,7 +336,7 @@ _setProbe((s) => plot(s));
324
336
 
325
337
 
326
338
 
327
- return { "resolve": resolve, "plot": plot, "configure": configure, "timeValues": timeValues, "safePlot": safePlot, "describe": describe, "toHTML": toHTML, "bundleSource": bundleSource, "default": plot, "render": render, "countWords": countWords, "dashboardSchema": dashboardSchema, "resolveColorScale": resolveColorScale, "colorScaleNames": colorScaleNames, "interpolateColor": interpolateColor, "COLORMAPS": COLORMAPS, "QUALITATIVE": QUALITATIVE, "dashboard": dashboard, "renderDashboard": renderDashboard, "planDashboard": planDashboard, "lintDashboard": lintDashboard, "safeDashboard": safeDashboard, "mountDashboard": mountDashboard, "packRows": packRows, "balancedRows": balancedRows, "RENDERERS": RENDERERS, "ALIASES": ALIASES, "registerGeo": registerGeo, "getGeo": getGeo, "registeredGeo": registeredGeo, "toFeatureCollection": toFeatureCollection, "loadGeo": loadGeo, "registerTheme": registerTheme, "resolveDefaults": withDefaults, "embedTiles": embedTiles, "TILE_PROVIDERS": TILE_PROVIDERS, "lint": lint, "schema": schema, "toolDefinition": toolDefinition, "themes": themes, "palettes": palettes, "ramps": ramps, "validatePalette": validatePalette, "getTheme": getTheme, "themeFor": themeFor, "attach": attach, "PlotError": PlotError, "TYPES": TYPES, "squarify": squarify, "makeFormat": makeFormat, "formatNumber": formatNumber, "formatCompact": formatCompact };
339
+ return { "resolve": resolve, "plot": plot, "configure": configure, "timeValues": timeValues, "safePlot": safePlot, "describe": describe, "toHTML": toHTML, "bundleSource": bundleSource, "default": plot, "render": render, "countWords": countWords, "dashboardSchema": dashboardSchema, "detectLocale": detectLocale, "correlations": correlations, "resolveColorScale": resolveColorScale, "colorScaleNames": colorScaleNames, "interpolateColor": interpolateColor, "COLORMAPS": COLORMAPS, "QUALITATIVE": QUALITATIVE, "dashboard": dashboard, "renderDashboard": renderDashboard, "planDashboard": planDashboard, "lintDashboard": lintDashboard, "safeDashboard": safeDashboard, "mountDashboard": mountDashboard, "packRows": packRows, "balancedRows": balancedRows, "RENDERERS": RENDERERS, "ALIASES": ALIASES, "registerGeo": registerGeo, "getGeo": getGeo, "registeredGeo": registeredGeo, "toFeatureCollection": toFeatureCollection, "loadGeo": loadGeo, "registerTheme": registerTheme, "resolveDefaults": withDefaults, "embedTiles": embedTiles, "TILE_PROVIDERS": TILE_PROVIDERS, "lint": lint, "schema": schema, "toolDefinition": toolDefinition, "themes": themes, "palettes": palettes, "ramps": ramps, "validatePalette": validatePalette, "getTheme": getTheme, "themeFor": themeFor, "attach": attach, "PlotError": PlotError, "TYPES": TYPES, "squarify": squarify, "makeFormat": makeFormat, "formatNumber": formatNumber, "formatCompact": formatCompact };
328
340
  });
329
341
  __def("src/theme.js", function () {
330
342
 
@@ -742,6 +754,15 @@ const LOWER = new Map();
742
754
  for (const k of [...Object.keys(COLORMAPS), ...Object.keys(QUALITATIVE)]) LOWER.set(k.toLowerCase(), k);
743
755
  const unpack = (s) => s.match(/.{6}/g).map((h) => '#' + h);
744
756
  const isHex = (c) => typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim());
757
+ const NAMED = {
758
+ black: '#000000', white: '#ffffff', gray: '#808080', grey: '#808080', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', darkgray: '#a9a9a9', darkgrey: '#a9a9a9', silver: '#c0c0c0',
759
+ red: '#ff0000', darkred: '#8b0000', crimson: '#dc143c', tomato: '#ff6347', salmon: '#fa8072', pink: '#ffc0cb', maroon: '#800000',
760
+ orange: '#ffa500', darkorange: '#ff8c00', gold: '#ffd700', yellow: '#ffff00', khaki: '#f0e68c', beige: '#f5f5dc', ivory: '#fffff0', wheat: '#f5deb3', brown: '#a52a2a',
761
+ green: '#008000', darkgreen: '#006400', lime: '#00ff00', limegreen: '#32cd32', olive: '#808000', seagreen: '#2e8b57', forestgreen: '#228b22', teal: '#008080',
762
+ cyan: '#00ffff', aqua: '#00ffff', turquoise: '#40e0d0', lightblue: '#add8e6', skyblue: '#87ceeb', steelblue: '#4682b4', blue: '#0000ff', darkblue: '#00008b', navy: '#000080', royalblue: '#4169e1', cornflowerblue: '#6495ed', dodgerblue: '#1e90ff',
763
+ purple: '#800080', indigo: '#4b0082', violet: '#ee82ee', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6', lavender: '#e6e6fa', plum: '#dda0dd',
764
+ };
765
+ const toHex = (c) => (isHex(c) ? c.trim() : typeof c === 'string' ? NAMED[c.trim().toLowerCase()] : undefined);
745
766
 
746
767
  /** Nombres disponibles, por tipo. */
747
768
  function colorScaleNames() {
@@ -762,7 +783,7 @@ function colorScaleNames() {
762
783
  function resolveColorScale(v) {
763
784
  if (!v) return null;
764
785
  if (Array.isArray(v)) {
765
- const stops = v.filter(isHex);
786
+ const stops = v.map(toHex).filter(Boolean);
766
787
  return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
767
788
  }
768
789
  if (typeof v === 'object') {
@@ -977,13 +998,17 @@ function bar(spec, theme) {
977
998
 
978
999
  const cmap = colorMap(seriesKeys, theme, spec.colors);
979
1000
  const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
1001
+ const hlSeries = hl && seriesKeys.some((s) => hl.has(s));
980
1002
  const colorFor = (c, si) => {
981
- if (multi) return cmap.get(seriesKeys[si]);
1003
+ if (multi) {
1004
+ if (hl && (hlSeries ? !hl.has(seriesKeys[si]) : !hl.has(c))) return theme.dim;
1005
+ return cmap.get(seriesKeys[si]);
1006
+ }
982
1007
  if (spec.colors && spec.colors[c]) return spec.colors[c];
983
1008
  if (hl) return hl.has(c) ? theme.categorical[0] : theme.dim;
984
1009
  return c === 'Otros' || c === 'Other' ? theme.other : theme.categorical[0];
985
1010
  };
986
- const legend = multi && spec.legend !== false ? seriesKeys.map((s) => ({ label: s, color: cmap.get(s), shape: 'square' })) : [];
1011
+ const legend = multi && spec.legend !== false ? seriesKeys.map((s) => ({ label: s, color: hlSeries && !hl.has(s) ? theme.dim : cmap.get(s), shape: 'square' })) : [];
987
1012
 
988
1013
  const H = hatcher(theme, 'pa-b' + Math.abs(hash(String(spec.title) + theme.mode + frame.width)).toString(36));
989
1014
  const dm = dashedMatcher(spec, m.data, m.x, m.series);
@@ -1016,7 +1041,8 @@ function bar(spec, theme) {
1016
1041
  if (spec._domains) spec._domains.y = [dom.min, dom.max];
1017
1042
  const leftM = hideAxis ? 0 : ticksWidth(linear(nd.min, nd.max, 0, 1).ticks(nd.step), fmt.tick(nd.step, linear(nd.min, nd.max, 0, 1).ticks(nd.step))) + 10;
1018
1043
  const top = showLabels ? 16 : 6;
1019
- const inner = { x: area.x + leftM, y: area.y + top * ctx.textScale, w: area.w - leftM, h: area.h - top * ctx.textScale - 26 * ctx.textScale };
1044
+ const negPad = showLabels && vmin < 0 ? 16 : 0;
1045
+ const inner = { x: area.x + leftM, y: area.y + top * ctx.textScale, w: area.w - leftM, h: area.h - top * ctx.textScale - 26 * ctx.textScale - negPad };
1020
1046
  const yS = linear(dom.min, dom.max, inner.y + inner.h, inner.y);
1021
1047
  const xb = band(cats, inner.x, inner.x + inner.w, { inner: multi && !stack ? 0.2 : 0.3, outer: 0.1 });
1022
1048
  if (!hideAxis) body.push(gridY(yS.ticks(nd.step), yS, inner, theme, fmt.tick(nd.step, yS.ticks(nd.step))));
@@ -1078,7 +1104,7 @@ function bar(spec, theme) {
1078
1104
  body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(y0) + 0.5, y2: Math.round(y0) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
1079
1105
  const dUnit = m.kind === 'date' ? dateUnit(cats.map((c) => parseDate(c))) : null;
1080
1106
  const items = cats.map((c) => ({ pos: xb.center(c), label: m.nominal ? truncate(c, Math.max(xb.step - 6, 40), 12) : dUnit ? formatDate(parseDate(c), dUnit, locale) : c }));
1081
- body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18, size: 12, color: theme.lede }));
1107
+ body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18 + negPad, size: 12, color: theme.lede }));
1082
1108
  body.push(refLinesY(refs, yS, inner, theme, fmt));
1083
1109
  } else {
1084
1110
  const step = Math.max(multi && !stack ? Math.min(maxBar, 16) * k + 2 * (k - 1) + 10 : maxBar + 9, 12.5 * ctx.textScale * 1.45);
@@ -1112,12 +1138,13 @@ function bar(spec, theme) {
1112
1138
  const y0b = by + (bh - h) / 2;
1113
1139
  const sg = segs.get(c);
1114
1140
  const lastPos = sg.map((s, i) => (s.vv > 0 ? i : -1)).filter((i) => i >= 0).pop();
1141
+ const lastNeg = sg.map((s, i) => (s.vv < 0 ? i : -1)).filter((i) => i >= 0).pop();
1115
1142
  sg.forEach((s, i) => {
1116
1143
  if (!s.vv) return;
1117
1144
  const xa = xS(s.from), xb2 = xS(s.to);
1118
1145
  let left = Math.min(xa, xb2), w = Math.abs(xb2 - xa);
1119
1146
  if (s.from !== 0) { if (s.vv > 0) left += 2; w -= 2; }
1120
- const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || s.vv < 0 ? RAD : 0);
1147
+ const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || i === lastNeg ? RAD : 0);
1121
1148
  const fill = colorFor(c, i);
1122
1149
  const lab = pct ? fmt(s.vv) : '';
1123
1150
  const fits = lab && measure(lab, 11, 600) + 10 < w;
@@ -1194,7 +1221,8 @@ __def("src/svg.js", function () {
1194
1221
  const { ctx } = __req("src/context.js");
1195
1222
 
1196
1223
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
1197
- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
1224
+ const BAD_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
1225
+ const esc = (s) => String(s ?? '').replace(BAD_XML, '').replace(/[&<>"']/g, (c) => ESC[c]);
1198
1226
 
1199
1227
  /** Redondea a 2 decimales para SVG compactos. */
1200
1228
  const r2 = (n) => Math.round(n * 100) / 100;
@@ -1299,6 +1327,11 @@ set('—', 0.9);
1299
1327
  set('▲▼■●', 0.9);
1300
1328
  set('·', 0.3);
1301
1329
  set('→', 0.9);
1330
+ set('\u202F', 0.17); // espacio fino (miles en francés)
1331
+ set('\u00A0', 0.27);
1332
+ set('✓✗', 0.7);
1333
+ set('‹›', 0.32);
1334
+ set('#', 0.6);
1302
1335
 
1303
1336
  function measure(str, size = 12, weight = 400) {
1304
1337
  let w = 0;
@@ -1342,19 +1375,29 @@ __def("src/format.js", function () {
1342
1375
  const SEP = {
1343
1376
  es: { group: '.', dec: ',' },
1344
1377
  en: { group: ',', dec: '.' },
1378
+ pt: { group: '.', dec: ',' },
1379
+ de: { group: '.', dec: ',' },
1380
+ fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
1345
1381
  };
1346
1382
 
1347
1383
  const COMPACT = {
1348
1384
  es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
1349
1385
  en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
1386
+ pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
1387
+ fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
1388
+ de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
1350
1389
  };
1351
1390
 
1352
1391
  const MONTHS = {
1353
1392
  es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
1354
1393
  en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
1394
+ pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
1395
+ fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
1396
+ de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
1355
1397
  };
1356
1398
 
1357
- const loc = (l) => (l && String(l).toLowerCase().startsWith('en') ? 'en' : 'es');
1399
+ /** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
1400
+ const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
1358
1401
 
1359
1402
  /** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
1360
1403
  function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
@@ -1362,7 +1405,8 @@ function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
1362
1405
  if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
1363
1406
  const { group, dec } = SEP[loc(locale)];
1364
1407
  const neg = v < 0;
1365
- let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))).split('.');
1408
+ 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"
1409
+ let [int, frac = ''] = fixed.split('.');
1366
1410
  int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
1367
1411
  if (trim) frac = frac.replace(/0+$/, '');
1368
1412
  const out = frac ? int + dec + frac : int;
@@ -1381,10 +1425,15 @@ function smartDecimals(v) {
1381
1425
  function formatCompact(v, locale = 'es', decimals) {
1382
1426
  const l = loc(locale);
1383
1427
  const a = Math.abs(v);
1384
- for (const [n, suf] of COMPACT[l]) {
1428
+ const tbl = COMPACT[l];
1429
+ for (let i = 0; i < tbl.length; i++) {
1430
+ let [n, suf] = tbl[i];
1385
1431
  if (a >= n) {
1386
- const s = v / n;
1387
- const d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
1432
+ let s = v / n;
1433
+ let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
1434
+ if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
1435
+ [n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
1436
+ }
1388
1437
  return formatNumber(s, d, l, decimals === undefined) + suf;
1389
1438
  }
1390
1439
  }
@@ -1455,8 +1504,8 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
1455
1504
  const decFor = (s) => {
1456
1505
  const a = Math.abs(s) || 1;
1457
1506
  let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
1458
- while (d < 6 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
1459
- return Math.min(6, d);
1507
+ while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
1508
+ return Math.min(12, d);
1460
1509
  };
1461
1510
  let core;
1462
1511
  if (type === 'year') core = (v) => String(Math.round(v));
@@ -1473,9 +1522,10 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
1473
1522
  ? (v) => formatNumber(v, decFor(step), l)
1474
1523
  : (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
1475
1524
  } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
1525
+ const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
1476
1526
  return (v) => {
1477
1527
  if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
1478
- const s = core(v);
1528
+ const s = core(v).replace(zeroFrac, '');
1479
1529
  if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
1480
1530
  return pre + s + suf;
1481
1531
  };
@@ -1493,7 +1543,7 @@ function parseDate(v) {
1493
1543
  if (v instanceof Date) return v.getTime();
1494
1544
  if (typeof v === 'string') {
1495
1545
  const m = ISO.exec(v.trim());
1496
- if (m) {
1546
+ 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) {
1497
1547
  const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
1498
1548
  let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
1499
1549
  const z = m[8];
@@ -1508,7 +1558,7 @@ function parseDate(v) {
1508
1558
  return NaN;
1509
1559
  }
1510
1560
 
1511
- const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
1561
+ const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
1512
1562
 
1513
1563
  /**
1514
1564
  * Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
@@ -1526,13 +1576,15 @@ function formatDate(ms, unit, locale = 'es', withYear = true) {
1526
1576
  return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
1527
1577
  }
1528
1578
  if (unit === 'year') return String(y);
1529
- if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
1579
+ if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
1530
1580
  if (unit === 'month') return withYear ? `${m} ${y}` : m;
1531
1581
  const day = d.getUTCDate();
1532
- return l === 'en' ? `${m} ${day}${withYear ? ', ' + y : ''}` : `${day} ${m}${withYear ? ' ' + y : ''}`;
1582
+ if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
1583
+ if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
1584
+ return `${day} ${m}${withYear ? ' ' + y : ''}`;
1533
1585
  }
1534
1586
 
1535
- return { "formatNumber": formatNumber, "smartDecimals": smartDecimals, "formatCompact": formatCompact, "makeFormat": makeFormat, "parseDate": parseDate, "formatDate": formatDate, "MONTHS": MONTHS, "isDateLike": isDateLike };
1587
+ return { "formatNumber": formatNumber, "smartDecimals": smartDecimals, "formatCompact": formatCompact, "makeFormat": makeFormat, "currencyPrefix": currencyPrefix, "parseDate": parseDate, "formatDate": formatDate, "MONTHS": MONTHS, "isDateLike": isDateLike };
1536
1588
  });
1537
1589
  __def("src/scale.js", function () {
1538
1590
  const { formatDate } = __req("src/format.js");
@@ -1574,7 +1626,7 @@ function niceDomain(min, max, count = 5, { integer = false } = {}) {
1574
1626
  step = st(hi - lo);
1575
1627
  lo = Math.floor(min / step + 1e-9) * step;
1576
1628
  hi = Math.ceil(max / step - 1e-9) * step;
1577
- return { min: lo, max: hi, step };
1629
+ return { min: +lo.toPrecision(15), max: +hi.toPrecision(15), step: +step.toPrecision(15) };
1578
1630
  }
1579
1631
 
1580
1632
  function linear(d0, d1, r0, r1) {
@@ -1591,7 +1643,7 @@ function linear(d0, d1, r0, r1) {
1591
1643
  if ((hi - lo) / st > 1000) st = niceStep(hi - lo, 5);
1592
1644
  const start = Math.ceil(lo / st - 1e-9) * st;
1593
1645
  if (start + st === start) return [lo];
1594
- for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(12));
1646
+ for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(15));
1595
1647
  return out;
1596
1648
  };
1597
1649
  return s;
@@ -1786,6 +1838,7 @@ function num(v) {
1786
1838
  if (typeof v === 'number') return v;
1787
1839
  if (v === null || v === undefined || v === '' || typeof v === 'boolean') return NaN;
1788
1840
  let s = String(v).trim().replace(/[\s$€£%]|US\$|CLP|UF/g, '').replace('−', '-');
1841
+ if (!/^[+-]?(\d[\d.,]*|[.,]\d[\d.,]*)(e[+-]?\d+)?$/i.test(s)) return NaN;
1789
1842
  const hasC = s.includes(','), hasD = s.includes('.');
1790
1843
  if (hasC && hasD) s = s.lastIndexOf(',') > s.lastIndexOf('.') ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
1791
1844
  else if (hasC) s = /,\d{3}(?!\d)/.test(s) && !/,\d{1,2}$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.');
@@ -2062,6 +2115,7 @@ const { el, esc, g, text, r2 } = __req("src/svg.js");
2062
2115
  const { measure, wrap, truncate } = __req("src/text.js");
2063
2116
  const { FONT, FONT_CSS } = __req("src/theme.js");
2064
2117
  const { ctx } = __req("src/context.js");
2118
+ const { words } = __req("src/i18n.js");
2065
2119
 
2066
2120
  const SIZES = {
2067
2121
  title: 22,
@@ -2102,7 +2156,7 @@ function createFrame(spec, theme, defaults = {}) {
2102
2156
  if (subLines.length) y += subLines.length * slh + 2;
2103
2157
  if (titleLines.length || subLines.length) y += 10;
2104
2158
 
2105
- const srcText = spec.source ? (/^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source) : '';
2159
+ const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
2106
2160
  const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
2107
2161
  const srcLH = Math.round(T.source * X * 1.35);
2108
2162
  const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
@@ -2148,7 +2202,7 @@ function createFrame(spec, theme, defaults = {}) {
2148
2202
  },
2149
2203
  /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
2150
2204
  setHeight(h) { height = h; },
2151
- finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
2205
+ finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
2152
2206
  if (legendBottom) {
2153
2207
  const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
2154
2208
  header.push(svg);
@@ -2158,7 +2212,8 @@ function createFrame(spec, theme, defaults = {}) {
2158
2212
  const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
2159
2213
  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}`
2160
2214
  + (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
2161
- + (/^(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}' : '');
2215
+ + (/^(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}' : '')
2216
+ + css;
2162
2217
  return el(
2163
2218
  'svg',
2164
2219
  {
@@ -2166,8 +2221,9 @@ function createFrame(spec, theme, defaults = {}) {
2166
2221
  viewBox: `0 0 ${width} ${r2(height)}`,
2167
2222
  width,
2168
2223
  height: r2(height),
2169
- role: 'img',
2170
- 'aria-labelledby': `${id}-t ${id}-d`,
2224
+ role,
2225
+ 'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
2226
+ 'aria-describedby': role === 'img' ? null : `${id}-d`,
2171
2227
  fontFamily: spec.fontFamily || theme.font?.family || FONT,
2172
2228
  class: 'pa-chart',
2173
2229
  'data-pa-type': type,
@@ -2275,6 +2331,105 @@ function hash(s) {
2275
2331
 
2276
2332
  return { "createFrame": createFrame, "legendPosition": legendPosition, "hash": hash, "SIZES": SIZES };
2277
2333
  });
2334
+ __def("src/i18n.js", function () {
2335
+
2336
+ const LANGS = ['es', 'en', 'pt', 'fr', 'de'];
2337
+
2338
+ const WORDS = {
2339
+ es: { source: 'Fuente: ', total: 'Total', search: 'Buscar', none: 'Sin resultados', clear: 'Borrar', page: 'Página', prev: 'Anterior', next: 'Siguiente', sort: 'Ordenar por', yes: 'Sí', no: 'No', rows: 'filas', table: 'Tabla', of: 'de' },
2340
+ en: { source: 'Source: ', total: 'Total', search: 'Search', none: 'No results', clear: 'Clear', page: 'Page', prev: 'Previous', next: 'Next', sort: 'Sort by', yes: 'Yes', no: 'No', rows: 'rows', table: 'Table', of: 'of' },
2341
+ pt: { source: 'Fonte: ', total: 'Total', search: 'Buscar', none: 'Sem resultados', clear: 'Limpar', page: 'Página', prev: 'Anterior', next: 'Próxima', sort: 'Ordenar por', yes: 'Sim', no: 'Não', rows: 'linhas', table: 'Tabela', of: 'de' },
2342
+ fr: { source: 'Source : ', total: 'Total', search: 'Rechercher', none: 'Aucun résultat', clear: 'Effacer', page: 'Page', prev: 'Précédente', next: 'Suivante', sort: 'Trier par', yes: 'Oui', no: 'Non', rows: 'lignes', table: 'Tableau', of: 'sur' },
2343
+ de: { source: 'Quelle: ', total: 'Gesamt', search: 'Suchen', none: 'Keine Treffer', clear: 'Löschen', page: 'Seite', prev: 'Zurück', next: 'Weiter', sort: 'Sortieren nach', yes: 'Ja', no: 'Nein', rows: 'Zeilen', table: 'Tabelle', of: 'von' },
2344
+ };
2345
+
2346
+ /** Código de idioma soportado ('es', 'en', 'pt', 'fr', 'de') a partir de cualquier etiqueta ('pt-BR', 'en_US'…). */
2347
+ function langOf(locale) {
2348
+ const k = String(locale || 'es').toLowerCase().slice(0, 2);
2349
+ return LANGS.includes(k) ? k : 'es';
2350
+ }
2351
+
2352
+ /** Palabras de interfaz en el idioma del gráfico. */
2353
+ const words = (locale) => WORDS[langOf(locale)];
2354
+
2355
+ const STOP = {
2356
+ es: 'de la el los las y en por con para del que una un se al más es son sus su según entre como año años sin hasta desde ventas región regiones total mil millones porcentaje hogares personas este esta sobre cada ha han fue lidera mayor menor tasa crecimiento meta comuna comunas mes meses semana proyecto estado avance',
2357
+ en: 'the of and in by for with to is are from on at than more its as per was were this that which share sales region regions total thousand million percent households people over each has have had leads largest smallest rate growth target month months week project status progress revenue year years',
2358
+ pt: 'de da do das dos e em por com para que uma um ao mais é são sua seu entre como ano anos não até desde pelo pela no na nos nas vendas região total mil milhões taxa crescimento meta mês meses projeto estado cada foi tem',
2359
+ fr: 'de la le les et en par pour avec du des que une un au aux plus est sont sa son ses entre comme année années sans jusqu depuis sur dans ventes région total mille millions taux croissance objectif mois semaine projet état chaque été',
2360
+ de: 'der die das und in von mit für zu ist sind den dem des ein eine einer im am mehr als nach bei auf aus nicht jahr jahre umsatz region gesamt tausend millionen anteil wachstum ziel monat monate woche projekt status jede wurde hat',
2361
+ };
2362
+ const INDEX = new Map();
2363
+ for (const [lang, list] of Object.entries(STOP)) for (const w of list.split(' ')) { if (!INDEX.has(w)) INDEX.set(w, []); INDEX.get(w).push(lang); }
2364
+
2365
+ const CHARS = [
2366
+ [/[ñ¿¡]/g, { es: 3 }],
2367
+ [/[ãõ]/g, { pt: 3 }],
2368
+ [/ç/g, { pt: 1.5, fr: 1.5 }],
2369
+ [/[èêëàâùûœî]/g, { fr: 2 }],
2370
+ [/[äöüß]/g, { de: 3 }],
2371
+ [/[áíóú]/g, { es: 1, pt: 1 }],
2372
+ [/é/g, { es: 0.5, pt: 0.5, fr: 0.8 }],
2373
+ ];
2374
+
2375
+ /**
2376
+ * Idioma más probable de unos textos (títulos, encabezados, categorías), o
2377
+ * null si no hay señal suficiente. Determinista y sin diccionarios grandes.
2378
+ */
2379
+ function detectLocale(texts) {
2380
+ const s = [].concat(texts || []).filter((t) => typeof t === 'string' && t).join(' \n ').toLowerCase().slice(0, 6000);
2381
+ if (!s) return null;
2382
+ const score = { es: 0, en: 0, pt: 0, fr: 0, de: 0 };
2383
+ for (const tok of s.split(/[^\p{L}]+/u)) {
2384
+ const langs = tok && INDEX.get(tok);
2385
+ if (langs) for (const l of langs) score[l] += 1 / langs.length;
2386
+ }
2387
+ for (const [re, w] of CHARS) {
2388
+ const n = (s.match(re) || []).length;
2389
+ if (n) for (const [l, v] of Object.entries(w)) score[l] += Math.min(6, n) * v;
2390
+ }
2391
+ const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
2392
+ const [[best, a], [, b]] = ranked;
2393
+ if (a < 1 || a < b * 1.25 + 0.25) return null;
2394
+ return best;
2395
+ }
2396
+
2397
+ /** Idioma del documento en el navegador (`<html lang>` o el del sistema), o null. */
2398
+ function browserLocale() {
2399
+ if (typeof document === 'undefined') return null;
2400
+ try {
2401
+ const l = (document.documentElement && document.documentElement.lang) || (typeof navigator !== 'undefined' && navigator.language) || '';
2402
+ const k = String(l).toLowerCase().slice(0, 2);
2403
+ return LANGS.includes(k) ? k : null;
2404
+ } catch { return null; }
2405
+ }
2406
+
2407
+ /**
2408
+ * Textos de un spec que delatan su idioma: título, bajada, fuente, nombres de
2409
+ * columna y algunas categorías.
2410
+ */
2411
+ function specTexts(spec, data) {
2412
+ const out = [spec.title, spec.subtitle, spec.source, spec.dashedLabel];
2413
+ for (const c of [].concat(Array.isArray(spec.columns) ? spec.columns : [])) {
2414
+ if (typeof c === 'string') out.push(c);
2415
+ else if (c && typeof c === 'object') out.push(c.label, c.field);
2416
+ }
2417
+ if (Array.isArray(data)) {
2418
+ const rows = data.slice(0, 40);
2419
+ const keys = new Set();
2420
+ for (const r of rows) if (r && typeof r === 'object') for (const [k, v] of Object.entries(r)) { keys.add(k); if (typeof v === 'string' && v.length > 2 && !/^[\d\s.,%$€£-]+$/.test(v)) out.push(v); }
2421
+ for (const k of keys) out.push(String(k).replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'));
2422
+ }
2423
+ return out;
2424
+ }
2425
+
2426
+ /** Resuelve `locale: "auto"`: idioma del texto, luego el del navegador, luego español. */
2427
+ function autoLocale(spec, data) {
2428
+ return detectLocale(specTexts(spec, data)) || browserLocale() || 'es';
2429
+ }
2430
+
2431
+ return { "langOf": langOf, "detectLocale": detectLocale, "browserLocale": browserLocale, "specTexts": specTexts, "autoLocale": autoLocale, "LANGS": LANGS, "words": words };
2432
+ });
2278
2433
  __def("src/stats.js", function () {
2279
2434
 
2280
2435
  const finite = (vs) => vs.filter((v) => Number.isFinite(v));
@@ -2386,11 +2541,12 @@ function histogram(vs, { bins, binWidth, domain } = {}) {
2386
2541
  }
2387
2542
  step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
2388
2543
  }
2389
- const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
2544
+ const start = bins ? lo : +(Math.floor(lo / step + 1e-9) * step).toPrecision(15);
2390
2545
  const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
2391
- const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
2546
+ const out = Array.from({ length: count }, (_, i) => ({ x0: +(start + i * step).toPrecision(15), x1: +(start + (i + 1) * step).toPrecision(15), count: 0 }));
2392
2547
  for (const v of s) {
2393
- let i = Math.floor((v - start) / step);
2548
+ if (domain && (v < domain[0] || v > domain[1])) continue;
2549
+ let i = Math.floor((v - start) / step + 1e-9);
2394
2550
  if (i >= count) i = count - 1;
2395
2551
  if (i >= 0) out[i].count++;
2396
2552
  }
@@ -2766,6 +2922,13 @@ function line(spec, theme, variant = 'line') {
2766
2922
  }
2767
2923
  }
2768
2924
 
2925
+ if (spec.yScale === 'log' && !stack && m.lines.some((l) => l.points.some((p) => p.y > 0))) {
2926
+ for (const l of m.lines) for (const p of l.points) {
2927
+ if (!(p.y > 0)) p.y = NaN;
2928
+ if (!(p.lo > 0)) p.lo = NaN;
2929
+ if (!(p.hi > 0)) p.hi = NaN;
2930
+ }
2931
+ }
2769
2932
  const allY = [], rawY = [];
2770
2933
  for (const l of m.lines) for (const p of l.points) {
2771
2934
  if (Number.isFinite(p.y)) allY.push(p.y);
@@ -2834,7 +2997,7 @@ function line(spec, theme, variant = 'line') {
2834
2997
  const intY = !pct && !spec.index && rawY.length > 0 && rawY.every(Number.isInteger);
2835
2998
  const nd = yLog ? logDomain(ymin, ymax) : spec.yDomain ? { min: ymin, max: ymax, step: niceStep(ymax - ymin, Math.max(3, Math.round(area.h / 64))) } : niceDomain(ymin, ymax, Math.max(3, Math.round(area.h / 64)), { integer: intY });
2836
2999
  const yTicksArr = (yLog ? log(nd.min, nd.max, 0, 1) : linear(nd.min, nd.max, 0, 1)).ticks(nd.step);
2837
- const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step, yTicksArr);
3000
+ const tickF = yLog ? (v) => fmt.tick(v, v)(v) : fmt.tick(nd.step, yTicksArr);
2838
3001
  const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
2839
3002
  const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
2840
3003
  const inner = { x: area.x + leftM, y: yTop, w: Math.max(80, area.w - leftM - endW), h: area.y + area.h - yTop - 24 * ctx.textScale - (spec.xLabel ? 16 * ctx.textScale : 0) };
@@ -2879,7 +3042,7 @@ function line(spec, theme, variant = 'line') {
2879
3042
  xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
2880
3043
  } else if (xLog) {
2881
3044
  const lf = makeFormat(spec.xFormat, locale, xsAll);
2882
- xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t)(t) }));
3045
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t, t)(t) }));
2883
3046
  } else {
2884
3047
  let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
2885
3048
  const ints = xsAll.every(Number.isInteger);
@@ -2933,6 +3096,7 @@ function line(spec, theme, variant = 'line') {
2933
3096
  if (spec.dashedLabel !== false) body.push(text(cutX + 6, inner.y + 11, dashedLabel, { fill: theme.muted, fontSize: 11.5, fontWeight: 500, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
2934
3097
  }
2935
3098
  }
3099
+ const clipD = spec.xDomain || spec.yDomain ? 'pa-clip-' + Math.abs(hash(String(spec.title) + m.y + inner.w + inner.h)).toString(36) : null;
2936
3100
  const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
2937
3101
  const H = hatcher(theme, gidD);
2938
3102
  const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
@@ -3002,7 +3166,7 @@ function line(spec, theme, variant = 'line') {
3002
3166
  else for (const sg of segs) if (sg.length === 1 && sg[0] !== pts[pts.length - 1]) parts.push(dot(sg[0], (M.pointRadius ?? 4) - 0.5));
3003
3167
  }
3004
3168
  if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
3005
- body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
3169
+ body.push(g({ class: 'pa-m', 'data-series': l.key, ...(clipD ? { 'clip-path': `url(#${clipD})` } : {}), ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
3006
3170
  }
3007
3171
 
3008
3172
  body.push(refLinesY(refs, yS, inner, theme, fmt));
@@ -3012,7 +3176,8 @@ function line(spec, theme, variant = 'line') {
3012
3176
  let labs = labeled.map((l) => {
3013
3177
  const last = lastOf(l);
3014
3178
  const t = endText(l);
3015
- const yy = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
3179
+ const yy0 = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
3180
+ const yy = Math.min(inner.y + inner.h, Math.max(inner.y, yy0)); // fuera del dominio y: pegada al borde
3016
3181
  const px = xS(last.x) + (singleX && isArea ? Math.min(inner.w * 0.2, 40) : 0);
3017
3182
  return { ...t, key: l.key, y: yy, y0: yy, px, color: colorOf(l.key) };
3018
3183
  }).sort((a, b) => a.y0 - b.y0);
@@ -3037,10 +3202,10 @@ function line(spec, theme, variant = 'line') {
3037
3202
  }
3038
3203
  }
3039
3204
 
3040
- let crossXs = xsAll;
3041
- if (xsAll.length > inner.w) {
3205
+ let crossXs = xsAll.filter((xv) => xv >= x0 && xv <= x1);
3206
+ if (crossXs.length > inner.w) {
3042
3207
  const byCol = new Map();
3043
- for (const xv of xsAll) byCol.set(Math.round(xS(xv)), xv);
3208
+ for (const xv of crossXs) byCol.set(Math.round(xS(xv)), xv);
3044
3209
  crossXs = [...byCol.values()];
3045
3210
  }
3046
3211
  const focusLines = m.lines.filter((l) => focus(l.key)).map((l) => ({ l, at: new Map(l.points.map((q) => [q.x, q])) }));
@@ -3057,6 +3222,7 @@ function line(spec, theme, variant = 'line') {
3057
3222
  body.push(el('rect', { class: 'pa-cross', x: inner.x - 4, y: inner.y, width: inner.w + 8, height: inner.h, fill: 'transparent', 'data-cross': JSON.stringify(cross), 'data-y0': inner.y, 'data-y1': inner.y + inner.h }));
3058
3223
 
3059
3224
  const desc = describeLine(m, fmt, xFmt, locale);
3225
+ if (clipD) defs += el('clipPath', { id: clipD }, el('rect', { x: inner.x - 6, y: inner.y - 6, width: inner.w + 12, height: inner.h + 12 }));
3060
3226
  if (cutX !== null) defs += el('clipPath', { id: clipL }, el('rect', { x: 0, y: 0, width: cutX, height: frame.height })) + el('clipPath', { id: clipR }, el('rect', { x: cutX, y: 0, width: frame.width, height: frame.height }));
3061
3227
  return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
3062
3228
  }
@@ -3290,7 +3456,8 @@ function scatter(spec, theme) {
3290
3456
  const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
3291
3457
  const MAX_TIPS = 20000;
3292
3458
  const hx = humanize(x), hy = humanize(y), hs = size ? humanize(size) : '';
3293
- const circles = sorted.map((p, i) => {
3459
+ const vis = (p) => { const cx = xS(p.x), cy = yS(p.y); return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1; };
3460
+ const circles = sorted.filter(vis).map((p, i) => {
3294
3461
  let t = '';
3295
3462
  if (i < MAX_TIPS || sorted.length <= 1500) {
3296
3463
  const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
@@ -3344,7 +3511,7 @@ function scatter(spec, theme) {
3344
3511
  }
3345
3512
  const placed = [];
3346
3513
  for (const p of chosen) {
3347
- if (!p.name) continue;
3514
+ if (!p.name || !vis(p)) continue;
3348
3515
  const w = measure(p.name, 12, 500);
3349
3516
  const r = rOf(p);
3350
3517
  let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
@@ -3373,7 +3540,7 @@ const { formatNumber } = __req("src/format.js");
3373
3540
  const { humanize } = __req("src/data.js");
3374
3541
  const { createFrame } = __req("src/frame.js");
3375
3542
  const { ctx } = __req("src/context.js");
3376
- const { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
3543
+ const { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
3377
3544
 
3378
3545
  /** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
3379
3546
  function squarify(items, x, y, w, h) {
@@ -3418,7 +3585,7 @@ function treemap(spec, theme) {
3418
3585
  const locale = spec.locale || 'es';
3419
3586
  const en = String(locale).startsWith('en');
3420
3587
  const { root, fields, valueField } = buildHierarchy(spec);
3421
- const focus = findNode(root, spec.focus);
3588
+ const focus = focusNode(root, spec.focus);
3422
3589
  const levels = height(focus);
3423
3590
  const single = height(root) <= 1 && spec.colorBy !== 'label';
3424
3591
  assignColors(root, theme, spec, { single });
@@ -3613,6 +3780,12 @@ function findNode(root, focus = []) {
3613
3780
  return node;
3614
3781
  }
3615
3782
 
3783
+ /** Nodo de foco para dibujar: una hoja no se puede "abrir" (quedaría vacío), se usa su padre. */
3784
+ function focusNode(root, focus = []) {
3785
+ const n = findNode(root, focus);
3786
+ return !n.children.length && n.parent ? n.parent : n;
3787
+ }
3788
+
3616
3789
  const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
3617
3790
  const descendants = (n) => [n, ...n.children.flatMap(descendants)];
3618
3791
 
@@ -3684,14 +3857,14 @@ function breadcrumb(focusNode, x, y, w, theme, locale = 'es') {
3684
3857
  return { svg: g({ class: 'pa-breadcrumb' }, out), h: 26 };
3685
3858
  }
3686
3859
 
3687
- return { "hierarchyFields": hierarchyFields, "buildHierarchy": buildHierarchy, "findNode": findNode, "mix": mix, "assignColors": assignColors, "shareFmt": shareFmt, "breadcrumb": breadcrumb, "height": height, "descendants": descendants, "SEP": SEP };
3860
+ return { "hierarchyFields": hierarchyFields, "buildHierarchy": buildHierarchy, "findNode": findNode, "focusNode": focusNode, "mix": mix, "assignColors": assignColors, "shareFmt": shareFmt, "breadcrumb": breadcrumb, "height": height, "descendants": descendants, "SEP": SEP };
3688
3861
  });
3689
3862
  __def("src/charts/donut.js", function () {
3690
3863
  const { el, g, text, tip } = __req("src/svg.js");
3691
3864
  const { measure, truncate } = __req("src/text.js");
3692
3865
  const { makeFormat, formatNumber } = __req("src/format.js");
3693
3866
  const { colorMap, textOn } = __req("src/theme.js");
3694
- const { ensureData, requireField, num, fieldsOf, fieldKind, humanize } = __req("src/data.js");
3867
+ const { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } = __req("src/data.js");
3695
3868
  const { createFrame, hash } = __req("src/frame.js");
3696
3869
  const { dashedMatcher, hatcher, dashedDefaultLabel, DASH } = __req("src/dashed.js");
3697
3870
  const { ctx } = __req("src/context.js");
@@ -3725,6 +3898,7 @@ function donut(spec, theme) {
3725
3898
  const k = String(r[labelF]);
3726
3899
  sums.set(k, (sums.get(k) || 0) + v);
3727
3900
  }
3901
+ if (!sums.size) throw new PlotError(en ? `No positive values in \`${valueF}\`.` : `No hay valores positivos en \`${valueF}\`.`, en ? 'A donut or pie needs at least one value greater than 0 (zero, negative and empty values are skipped).' : 'La dona o torta necesita al menos un valor mayor que 0 (se omiten ceros, negativos y vacíos).');
3728
3902
  let slices = [...sums.entries()].map(([k, v]) => ({ k, v }));
3729
3903
  if ((spec.sort ?? 'desc') === 'desc') slices.sort((a, b) => b.v - a.v);
3730
3904
  const max = spec.maxSlices ?? 6;
@@ -3915,7 +4089,7 @@ const { makeFormat } = __req("src/format.js");
3915
4089
  const { textOn } = __req("src/theme.js");
3916
4090
  const { humanize } = __req("src/data.js");
3917
4091
  const { createFrame } = __req("src/frame.js");
3918
- const { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
4092
+ const { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
3919
4093
 
3920
4094
  const TAU = Math.PI * 2;
3921
4095
  const f = (n) => n.toFixed(2);
@@ -3932,7 +4106,7 @@ function sunburst(spec, theme) {
3932
4106
  const locale = spec.locale || 'es';
3933
4107
  const en = String(locale).startsWith('en');
3934
4108
  const { root, fields, valueField } = buildHierarchy(spec);
3935
- const focus = findNode(root, spec.focus);
4109
+ const focus = focusNode(root, spec.focus);
3936
4110
  const levels = Math.max(1, height(focus));
3937
4111
  assignColors(root, theme, spec); // colores estables al navegar
3938
4112
  const vals = [];
@@ -4150,7 +4324,7 @@ function hist(spec, theme) {
4150
4324
  const locale = spec.locale || 'es';
4151
4325
  const isLog = (horizontal ? spec.yScale : spec.xScale) === 'log';
4152
4326
  const tr = T(isLog);
4153
- const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
4327
+ const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values })).filter((gr) => gr.values.length);
4154
4328
  const all = groups.flatMap((gr) => gr.values);
4155
4329
  if (!all.length) throw noNumbers(field, en, isLog);
4156
4330
  const N = all.length;
@@ -4608,7 +4782,7 @@ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
4608
4782
  const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
4609
4783
  return { line: pts, band: null, r2: null, n: gp.length };
4610
4784
  }
4611
- const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci ?? 95) / 100 });
4785
+ const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95 });
4612
4786
  const line = grid.map((v) => [v, fit.predict(fx(v))]);
4613
4787
  const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
4614
4788
  return { line, band, r2: fit.r2, n: fit.n, fit };
@@ -4672,7 +4846,7 @@ function regplot(spec, theme) {
4672
4846
  });
4673
4847
  if (spec.scatter !== false) {
4674
4848
  const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
4675
- const dots = order.map((p) => ({
4849
+ const dots = order.filter((p) => visible(p, xS, yS, inner)).map((p) => ({
4676
4850
  class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: K === 1 ? (M.dotOpacity ?? 0.85) * 0.75 : M.dotOpacity ?? 0.85,
4677
4851
  stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
4678
4852
  'data-tip': tip(p.name || p.k || '', `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : ''),
@@ -4891,7 +5065,7 @@ function jointplot(spec, theme) {
4891
5065
  body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
4892
5066
  });
4893
5067
  }
4894
- body.push(g({}, dotLayer(pts.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
5068
+ body.push(g({}, dotLayer(pts.filter((p) => visible(p, xS, yS, inner)).map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
4895
5069
  body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
4896
5070
  body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
4897
5071
  body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
@@ -4903,6 +5077,12 @@ function jointplot(spec, theme) {
4903
5077
  return frame.finish(body, { desc, type: 'jointplot' });
4904
5078
  }
4905
5079
 
5080
+ /** Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área. */
5081
+ function visible(p, xS, yS, inner) {
5082
+ const cx = xS(p.x), cy = yS(p.y);
5083
+ return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1;
5084
+ }
5085
+
4906
5086
  function hashStr(s) {
4907
5087
  let h = 0;
4908
5088
  for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
@@ -5799,13 +5979,15 @@ function spanningTree(nodes, edges, byId, root) {
5799
5979
  }
5800
5980
  const pos = new Map();
5801
5981
  let leaf = 0;
5802
- const visit = (id) => {
5803
- const ch = children.get(id);
5804
- if (!ch.length) { pos.set(id, leaf++); return; }
5805
- for (const c of ch) visit(c);
5806
- pos.set(id, ch.reduce((a, c) => a + pos.get(c), 0) / ch.length);
5807
- };
5808
- visit(root.id);
5982
+ const stack = [[root.id, 0]];
5983
+ while (stack.length) {
5984
+ const top = stack[stack.length - 1];
5985
+ const ch = children.get(top[0]);
5986
+ if (!ch.length) { pos.set(top[0], leaf++); stack.pop(); continue; }
5987
+ if (top[1] < ch.length) { stack.push([ch[top[1]++], 0]); continue; }
5988
+ pos.set(top[0], ch.reduce((a, c) => a + pos.get(c), 0) / ch.length);
5989
+ stack.pop();
5990
+ }
5809
5991
  const maxDepth = Math.max(0, ...depth.values());
5810
5992
  const rest = nodes.filter((n) => !depth.has(n.id)).sort(byGroup);
5811
5993
  return { depth, pos, leaves: leaf, maxDepth, rest, treeEdges, children };
@@ -6016,7 +6198,8 @@ function network(spec, theme) {
6016
6198
  for (const n of nodes) { n.r = Math.max(rMin, rTop * Math.sqrt(Math.max(0, n.w) / maxW)); n.r0 = n.r; n.ring = false; }
6017
6199
 
6018
6200
  const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) ?? null : null;
6019
- const hl = focusNode ? new Set([focusNode.id]) : spec.highlight === undefined || spec.highlight === null ? null : new Set([].concat(spec.highlight).map(String));
6201
+ const hlIds = spec.highlight === undefined || spec.highlight === null ? [] : [].concat(spec.highlight).map(String).filter((id) => byId.has(id));
6202
+ const hl = focusNode ? new Set([focusNode.id]) : hlIds.length ? new Set(hlIds) : null;
6020
6203
  const near = new Set();
6021
6204
  if (hl) { for (const id of hl) near.add(id); for (const e of edges) { if (hl.has(e.s)) near.add(e.t); if (hl.has(e.t)) near.add(e.s); } }
6022
6205
  const on = (n) => !hl || near.has(n.id);
@@ -6331,7 +6514,8 @@ function chord(spec, theme) {
6331
6514
  const P = (ang, r) => [cx + Math.cos(ang) * r, cy + Math.sin(ang) * r];
6332
6515
  const f = (v) => v.toFixed(2);
6333
6516
 
6334
- const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
6517
+ const hlNames = spec.highlight === undefined || spec.highlight === null ? [] : [].concat(spec.highlight).map(String).filter((k) => names.includes(k));
6518
+ const hl = hlNames.length ? new Set(hlNames) : null;
6335
6519
  const isOn = (rb) => !hl || hl.has(names[rb.i]) || hl.has(names[rb.j]);
6336
6520
 
6337
6521
  const ribbonG = [];
@@ -7377,15 +7561,24 @@ const STOP = new Set((
7377
7561
  + ' a about above after again all am an and any are as at be because been before being below between both but by can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its itself just me more most my no nor not now of off on once only or other our ours out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your yours also get got really much many one two'
7378
7562
  ).split(/\s+/));
7379
7563
 
7564
+ const STOP_BY = {
7565
+ pt: 'não uma uns umas dos das nas nos pelo pela pelos pelas com para por que mas como mais muito já também são foi era tem têm ele ela eles elas isso isto esse essa este esta aos àquele seu sua seus suas meu minha nós você vocês ser estar ter há então quando onde',
7566
+ fr: 'les des une dans pour que qui est sont mais comme plus par sur pas avec ces cette cet aux ils elles nous vous ont été être avoir fait très aussi donc ainsi sans sous entre leur leurs son ses mes tes notre votre',
7567
+ de: 'der die das den dem des ein eine einer einem einen und oder aber nicht mit von zu für auf ist sind war waren wird werden hat haben auch als wie bei nach aus über sich es sie wir ihr ich du er an im am zum zur dass noch nur sehr',
7568
+ };
7569
+ const plain = (w) => w.normalize('NFD').replace(/\p{M}/gu, '');
7570
+ const STOP_PLAIN = new Set([...STOP].map(plain));
7571
+
7380
7572
  /** Texto crudo → [{ text, value }] por frecuencia. */
7381
7573
  function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
7382
7574
  const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
7575
+ for (const w of (STOP_BY[String(locale || '').slice(0, 2)] || '').split(' ')) if (w) extra.add(w);
7383
7576
  const counts = new Map(), shown = new Map();
7384
7577
  const docs = [].concat(input).filter((d) => typeof d === 'string');
7385
7578
  for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
7386
7579
  const raw = m[0].replace(/['’-]+$/, '');
7387
7580
  const k = raw.toLowerCase();
7388
- if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
7581
+ if (k.length < minLength || STOP.has(k) || STOP_PLAIN.has(plain(k)) || extra.has(k) || /^\d+$/.test(k)) continue;
7389
7582
  counts.set(k, (counts.get(k) || 0) + 1);
7390
7583
  const f = shown.get(k) || new Map();
7391
7584
  f.set(raw, (f.get(raw) || 0) + 1);
@@ -7419,9 +7612,11 @@ function wordcloud(spec, theme) {
7419
7612
  if (!t) continue;
7420
7613
  const v = valueF ? num(r[valueF]) : 1;
7421
7614
  if (!Number.isFinite(v) || v <= 0) continue;
7422
- const e = acc.get(t) || { text: t, value: 0, group: groupF ? String(r[groupF]) : null };
7615
+ const gk = groupF ? String(r[groupF]) : null;
7616
+ const key = gk === null ? t : gk + '\u0000' + t;
7617
+ const e = acc.get(key) || { text: t, value: 0, group: gk };
7423
7618
  e.value += v;
7424
- acc.set(t, e);
7619
+ acc.set(key, e);
7425
7620
  }
7426
7621
  words = [...acc.values()];
7427
7622
  }
@@ -7521,7 +7716,8 @@ function normKey(s) {
7521
7716
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
7522
7717
  .toLowerCase()
7523
7718
  .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' : ''))
7524
- .replace(/[^a-z0-9]/g, '');
7719
+ .replace(/[^a-z0-9]/g, '')
7720
+ .replace(/^0+(?=\d)/, ''); // códigos con cero a la izquierda: "08" = 8, "004" = 4
7525
7721
  }
7526
7722
 
7527
7723
  function featureIndex(features, featureKey) {
@@ -7705,6 +7901,7 @@ function prepare(spec, locale) {
7705
7901
  const categorical = spec.scale === 'categorical' || (data.length > 0 && fieldKind(data, valueF) === 'category');
7706
7902
  let features = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
7707
7903
  if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
7904
+ 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.'));
7708
7905
  const hasName = (f) => { const p = f.properties || {}; return primitive(p.name) || primitive(p.nameEn) || primitive(p.NAME) || primitive(p.nombre); };
7709
7906
  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; };
7710
7907
  const nameKey = spec.nameKey ?? (features.length && !features.some(hasName) ? NAME_PROPS.find((k) => features.some((f) => primitive(f.properties?.[k]))) ?? spec.featureKey ?? firstText() : null);
@@ -8468,8 +8665,9 @@ const projections = {
8468
8665
  */
8469
8666
  function makeProjection(p) {
8470
8667
  if (!p) return null;
8471
- if (typeof p === 'string') { const f = projections[p] || projections[p.toLowerCase()]; return f ? f() : null; }
8472
- if (typeof p === 'object') { const f = projections[p.type || p.name]; return f ? f(p) : null; }
8668
+ 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; };
8669
+ if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
8670
+ if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
8473
8671
  return null;
8474
8672
  }
8475
8673
 
@@ -8606,6 +8804,8 @@ const MAX_LAT = 85.0511287798;
8606
8804
  const TILE = 256;
8607
8805
  const MAX_TILES = 48;
8608
8806
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
8807
+ const lo = (a) => a.reduce((m, v) => (v < m ? v : m), Infinity);
8808
+ const hi = (a) => a.reduce((m, v) => (v > m ? v : m), -Infinity);
8609
8809
 
8610
8810
  /** Longitud → x de Web Mercator en [0, 1] (0 = 180° O). */
8611
8811
  const mercX = (lon) => (lon + 180) / 360;
@@ -8803,10 +9003,10 @@ function pointmap(spec, theme) {
8803
9003
  let unwrapped = false;
8804
9004
  {
8805
9005
  const lons = pts.map((p) => p.lon);
8806
- const span = Math.max(...lons) - Math.min(...lons);
9006
+ const span = hi(lons) - lo(lons);
8807
9007
  if (span > 180) {
8808
9008
  const alt = lons.map((l) => (l < 0 ? l + 360 : l));
8809
- if (Math.max(...alt) - Math.min(...alt) < span) { for (const p of pts) if (p.lon < 0) p.lon += 360; unwrapped = true; }
9009
+ if (hi(alt) - lo(alt) < span) { for (const p of pts) if (p.lon < 0) p.lon += 360; unwrapped = true; }
8810
9010
  }
8811
9011
  }
8812
9012
  for (const p of pts) { p.mx = mercX(p.lon); p.my = mercY(p.lat); }
@@ -8833,7 +9033,7 @@ function pointmap(spec, theme) {
8833
9033
  colorOf = (p) => cmap.get(p.k) || theme.other;
8834
9034
  if (keys.length > 1) legend = keys.map((k) => ({ label: k, color: cmap.get(k) }));
8835
9035
  } else if (valueF && values.length) {
8836
- const vmin = Math.min(...values), vmax = Math.max(...values);
9036
+ const vmin = lo(values), vmax = hi(values);
8837
9037
  const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
8838
9038
  let stops, at;
8839
9039
  if (mid !== undefined) {
@@ -8858,7 +9058,7 @@ function pointmap(spec, theme) {
8858
9058
  const frame = createFrame(spec, theme, { height: Math.round(W * 0.8) });
8859
9059
  let area = frame.layout(legend);
8860
9060
 
8861
- const maxS = Math.max(0, ...sizes.map(Math.abs));
9061
+ const maxS = sizes.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
8862
9062
  const wk = Math.max(0.6, Math.min(1.2, area.w / 672));
8863
9063
  const rMax = sizeF && maxS ? spec.maxRadius ?? Math.round((n <= 40 ? 22 : n <= 150 ? 15 : n <= 600 ? 10 : 6) * wk) : 0;
8864
9064
  const rBase = spec.radius ?? (dense ? 2.2 : n > 500 ? 3 : n > 120 ? 4 : 5.5);
@@ -9060,8 +9260,8 @@ function describePointmap({ pts, n, provider, world, hueF, keys, sizeF, valueF,
9060
9260
  const en = String(locale).startsWith('en');
9061
9261
  const W = en ? 'W' : 'O';
9062
9262
  const lats = pts.map((p) => p.lat), lons = pts.map((p) => (p.lon > 180 ? p.lon - 360 : p.lon));
9063
- const la0 = Math.min(...lats), la1 = Math.max(...lats);
9064
- const lo0 = Math.min(...lons), lo1 = Math.max(...lons);
9263
+ const la0 = lo(lats), la1 = hi(lats);
9264
+ const lo0 = lo(lons), lo1 = hi(lons);
9065
9265
  const base = provider ? provider.name || provider.attribution : world ? (en ? 'a world outline' : 'un contorno del mundo') : '';
9066
9266
  const where = n === 1 || (la1 - la0 < 0.05 && lo1 - lo0 < 0.05)
9067
9267
  ? (en ? ` at ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}` : ` en ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}`)
@@ -9224,6 +9424,503 @@ function heatmap(spec, theme) {
9224
9424
 
9225
9425
  return { "heatmap": heatmap };
9226
9426
  });
9427
+ __def("src/charts/corr.js", function () {
9428
+ const { el, g, text, tip, barPath, dotLayer } = __req("src/svg.js");
9429
+ const { measure, truncate } = __req("src/text.js");
9430
+ const { makeFormat, formatNumber } = __req("src/format.js");
9431
+ const { ramp, textOn, colorMap, readable } = __req("src/theme.js");
9432
+ const { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } = __req("src/data.js");
9433
+ const { createFrame } = __req("src/frame.js");
9434
+ const { ctx } = __req("src/context.js");
9435
+ const { pearson, polyfit, histogram, kde: kdeEst } = __req("src/stats.js");
9436
+ const { hueField, axisDomain, tickFmt } = __req("src/charts/dist.js");
9437
+
9438
+ const isEN = (spec) => String(spec.locale || 'es').startsWith('en');
9439
+ const halo = (theme) => ({ stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' });
9440
+
9441
+
9442
+ /** Rangos con empates promediados (para Spearman). */
9443
+ function ranks(vs) {
9444
+ const idx = vs.map((_, i) => i).sort((a, b) => vs[a] - vs[b]);
9445
+ const out = new Array(vs.length);
9446
+ for (let i = 0; i < idx.length;) {
9447
+ let j = i;
9448
+ while (j + 1 < idx.length && vs[idx[j + 1]] === vs[idx[i]]) j++;
9449
+ const avg = (i + j) / 2 + 1;
9450
+ for (let t = i; t <= j; t++) out[idx[t]] = avg;
9451
+ i = j + 1;
9452
+ }
9453
+ return out;
9454
+ }
9455
+
9456
+ /** Φ(z) (Abramowitz–Stegun 7.1.26, error < 1,5e-7). */
9457
+ function normCdf(z) {
9458
+ const x = Math.abs(z) / Math.SQRT2;
9459
+ const t = 1 / (1 + 0.3275911 * x);
9460
+ const erf = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
9461
+ return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2;
9462
+ }
9463
+
9464
+ /** ln Γ(x) (Lanczos, g = 7). */
9465
+ function lnGamma(x) {
9466
+ const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
9467
+ x -= 1;
9468
+ let a = c[0];
9469
+ const t = x + 7.5;
9470
+ for (let i = 1; i < 9; i++) a += c[i] / (x + i);
9471
+ return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a);
9472
+ }
9473
+
9474
+ /** Beta incompleta regularizada I_x(a, b) (fracción continua de Lentz). */
9475
+ function betaInc(x, a, b) {
9476
+ if (x <= 0) return 0;
9477
+ if (x >= 1) return 1;
9478
+ const front = Math.exp(lnGamma(a + b) - lnGamma(a) - lnGamma(b) + a * Math.log(x) + b * Math.log(1 - x));
9479
+ const cf = (xx, aa, bb) => {
9480
+ const tiny = 1e-300;
9481
+ let c = 1, d = 1 - ((aa + bb) * xx) / (aa + 1);
9482
+ d = 1 / (Math.abs(d) < tiny ? tiny : d);
9483
+ let h = d;
9484
+ for (let m = 1; m <= 200; m++) {
9485
+ const m2 = 2 * m;
9486
+ let k = (m * (bb - m) * xx) / ((aa + m2 - 1) * (aa + m2));
9487
+ d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
9488
+ c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
9489
+ h *= d * c;
9490
+ k = (-(aa + m) * (aa + bb + m) * xx) / ((aa + m2) * (aa + m2 + 1));
9491
+ d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
9492
+ c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
9493
+ const del = d * c;
9494
+ h *= del;
9495
+ if (Math.abs(del - 1) < 1e-14) break;
9496
+ }
9497
+ return h;
9498
+ };
9499
+ return x < (a + 1) / (a + b + 2) ? (front * cf(x, a, b)) / a : 1 - (front * cf(1 - x, b, a)) / b;
9500
+ }
9501
+
9502
+ /**
9503
+ * p bilateral de H0: ρ = 0, con la t de Student de n − 2 grados de libertad
9504
+ * (la misma de scipy.stats.pearsonr / spearmanr y de pandas). La aproximación
9505
+ * normal de Fisher se queda corta con pocas filas y cambia quién pasa `alpha`.
9506
+ */
9507
+ function pValue(r, n) {
9508
+ if (!Number.isFinite(r) || n < 3) return NaN;
9509
+ if (Math.abs(r) >= 1) return 0;
9510
+ return betaInc(1 - r * r, (n - 2) / 2, 0.5);
9511
+ }
9512
+
9513
+ /** Campos numéricos a correlacionar: `vars`, `y: [...]` o todas las medidas. */
9514
+ function corrVars(spec, data) {
9515
+ const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
9516
+ if (list) {
9517
+ for (const f of list) requireField(data, f, 'vars');
9518
+ return uniq(list.map(String));
9519
+ }
9520
+ const hue = hueField(spec);
9521
+ return fieldsOf(data).filter((f) => f !== hue && f !== spec.label && fieldKind(data, f) === 'number');
9522
+ }
9523
+
9524
+ /**
9525
+ * Correlaciones por pares (casos completos de cada par).
9526
+ * Devuelve { r, n, p } como matrices k × k. Un par sin variación da NaN.
9527
+ */
9528
+ function correlations(data, vars, method = 'pearson') {
9529
+ const cols = vars.map((f) => data.map((row) => num(row[f])));
9530
+ const k = vars.length;
9531
+ const M = () => Array.from({ length: k }, () => new Array(k).fill(NaN));
9532
+ const r = M(), n = M(), p = M();
9533
+ for (let i = 0; i < k; i++) {
9534
+ const c = cols[i].filter(Number.isFinite);
9535
+ const varies = c.some((v) => v !== c[0]);
9536
+ r[i][i] = varies ? 1 : NaN; n[i][i] = c.length; p[i][i] = varies ? 0 : NaN;
9537
+ for (let j = i + 1; j < k; j++) {
9538
+ const xs = [], ys = [];
9539
+ for (let t = 0; t < data.length; t++) {
9540
+ const a = cols[i][t], b = cols[j][t];
9541
+ if (Number.isFinite(a) && Number.isFinite(b)) { xs.push(a); ys.push(b); }
9542
+ }
9543
+ const m = xs.length;
9544
+ let v = NaN;
9545
+ if (m >= 3 && xs.some((x) => x !== xs[0]) && ys.some((y) => y !== ys[0])) {
9546
+ v = method === 'spearman' ? pearson(ranks(xs), ranks(ys)) : pearson(xs, ys);
9547
+ v = Math.max(-1, Math.min(1, v));
9548
+ }
9549
+ r[i][j] = r[j][i] = v;
9550
+ n[i][j] = n[j][i] = m;
9551
+ p[i][j] = p[j][i] = pValue(v, m);
9552
+ }
9553
+ }
9554
+ return { r, n, p };
9555
+ }
9556
+
9557
+ /**
9558
+ * Agrupamiento jerárquico aglomerativo (enlace promedio, distancia 1 − r),
9559
+ * como el de sns.clustermap. Devuelve el árbol: hojas { leaf }, nodos { left, right, h }.
9560
+ */
9561
+ function clusterTree(r, idx = r.map((_, i) => i)) {
9562
+ let nodes = idx.map((i) => ({ leaf: i, members: [i], h: 0 }));
9563
+ const dist = (A, B) => {
9564
+ let s = 0, c = 0;
9565
+ for (const a of A.members) for (const b of B.members) { const v = r[a][b]; if (Number.isFinite(v)) { s += 1 - v; c++; } }
9566
+ return c ? s / c : 2;
9567
+ };
9568
+ while (nodes.length > 1) {
9569
+ let best = [0, 1, Infinity];
9570
+ for (let a = 0; a < nodes.length; a++) for (let b = a + 1; b < nodes.length; b++) {
9571
+ const d = dist(nodes[a], nodes[b]);
9572
+ if (d < best[2] - 1e-12) best = [a, b, d];
9573
+ }
9574
+ const [a, b, d] = best;
9575
+ const node = { left: nodes[a], right: nodes[b], h: Math.max(d, nodes[a].h, nodes[b].h), members: [...nodes[a].members, ...nodes[b].members] };
9576
+ nodes = nodes.filter((_, i) => i !== a && i !== b);
9577
+ nodes.splice(a, 0, node);
9578
+ }
9579
+ return nodes[0];
9580
+ }
9581
+
9582
+ const leaves = (node) => (node.left ? [...leaves(node.left), ...leaves(node.right)] : [node.leaf]);
9583
+
9584
+
9585
+ /**
9586
+ * Matriz de correlación. Desde filas crudas (`vars` o todas las medidas) o
9587
+ * desde una matriz ya calculada en formato largo (`x`, `y`, `value`).
9588
+ * Opciones: method ("pearson" | "spearman"), triangle ("lower" | "full"),
9589
+ * cluster (reordena y dibuja el dendrograma), alpha (apaga las no significativas),
9590
+ * highlight (una variable), labels, cellSize.
9591
+ */
9592
+ function corr(spec, theme) {
9593
+ const data = ensureData(spec);
9594
+ const en = isEN(spec);
9595
+ const locale = spec.locale || 'es';
9596
+ const method = String(spec.method || 'pearson').toLowerCase() === 'spearman' ? 'spearman' : 'pearson';
9597
+ const long = typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
9598
+
9599
+ let vars, R, N, P;
9600
+ if (long) {
9601
+ requireField(data, spec.x, 'x'); requireField(data, spec.y, 'y'); requireField(data, spec.value, 'value');
9602
+ vars = uniq([...data.map((row) => String(row[spec.x])), ...data.map((row) => String(row[spec.y]))]);
9603
+ const at = new Map(vars.map((v, i) => [v, i]));
9604
+ const k = vars.length;
9605
+ R = Array.from({ length: k }, (_, i) => Array.from({ length: k }, (_, j) => (i === j ? 1 : NaN)));
9606
+ N = R.map((row) => row.map(() => NaN));
9607
+ P = N.map((row) => [...row]);
9608
+ for (const row of data) {
9609
+ const i = at.get(String(row[spec.x])), j = at.get(String(row[spec.y])), v = num(row[spec.value]);
9610
+ if (!Number.isFinite(v)) continue;
9611
+ R[i][j] = v;
9612
+ if (!Number.isFinite(R[j][i]) || i === j) R[j][i] = v;
9613
+ }
9614
+ } else {
9615
+ vars = corrVars(spec, data);
9616
+ ({ r: R, n: N, p: P } = correlations(data, vars, method));
9617
+ }
9618
+ if (vars.length < 2) {
9619
+ throw en
9620
+ ? new PlotError('A correlation matrix needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area", "age"]`.')
9621
+ : new PlotError('La matriz de correlación necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie", "antiguedad"]`.');
9622
+ }
9623
+ const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || (long ? vars[i] : humanize(vars[i]));
9624
+
9625
+ const k = vars.length;
9626
+ let tree = null;
9627
+ let order = vars.map((_, i) => i);
9628
+ if (spec.cluster && k > 2) { tree = clusterTree(R); order = leaves(tree); }
9629
+ else if (Array.isArray(spec.order)) {
9630
+ const pos = (i) => { const a = spec.order.indexOf(vars[i]); return a < 0 ? spec.order.indexOf(name(i)) : a; };
9631
+ order = [...order].sort((a, b) => ((pos(a) + 1 || 1e9) - (pos(b) + 1 || 1e9)) || a - b);
9632
+ }
9633
+ const triangle = spec.triangle === false ? 'full' : spec.triangle === true ? 'lower' : (spec.triangle ?? (spec.cluster ? 'full' : 'lower'));
9634
+ const full = triangle === 'full' || k === 2 && triangle !== 'lower';
9635
+ const rows = full ? order : order.slice(1);
9636
+ const cols = full ? order : order.slice(0, -1);
9637
+ const showDendro = !!tree && full && spec.dendrogram !== false;
9638
+
9639
+ const vals = R.flat().filter(Number.isFinite);
9640
+ const span = long ? Math.max(1, ...vals.map(Math.abs)) : 1;
9641
+ const stops = theme.diverging;
9642
+ const colorOf = (v) => (Number.isFinite(v) ? ramp(stops, 0.5 + v / span / 2) : theme.tint);
9643
+ const dec = (v) => formatNumber(v, span > 1 ? 1 : 2, locale);
9644
+ const fmt = long && span > 1 ? makeFormat(spec.format, locale, vals) : dec;
9645
+ const endFmt = (v) => (span === 1 ? formatNumber(v, 0, locale) : fmt(v));
9646
+ const alpha = Number.isFinite(+spec.alpha) && +spec.alpha > 0 && +spec.alpha < 1 ? +spec.alpha : null;
9647
+ const insignificant = (i, j) => alpha !== null && i !== j && Number.isFinite(P[i][j]) && P[i][j] >= alpha;
9648
+ const sym = method === 'spearman' ? 'ρ' : 'r';
9649
+ const pText = (p) => (Number.isFinite(p) ? (p < 0.001 ? `p < ${formatNumber(0.001, 3, locale)}` : `p = ${formatNumber(p, p < 0.01 ? 3 : 2, locale)}`) : '');
9650
+
9651
+ const hl = new Set([].concat(spec.highlight ?? []).map(String));
9652
+ const isHL = (i) => hl.has(vars[i]) || hl.has(name(i));
9653
+ const anyHL = hl.size > 0 && vars.some((_, i) => isHL(i));
9654
+
9655
+ const rampTitle = spec.legendTitle ?? (long ? humanize(spec.value) : method === 'spearman' ? (en ? 'Spearman ρ' : 'ρ de Spearman') : (en ? 'Pearson r' : 'r de Pearson'));
9656
+ const legend = spec.legend === false ? [] : [
9657
+ { type: 'ramp', stops, min: endFmt(-span), max: endFmt(span), title: rampTitle },
9658
+ ...(alpha !== null ? [{ label: en ? `p ≥ ${formatNumber(alpha, 2, locale)}: not significant` : `p ≥ ${formatNumber(alpha, 2, locale)}: no significativa`, color: theme.tint }] : []),
9659
+ ];
9660
+ const frame = createFrame(spec, theme, { height: 520 });
9661
+ const area = frame.layout(legend);
9662
+
9663
+ const X = ctx.textScale;
9664
+ const fs = 12;
9665
+ const rowW = Math.min(area.w * 0.34, Math.max(...rows.map((i) => measure(name(i), fs)))) + 12;
9666
+ const dendroH = showDendro ? 48 : 0;
9667
+ const colW = Math.max(...cols.map((i) => measure(name(i), fs)));
9668
+ const colLabMax = Math.min(colW, 150, spec.height ? Math.max(40, area.h * 0.28) : 150);
9669
+ const labH = (ang) => (ang === 90 ? colLabMax + 14 * X : ang ? colLabMax * 0.71 + 14 * X : 20 * X);
9670
+ const fitCell = (reserve) => Math.max(14, Math.min(+spec.cellSize || 96, (area.w - rowW) / cols.length, (spec.height ? Math.max(80, area.h - dendroH - reserve) : 600) / rows.length));
9671
+ const angleFor = (c) => (colW + 8 <= c ? 0 : c * 0.71 < fs * X + 3 ? 90 : 45);
9672
+ let cell = fitCell(labH(0));
9673
+ let angle = angleFor(cell);
9674
+ if (spec.height && angle) {
9675
+ cell = fitCell(labH(angle));
9676
+ angle = angleFor(cell);
9677
+ if (angle === 90) cell = fitCell(labH(90));
9678
+ }
9679
+ const colLabH = labH(angle);
9680
+ const gx = area.x + rowW, gy = area.y + dendroH;
9681
+ const gw = cell * cols.length, gh = cell * rows.length;
9682
+ if (spec.height === undefined) frame.setHeight(gy + gh + colLabH + 8 + frame.chrome);
9683
+
9684
+ const lab = cell >= 70 ? 13 : 11;
9685
+ const showLabels = spec.labels ?? cell >= measure(dec(-0.88), lab) + 8;
9686
+ const body = [];
9687
+
9688
+ if (showDendro) {
9689
+ const cx = new Map(cols.map((i, c) => [i, gx + c * cell + cell / 2]));
9690
+ const maxH = tree.h || 1;
9691
+ const yOf = (h) => gy - 6 - (h / maxH) * (dendroH - 12);
9692
+ const segs = [];
9693
+ const walk = (nd) => {
9694
+ if (!nd.left) return [cx.get(nd.leaf), gy - 6];
9695
+ const [xl, yl] = walk(nd.left), [xr, yr] = walk(nd.right);
9696
+ const y = yOf(nd.h);
9697
+ segs.push(`M${xl.toFixed(1)},${yl.toFixed(1)}V${y.toFixed(1)}H${xr.toFixed(1)}V${yr.toFixed(1)}`);
9698
+ return [(xl + xr) / 2, y];
9699
+ };
9700
+ walk(tree);
9701
+ body.push(el('path', { d: segs.join(''), fill: 'none', stroke: theme.muted, strokeWidth: 1.1, strokeLinejoin: 'round' }));
9702
+ }
9703
+
9704
+ rows.forEach((i, ri) => {
9705
+ const on = !anyHL || isHL(i);
9706
+ body.push(text(gx - 10, gy + ri * cell + cell / 2 + 4, truncate(name(i), rowW - 12, fs), { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, textAnchor: 'end', fontWeight: anyHL && on ? 600 : null }));
9707
+ cols.forEach((j, ci) => {
9708
+ if (!full && order.indexOf(i) <= order.indexOf(j)) return;
9709
+ const v = R[i][j];
9710
+ const ns = insignificant(i, j);
9711
+ const fill = ns ? theme.tint : colorOf(v);
9712
+ const x = gx + ci * cell + 1, y = gy + ri * cell + 1, w = Math.max(0, cell - 2);
9713
+ const dim = anyHL && !isHL(i) && !isHL(j);
9714
+ const parts = [el('rect', { x, y, width: w, height: w, rx: Math.min(4, cell / 8), fill, opacity: dim ? 0.22 : null })];
9715
+ if (showLabels && Number.isFinite(v) && i !== j) {
9716
+ parts.push(text(x + w / 2, y + w / 2 + lab * 0.36, fmt(v), { fill: ns || dim ? theme.muted : textOn(fill, theme), fontSize: lab, textAnchor: 'middle', fontWeight: 500, opacity: dim ? 0.7 : null }));
9717
+ }
9718
+ const lines = Number.isFinite(v)
9719
+ ? [`${long ? humanize(spec.value) : sym} = ${fmt(v)}`, i !== j && Number.isFinite(N[i][j]) ? `n = ${formatNumber(N[i][j], 0, locale)}${Number.isFinite(P[i][j]) ? ' · ' + pText(P[i][j]) : ''}` : '']
9720
+ : [en ? 'No variation or too few rows' : 'Sin variación o muy pocas filas'];
9721
+ body.push(g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, ...lines) }, parts));
9722
+ });
9723
+ });
9724
+
9725
+ cols.forEach((j, ci) => {
9726
+ const on = !anyHL || isHL(j);
9727
+ const cx = gx + ci * cell + cell / 2;
9728
+ const attrs = { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, fontWeight: anyHL && on ? 600 : null };
9729
+ if (angle) {
9730
+ const y = gy + gh + (angle === 90 ? 8 : 12);
9731
+ const ax = angle === 90 ? cx + fs * 0.35 : cx + 4;
9732
+ body.push(text(ax, y, truncate(name(j), colLabMax, fs), { ...attrs, textAnchor: 'end', transform: `rotate(-${angle} ${ax.toFixed(1)} ${y.toFixed(1)})` }));
9733
+ } else body.push(text(cx, gy + gh + 16 * X, name(j), { ...attrs, textAnchor: 'middle' }));
9734
+ });
9735
+
9736
+ const pairs = [];
9737
+ for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
9738
+ pairs.sort((p, q) => q[2] - p[2]);
9739
+ const pos = pairs.find((p) => p[2] > 0), neg = [...pairs].reverse().find((p) => p[2] < 0);
9740
+ const kind = long ? '' : method === 'spearman' ? ' (Spearman)' : ' (Pearson)';
9741
+ const say = (p) => `${name(p[0])} ${en ? 'and' : 'y'} ${name(p[1])} (${fmt(p[2])})`;
9742
+ const desc = en
9743
+ ? `Correlation matrix${kind} of ${k} variables${tree ? ', ordered by hierarchical clustering' : ''}.` + (pos ? ` Strongest positive: ${say(pos)}.` : '') + (neg ? ` Strongest negative: ${say(neg)}.` : '') + (!pairs.length ? ' No pair has enough data.' : '')
9744
+ : `Matriz de correlación${kind} de ${k} variables${tree ? ', ordenadas por agrupamiento jerárquico' : ''}.` + (pos ? ` Más fuerte positiva: ${say(pos)}.` : '') + (neg ? ` Más fuerte negativa: ${say(neg)}.` : '') + (!pairs.length ? ' Ningún par tiene datos suficientes.' : '');
9745
+ return frame.finish(body, { desc, type: 'corr' });
9746
+ }
9747
+
9748
+
9749
+ /**
9750
+ * Matriz de dispersión (sns.pairplot). Abajo de la diagonal, dispersión de
9751
+ * cada par; en la diagonal, la distribución de cada variable; arriba, el r
9752
+ * de Pearson (o dispersión con `upper: "scatter"`, nada con `corner: true`).
9753
+ * Opciones: vars, hue, diag ("hist" | "kde"), kind ("scatter" | "reg"), bins, radius.
9754
+ */
9755
+ function pairplot(spec, theme) {
9756
+ const data = ensureData(spec);
9757
+ const en = isEN(spec);
9758
+ const locale = spec.locale || 'es';
9759
+ let vars = corrVars(spec, data);
9760
+ const explicit = spec.vars || Array.isArray(spec.y);
9761
+ if (!explicit && vars.length > 6) vars = vars.slice(0, 6);
9762
+ if (vars.length < 2) {
9763
+ throw en
9764
+ ? new PlotError('A pair plot needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area"]`.')
9765
+ : new PlotError('La matriz de dispersión necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie"]`.');
9766
+ }
9767
+ const hue = hueField(spec);
9768
+ if (hue) requireField(data, hue, 'hue');
9769
+ const k = vars.length;
9770
+ const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || humanize(vars[i]);
9771
+ const cols = vars.map((f) => data.map((row) => num(row[f])));
9772
+ const hk = data.map((row) => (hue ? String(row[hue] ?? '') : ''));
9773
+ const keys = hue ? uniq(hk) : [''];
9774
+ const K = keys.length;
9775
+ const cmap = colorMap(keys, theme, spec.colors);
9776
+ const colorOf = (key) => (K === 1 ? (spec.colors && spec.colors['']) || theme.categorical[0] : cmap.get(key));
9777
+ const legend = K > 1 && spec.legend !== false ? keys.map((key) => ({ label: key, color: colorOf(key) })) : [];
9778
+
9779
+ const frame = createFrame(spec, theme, { height: 640 });
9780
+ const area = frame.layout(legend);
9781
+ const X = ctx.textScale;
9782
+ const fmts = vars.map((_, i) => makeFormat(spec.format, locale, cols[i].filter(Number.isFinite)));
9783
+ const axes = vars.map((_, i) => axisDomain(cols[i], { nice: 'tight', count: 3 }));
9784
+ const tfs = vars.map((_, i) => tickFmt(fmts[i], axes[i]));
9785
+ const ticksIn = (ax) => ax.ticks.filter((t) => t >= ax.min && t <= ax.max);
9786
+ const fs = 11;
9787
+ const leftM = Math.max(...vars.slice(1).map((_, i) => Math.max(0, ...ticksIn(axes[i + 1]).map((t) => measure(tfs[i + 1](t), fs))))) + 8;
9788
+ const gap = 8;
9789
+ const bottom = 20 * X;
9790
+ const fitW = (area.w - leftM - gap * (k - 1)) / k;
9791
+ const fitH = spec.height ? (area.h - bottom - gap * (k - 1)) / k : Infinity;
9792
+ const s = Math.max(40, Math.min(fitW, fitH, +spec.panelSize || 220));
9793
+ const px = (j) => area.x + leftM + j * (s + gap);
9794
+ const py = (i) => area.y + i * (s + gap);
9795
+ if (spec.height === undefined) frame.setHeight(py(k - 1) + s + bottom + 6 + frame.chrome);
9796
+
9797
+ const { r: R, n: N } = correlations(data, vars, 'pearson');
9798
+ const upper = spec.corner ? 'none' : spec.upper === false ? 'none' : (spec.upper ?? 'corr');
9799
+ const diag = spec.diag === 'kde' || spec.diag === 'hist' ? spec.diag : hue ? 'kde' : 'hist';
9800
+ const n = data.length;
9801
+ const rad = spec.radius ?? (n > 600 ? 1.5 : n > 200 ? 2 : n > 60 ? 2.6 : 3.2);
9802
+ const M = theme.marks || {};
9803
+ const body = [];
9804
+ const frameRect = (x, y) => el('rect', { x, y, width: s, height: s, fill: 'none', stroke: theme.grid, strokeWidth: 1 });
9805
+ const gridLines = (i, j) => {
9806
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
9807
+ const out = [];
9808
+ for (const t of ticksIn(axes[j])) out.push(el('line', { x1: xs(t), x2: xs(t), y1: py(i), y2: py(i) + s, stroke: theme.grid, strokeWidth: 0.75 }));
9809
+ for (const t of ticksIn(axes[i])) out.push(el('line', { x1: px(j), x2: px(j) + s, y1: ys(t), y2: ys(t), stroke: theme.grid, strokeWidth: 0.75 }));
9810
+ return out;
9811
+ };
9812
+
9813
+ const scatterPanel = (i, j) => {
9814
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
9815
+ const pts = [];
9816
+ for (let t = 0; t < n; t++) {
9817
+ const a = cols[j][t], b = cols[i][t];
9818
+ if (Number.isFinite(a) && Number.isFinite(b)) pts.push({ x: a, y: b, key: hk[t], label: spec.label ? String(data[t][spec.label] ?? '') : '' });
9819
+ }
9820
+ const out = [...gridLines(i, j)];
9821
+ out.push(g({}, dotLayer(pts.map((p) => ({
9822
+ class: 'pa-m', cx: xs(p.x), cy: ys(p.y), r: rad, fill: colorOf(p.key), fillOpacity: M.dotOpacity ?? 0.72,
9823
+ stroke: pts.length > 300 ? 'none' : theme.surface, strokeWidth: 0.75,
9824
+ 'data-tip': tip(p.label || p.key || `${name(i)} · ${name(j)}`, `${name(j)}: ${fmts[j](p.x)}`, `${name(i)}: ${fmts[i](p.y)}`, p.label && p.key ? p.key : ''),
9825
+ })))));
9826
+ if (spec.kind === 'reg') {
9827
+ for (const key of keys) {
9828
+ const gp = pts.filter((p) => p.key === key);
9829
+ if (gp.length < 3) continue;
9830
+ const f = polyfit(gp.map((p) => p.x), gp.map((p) => p.y));
9831
+ const lo = Math.min(...gp.map((p) => p.x)), hi = Math.max(...gp.map((p) => p.x));
9832
+ const clampY = (v) => Math.max(py(i), Math.min(py(i) + s, ys(v)));
9833
+ out.push(el('line', { x1: xs(lo), y1: clampY(f.predict(lo)), x2: xs(hi), y2: clampY(f.predict(hi)), stroke: K === 1 ? theme.ink : colorOf(key), strokeWidth: 1.75, opacity: 0.9 }));
9834
+ }
9835
+ }
9836
+ return out;
9837
+ };
9838
+
9839
+ const diagPanel = (i) => {
9840
+ const ax = axes[i];
9841
+ const xs = ax.scale(px(i) + 4, px(i) + s - 4);
9842
+ const base = py(i) + s, top = py(i) + 22 * X;
9843
+ const out = [];
9844
+ const groups = keys.map((key) => cols[i].filter((v, t) => Number.isFinite(v) && hk[t] === key));
9845
+ const allV = cols[i].filter(Number.isFinite);
9846
+ if (diag === 'kde') {
9847
+ const curves = groups.map((vs) => (vs.length > 1 && vs.some((v) => v !== vs[0]) ? kdeEst(vs, { extent: [ax.min, ax.max], gridSize: 90 }).map(([x, d]) => [x, (d * vs.length) / allV.length]) : []));
9848
+ const mx = Math.max(1e-12, ...curves.flat().map((p) => p[1]));
9849
+ curves.forEach((c, gi) => {
9850
+ if (!c.length) return;
9851
+ const yOf = (d) => base - (d / mx) * (base - top);
9852
+ const line = c.map(([x, d], t) => `${t ? 'L' : 'M'}${xs(x).toFixed(2)},${yOf(d).toFixed(2)}`).join('');
9853
+ out.push(el('path', { d: `${line}L${xs(c[c.length - 1][0]).toFixed(2)},${base}L${xs(c[0][0]).toFixed(2)},${base}Z`, fill: colorOf(keys[gi]), fillOpacity: (M.areaOpacity ?? 0.12) * 1.6 }));
9854
+ out.push(el('path', { d: line, fill: 'none', stroke: colorOf(keys[gi]), strokeWidth: 1.6 }));
9855
+ });
9856
+ } else {
9857
+ const nb = spec.bins ?? Math.max(6, Math.min(s < 120 ? 14 : 22, histogram(allV).length));
9858
+ const bins = histogram(allV, { bins: nb, domain: [ax.min, ax.max] });
9859
+ const counts = groups.map((vs) => bins.map((b, bi) => vs.filter((v) => v >= b.x0 && (v < b.x1 || (bi === bins.length - 1 && v <= b.x1))).length));
9860
+ const mx = Math.max(1, ...counts.flat());
9861
+ const op = K > 1 ? 0.55 : 0.85;
9862
+ counts.forEach((cs, gi) => bins.forEach((b, bi) => {
9863
+ const h = (cs[bi] / mx) * (base - top);
9864
+ const a0 = xs(b.x0) + 0.5, a1 = xs(b.x1) - 0.5;
9865
+ if (h <= 0 || a1 <= a0) return;
9866
+ out.push(el('path', { class: 'pa-m', d: barPath(a0, base - h, a1 - a0, h, 'up', 1.5), fill: colorOf(keys[gi]), fillOpacity: op, 'data-tip': tip(name(i), `${fmts[i](b.x0)} – ${fmts[i](b.x1)}`, `${keys[gi] ? keys[gi] + ': ' : ''}${cs[bi]}`) }));
9867
+ }));
9868
+ }
9869
+ out.push(el('line', { x1: px(i), x2: px(i) + s, y1: base - 0.5, y2: base - 0.5, stroke: theme.axis }));
9870
+ out.push(text(px(i) + 6, py(i) + 14 * X, truncate(name(i), s - 12, 12, 600), { fill: theme.ink, fontSize: 12, fontWeight: 600, ...halo(theme) }));
9871
+ return out;
9872
+ };
9873
+
9874
+ const corrPanel = (i, j) => {
9875
+ const v = R[i][j];
9876
+ const cx = px(j) + s / 2, cy = py(i) + s / 2;
9877
+ const out = [el('rect', { x: px(j), y: py(i), width: s, height: s, fill: Number.isFinite(v) ? ramp(theme.diverging, 0.5 + v / 2) : theme.tint, fillOpacity: Number.isFinite(v) ? 0.08 + 0.32 * Math.abs(v) : 0.5 })];
9878
+ if (Number.isFinite(v)) {
9879
+ const size = Math.min(s * 0.24, 13 + 15 * Math.abs(v));
9880
+ const showGroups = K > 1 && K <= 4 && s >= 110;
9881
+ const y0 = showGroups ? cy - (K * 14) / 2 + 2 : cy + size * 0.36;
9882
+ out.push(text(cx, y0, formatNumber(v, 2, locale), { fill: readable(ramp(theme.diverging, 0.5 + v / 2), theme, 4.5), fontSize: size, fontWeight: 600, textAnchor: 'middle' }));
9883
+ if (showGroups) {
9884
+ keys.forEach((key, gi) => {
9885
+ const sub = correlations(data.filter((_, t) => hk[t] === key), [vars[i], vars[j]]).r[0][1];
9886
+ out.push(text(cx, y0 + 18 + gi * 14, `${truncate(key, s * 0.55, 10.5)}: ${Number.isFinite(sub) ? formatNumber(sub, 2, locale) : '—'}`, { fill: readable(colorOf(key), theme, 3), fontSize: 10.5, textAnchor: 'middle' }));
9887
+ });
9888
+ }
9889
+ }
9890
+ return [g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, `r = ${Number.isFinite(v) ? formatNumber(v, 2, locale) : '—'}`, `n = ${formatNumber(N[i][j], 0, locale)}`) }, out)];
9891
+ };
9892
+
9893
+ for (let i = 0; i < k; i++) {
9894
+ for (let j = 0; j < k; j++) {
9895
+ if (i < j && upper === 'none') continue;
9896
+ const part = i === j ? diagPanel(i) : i > j || upper === 'scatter' ? scatterPanel(i, j) : corrPanel(i, j);
9897
+ body.push(g({}, [...part, frameRect(px(j), py(i))]));
9898
+ }
9899
+ }
9900
+
9901
+ for (let j = 0; j < k; j++) {
9902
+ const xs = axes[j].scale(px(j) + 4, px(j) + s - 4);
9903
+ let ts = ticksIn(axes[j]);
9904
+ if (ts.length > 2 && ts.some((t, ti) => ti && xs(t) - xs(ts[ti - 1]) < Math.max(...ts.map((u) => measure(tfs[j](u), fs))) + 6)) ts = ts.filter((_, ti) => ti % 2 === 0);
9905
+ for (const t of ts) body.push(text(xs(t), py(k - 1) + s + 14 * X, tfs[j](t), { fill: theme.muted, fontSize: fs, textAnchor: 'middle' }));
9906
+ }
9907
+ for (let i = 1; i < k; i++) {
9908
+ const ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
9909
+ for (const t of ticksIn(axes[i])) body.push(text(px(0) - 6, ys(t) + 4, tfs[i](t), { fill: theme.muted, fontSize: fs, textAnchor: 'end' }));
9910
+ }
9911
+
9912
+ const pairs = [];
9913
+ for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
9914
+ pairs.sort((p, q) => Math.abs(q[2]) - Math.abs(p[2]));
9915
+ const top = pairs[0];
9916
+ const desc = en
9917
+ ? `Pair plot of ${k} variables (${formatNumber(n, 0, locale)} rows)${hue ? ` colored by ${humanize(hue)}` : ''}: scatter plots below the diagonal, distributions on it.` + (top ? ` Strongest correlation: ${name(top[0])} and ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '')
9918
+ : `Matriz de dispersión de ${k} variables (${formatNumber(n, 0, locale)} filas)${hue ? ` por ${humanize(hue).toLowerCase()}` : ''}: dispersión bajo la diagonal y distribuciones en ella.` + (top ? ` Correlación más fuerte: ${name(top[0])} y ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '');
9919
+ return frame.finish(body, { desc, type: 'pairplot' });
9920
+ }
9921
+
9922
+ return { "corrVars": corrVars, "correlations": correlations, "clusterTree": clusterTree, "corr": corr, "pairplot": pairplot };
9923
+ });
9227
9924
  __def("src/charts/sankey.js", function () {
9228
9925
  const { el, g, text, tip } = __req("src/svg.js");
9229
9926
  const { measure, truncate } = __req("src/text.js");
@@ -9338,7 +10035,7 @@ function sankey(spec, theme) {
9338
10035
  const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
9339
10036
  const d = `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}L${x1},${l.y1 + w}C${xm},${l.y1 + w} ${xm},${l.y0 + w} ${x0},${l.y0 + w}Z`;
9340
10037
  const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
9341
- linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% de ${l.s}` }));
10038
+ linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% ${en ? 'of' : 'de'} ${l.s}` }));
9342
10039
  }
9343
10040
  body.push(g({ class: 'pa-links' }, linkG));
9344
10041
  for (const [ci, c] of cols.entries()) for (const n of c) {
@@ -9456,14 +10153,14 @@ function kpi(spec, theme) {
9456
10153
  if (it.target !== undefined) {
9457
10154
  y += 24 * X;
9458
10155
  const t = num(it.target);
9459
- const f = Math.max(0, Math.min(1, value / (t || 1)));
10156
+ const f = Number.isFinite(value) ? Math.max(0, Math.min(1, value / (t || 1))) : 0; // valor no numérico: riel vacío, nunca NaN
9460
10157
  const w = tw - P * 2;
9461
10158
  const upGood = (it.good ?? 'up') === 'up';
9462
10159
  const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
9463
10160
  const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
9464
10161
  parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
9465
10162
  parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
9466
- const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
10163
+ const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + (Number.isFinite(value) ? formatNumber((value / (t || 1)) * 100, 0, locale) + '%' : '—');
9467
10164
  parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
9468
10165
  }
9469
10166
 
@@ -9484,6 +10181,850 @@ function kpi(spec, theme) {
9484
10181
 
9485
10182
  return { "kpi": kpi };
9486
10183
  });
10184
+ __def("src/charts/table.js", function () {
10185
+ const { el, g, text, tip, esc } = __req("src/svg.js");
10186
+ const { measure, truncate, wrap } = __req("src/text.js");
10187
+ const { makeFormat, formatDate, parseDate, currencyPrefix } = __req("src/format.js");
10188
+ const { ensureData, fieldsOf, fieldKind, num, uniq, humanize, PlotError, aggregate } = __req("src/data.js");
10189
+ const { createFrame } = __req("src/frame.js");
10190
+ const { ctx } = __req("src/context.js");
10191
+ const { ramp, textOn, readable, hexToRgb, rgbToHex, contrast } = __req("src/theme.js");
10192
+ const { dateUnit } = __req("src/scale.js");
10193
+ const { resolveColorScale, sample } = __req("src/colorscales.js");
10194
+ const { words, langOf } = __req("src/i18n.js");
10195
+
10196
+ const TYPE_ALIAS = {
10197
+ text: 'text', string: 'text', label: 'text', category: 'text',
10198
+ number: 'number', numeric: 'number', value: 'number',
10199
+ bar: 'bar', bars: 'bar', databar: 'bar',
10200
+ heat: 'heat', heatmap: 'heat', color: 'heat', colour: 'heat', scale: 'heat',
10201
+ delta: 'delta', change: 'delta', variation: 'delta', variacion: 'delta', 'variación': 'delta', arrow: 'delta', diff: 'delta',
10202
+ sparkline: 'sparkline', spark: 'sparkline', trend: 'sparkline', line: 'sparkline', tendencia: 'sparkline',
10203
+ progress: 'progress', meter: 'progress', target: 'progress', bullet: 'progress', avance: 'progress', goal: 'progress',
10204
+ badge: 'badge', pill: 'badge', tag: 'badge', status: 'badge', estado: 'badge', chip: 'badge',
10205
+ check: 'check', boolean: 'check', bool: 'check', icon: 'check',
10206
+ date: 'date', fecha: 'date', time: 'date',
10207
+ link: 'link', url: 'link',
10208
+ rank: 'rank',
10209
+ };
10210
+ const COLUMN_TYPES = [...new Set(Object.values(TYPE_ALIAS))];
10211
+ const columnType = (t) => TYPE_ALIAS[String(t || '').toLowerCase()] || null;
10212
+
10213
+ const DELTA_RE = /(^|[\s_.-])((var|vars|chg|yoy|mom|qoq|diff|dif)(?![a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1])|delta|change|cambio|crec|growth|difference|diferencia|variaci[oó]n|varia[cç][aã]o|ver[äa]nderung|[ée]volution|evoluci[oó]n|\+\/-)/i;
10214
+ const PCT_NAME_RE = /(%|pct|percent|porc|porcentaje|tasa|rate|share|participaci|anteil|taux|part\b)/i;
10215
+ const PROGRESS_RE = /(progress|avance|progreso|complet|cumplim|ejecuci[oó]n|achiev|logro|fortschritt|avancement|andamento)/i;
10216
+ const GOAL_RE = /^(meta|metas|target|goal|objetivo|objective|ziel|objectif|cumplimiento|attainment|ratio)$|(^|[\s_.-])(meta|target|goal|objetivo)([\s_.-]|$)/i;
10217
+ const ID_RE = /^(id|_id|code|c[oó]d(igo)?|cod_.*|n[°º.]?|no\.?|nro|num(ero|ber)?|#|rut|dni|zip|cp|phone|tel[eé]fono|tel)$/i;
10218
+
10219
+ const STATUS = [
10220
+ ['good', /^(ok|okay|done|complete|completed|finished|approved|active|paid|delivered|resolved|success|successful|on track|healthy|passed|closed|won|live|listo|lista|hecho|hecha|completad[oa]|terminad[oa]|finalizad[oa]|aprobad[oa]|activ[oa]|pagad[oa]|entregad[oa]|resuelt[oa]|al d[ií]a|cumplid[oa]|exitos[oa]|vigente|logrado|ganad[oa]|conclu[ií]d[oa]|pago|ativo|aprovad[oa]|fertig|erledigt|abgeschlossen|aktiv|bezahlt|genehmigt|termin[ée]e?|valid[ée]e?|pay[ée]e?|actif|active|r[ée]ussi)$/i],
10221
+ ['warning', /^(at risk|pending|on hold|in review|under review|review|delayed|warning|paused|draft|waiting|medium|en riesgo|pendiente|en espera|en revisi[oó]n|pausad[oa]|borrador|demorad[oa]|retrasad[oa]|observad[oa]|em risco|pendente|em an[aá]lise|em espera|en attente|[àa] risque|en retard|brouillon|ausstehend|verz[öo]gert|gef[äa]hrdet|in pr[üu]fung|entwurf)$/i],
10222
+ ['critical', /^(late|overdue|failed|fail|failing|error|blocked|cancell?ed|rejected|lost|critical|down|off track|expired|atrasad[oa]|vencid[oa]|bloquead[oa]|cancelad[oa]|rechazad[oa]|fallid[oa]|perdid[oa]|cr[ií]tic[oa]|ca[ií]d[oa]|expirad[oa]|reprovad[oa]|falh[ao]u?|[ée]chou[ée]e?|bloqu[ée]e?|annul[ée]e?|rejet[ée]e?|perdu|fehlgeschlagen|blockiert|abgelehnt|storniert|[üu]berf[äa]llig|verloren)$/i],
10223
+ ['info', /^(in progress|ongoing|open|new|started|running|planned|scheduled|en curso|en progreso|en proceso|en ejecuci[oó]n|abiert[oa]|nuev[oa]|iniciad[oa]|planificad[oa]|programad[oa]|em andamento|em curso|aberto|novo|planejado|en cours|ouvert|nouveau|planifi[ée]e?|in bearbeitung|offen|neu|geplant|l[äa]uft)$/i],
10224
+ ];
10225
+ const statusOf = (v) => { const s = String(v ?? '').trim(); for (const [k, re] of STATUS) if (re.test(s)) return k; return null; };
10226
+
10227
+ const YES = /^(true|yes|y|s[ií]|sim|oui|ja|x|✓|✔|☑)$/i;
10228
+ const NO = /^(false|no|n|não|nao|non|nein|✗|✘|☐|-|–|—)$/i;
10229
+ const SAFE_URL = /^(https?:\/\/|mailto:)[^\s"'<>]+$/i;
10230
+
10231
+ const norm = (s) => String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
10232
+ const isNil = (v) => v === null || v === undefined || v === '' || (typeof v === 'number' && Number.isNaN(v));
10233
+ const decimalsOf = (v) => { if (!Number.isFinite(v) || Number.isInteger(v)) return 0; const s = String(+v.toPrecision(12)); const m = /\.(\d+)$/.exec(s) || /e-(\d+)/.exec(s); return m ? Math.min(6, m[1].length) : 0; };
10234
+
10235
+ function mix(a, b, t) {
10236
+ const A = hexToRgb(a), B = hexToRgb(b);
10237
+ return rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
10238
+ }
10239
+ /** Acerca `fg` a `toward` hasta lograr contraste ≥ min sobre `bg`. */
10240
+ function legible(fg, bg, toward, min = 4.5) {
10241
+ let c = fg;
10242
+ for (let t = 0.05; contrast(c, bg) < min && t <= 1.0001; t += 0.05) c = mix(fg, toward, t);
10243
+ return c;
10244
+ }
10245
+ const hexOr = (c, d) => (typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim()) ? c.trim() : d);
10246
+
10247
+ /** Lista de columnas: la que da el agente o una inferida de los datos. */
10248
+ function columnList(spec, data) {
10249
+ const fields = fieldsOf(data);
10250
+ const groupF = typeof spec.group === 'string' && fields.includes(spec.group) ? spec.group : null;
10251
+ if (Array.isArray(spec.columns) && spec.columns.length) {
10252
+ return spec.columns.map((c) => (typeof c === 'string' ? { field: c } : c && typeof c === 'object' ? { ...c, field: c.field ?? c.key ?? c.name ?? c.id } : null)).filter((c) => c && (c.field !== undefined || columnType(c.type) === 'rank'));
10253
+ }
10254
+ const labelF = spec.label ?? spec.x;
10255
+ let list;
10256
+ if (spec.y !== undefined || spec.value !== undefined) {
10257
+ list = [labelF, ...[].concat(spec.y ?? []), ...[].concat(spec.value ?? [])].filter((f) => f !== undefined && f !== null);
10258
+ if (!labelF) { const first = fields.find((f) => fieldKind(data, f) === 'category' && f !== groupF); if (first) list.unshift(first); }
10259
+ } else {
10260
+ list = fields.filter((f) => f !== groupF && !String(f).startsWith('_'));
10261
+ if (labelF && list.includes(labelF)) list = [labelF, ...list.filter((f) => f !== labelF)];
10262
+ }
10263
+ if (typeof spec.dashed === 'string') list = list.filter((f) => f !== spec.dashed);
10264
+ return uniq(list).map((f) => ({ field: f }));
10265
+ }
10266
+
10267
+ /** Pista de formato a partir del texto crudo: "12%" → porcentaje, "$ 3.000" → moneda. */
10268
+ function rawHint(values) {
10269
+ const strs = values.filter((v) => typeof v === 'string' && v.trim());
10270
+ if (!strs.length || strs.length < values.length * 0.6) return null;
10271
+ if (strs.filter((s) => /%\s*$/.test(s)).length >= strs.length * 0.6) return { type: 'percent', scale: 1 };
10272
+ const cur = strs.map((s) => (/^\s*[-−]?\s*(US\$|\$|€|£|R\$|S\/|UF)/.exec(s) || [])[1]).filter(Boolean);
10273
+ if (cur.length >= strs.length * 0.6) return { type: 'number', prefix: cur[0] === 'UF' ? 'UF ' : cur[0] };
10274
+ return null;
10275
+ }
10276
+
10277
+ /**
10278
+ * Formateador de una columna numérica: mismos decimales en toda la columna
10279
+ * (los números se leen alineados) y cifras completas; nada de "1,2 M" en una
10280
+ * tabla salvo que se pida `format: "compact"`.
10281
+ */
10282
+ function columnFormat(fmt, values, locale, hint) {
10283
+ let o = typeof fmt === 'string' ? { type: fmt } : fmt && typeof fmt === 'object' ? { ...fmt } : hint ? { ...hint } : { type: 'number' };
10284
+ if (!o.type) o.type = 'number';
10285
+ const nums = values.filter(Number.isFinite);
10286
+ const maxAbs = nums.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
10287
+ if (o.type === 'currency') { o = { ...o, type: 'number', prefix: o.prefix ?? currencyPrefix(o.currency, langOf(locale)) }; if (o.decimals === undefined) o.decimals = maxAbs >= 100 || nums.every(Number.isInteger) ? 0 : 2; }
10288
+ if (o.type === 'number' && o.decimals === undefined) {
10289
+ const cap = maxAbs >= 1000 ? 0 : maxAbs >= 100 ? 1 : maxAbs >= 1 ? 2 : 3;
10290
+ o.decimals = Math.min(cap, Math.max(0, ...nums.map(decimalsOf)));
10291
+ }
10292
+ if (o.type === 'percent' && o.decimals === undefined) {
10293
+ const scale = o.scale ?? (maxAbs > 1.5 ? 1 : 100);
10294
+ o.scale = scale;
10295
+ const sc = nums.map((v) => v * scale);
10296
+ const m = sc.reduce((a, v) => Math.max(a, Math.abs(v)), 0);
10297
+ o.decimals = Math.min(m >= 10 ? 1 : 2, Math.max(0, ...sc.map((v) => decimalsOf(+v.toPrecision(10)))));
10298
+ }
10299
+ return makeFormat(o, locale, nums);
10300
+ }
10301
+
10302
+ /** Prepara cada columna: tipo, alineación, formato y estadísticas sobre TODAS las filas. */
10303
+ function prepareColumns(spec, data, theme, locale) {
10304
+ const W = words(locale);
10305
+ const list = columnList(spec, data);
10306
+ const fields = fieldsOf(data);
10307
+ for (const c of list) {
10308
+ if (c.field !== undefined && columnType(c.type) !== 'rank' && !fields.includes(c.field)) {
10309
+ throw new PlotError(`La columna "${c.field}" no existe en los datos.`, `Campos disponibles: ${fields.map((f) => `"${f}"`).join(', ') || '(ninguno)'}`);
10310
+ }
10311
+ }
10312
+ const labelF = spec.label ?? spec.x ?? (list.find((c) => c.field !== undefined && fieldKind(data, c.field) === 'category') || list[0] || {}).field;
10313
+ const mainF = spec.bars === false ? null : spec.value ?? (typeof spec.y === 'string' ? spec.y : undefined);
10314
+ let barGiven = false;
10315
+ const cols = list.map((c0, i) => {
10316
+ const c = { ...c0, index: i };
10317
+ const t = columnType(c.type);
10318
+ if (c.type !== undefined && !t) c.type = undefined;
10319
+ c.label = c.label ?? c.title ?? c.header ?? (t === 'rank' && c.field === undefined ? '#' : humanize(c.field));
10320
+ if (t === 'rank') { c.type = 'rank'; c.align = c.align ?? 'right'; return c; }
10321
+ const raw = data.map((r) => r[c.field]);
10322
+ const present = raw.filter((v) => !isNil(v));
10323
+ const kind = present.length ? fieldKind(data, c.field) : 'empty';
10324
+ const isArr = present.length && present.filter((v) => Array.isArray(v)).length >= present.length * 0.5;
10325
+ const isBool = present.length && (present.every((v) => typeof v === 'boolean') || (present.every((v) => typeof v === 'string' && (YES.test(v.trim()) || NO.test(v.trim()))) && present.some((v) => YES.test(String(v).trim())) && present.some((v) => NO.test(String(v).trim())) && uniq(present.map((v) => String(v).trim().toLowerCase())).length <= 3));
10326
+ const nums = kind === 'number' || kind === 'year' ? raw.map(num) : [];
10327
+ const finite = nums.filter(Number.isFinite);
10328
+ c.isLabel = c.field === labelF;
10329
+ let type = t;
10330
+ if (!type) {
10331
+ if (isArr) type = 'sparkline';
10332
+ else if (isBool) type = 'check';
10333
+ else if (kind === 'date') type = 'date';
10334
+ else if (kind === 'number' || (kind === 'year' && !c.isLabel)) {
10335
+ const lo = Math.min(...finite), hi = Math.max(...finite);
10336
+ if (ID_RE.test(String(c.field))) type = 'text';
10337
+ else if (kind === 'year') type = 'number';
10338
+ else if (DELTA_RE.test(String(c.field)) || DELTA_RE.test(String(c.label))) type = 'delta';
10339
+ else if ((PROGRESS_RE.test(String(c.field)) || PROGRESS_RE.test(String(c.label))) && lo >= 0 && hi <= 150) type = 'progress';
10340
+ else if ((GOAL_RE.test(String(c.field)) || GOAL_RE.test(String(c.label))) && lo >= 0 && hi <= 1.5 && !finite.every(Number.isInteger)) type = 'progress';
10341
+ else type = 'number';
10342
+ } else if (present.length && present.filter((v) => typeof v === 'string' && SAFE_URL.test(v.trim())).length >= present.length * 0.8) type = 'link';
10343
+ else if (present.length >= 2 && present.filter((v) => statusOf(v)).length >= present.length * 0.8) type = 'badge';
10344
+ else type = 'text';
10345
+ }
10346
+ if (type === 'bar' || type === 'heat' || type === 'progress') barGiven = barGiven || type === 'bar';
10347
+ c.type = type;
10348
+ c.kind = kind;
10349
+ c.values = nums;
10350
+ c.finite = finite;
10351
+ const numeric = ['number', 'bar', 'heat', 'delta', 'progress'].includes(type);
10352
+ if (numeric && !finite.length && !(type === 'number' && kind === 'year')) c.type = type = 'text';
10353
+ const hint = rawHint(raw);
10354
+ if (type === 'delta') {
10355
+ const pctLike = hint?.type === 'percent' || PCT_NAME_RE.test(String(c.field)) || PCT_NAME_RE.test(String(c.label)) || (finite.length && finite.every((v) => Math.abs(v) <= 1.5) && !finite.every(Number.isInteger));
10356
+ c.fmt = columnFormat(c.format ?? (pctLike ? (hint?.type === 'percent' ? hint : { type: 'percent' }) : hint), finite.map(Math.abs), locale, null);
10357
+ } else if (type === 'progress') {
10358
+ const target = c.target ?? c.max;
10359
+ c.targetOf = (r) => { const t = typeof target === 'string' ? num(r[target]) : Number.isFinite(+target) && target !== null && target !== undefined ? +target : NaN; return t; };
10360
+ c.hasTarget = target !== undefined && target !== null;
10361
+ const hi = Math.max(0, ...finite);
10362
+ c.scale100 = !c.hasTarget && hi > 1.5;
10363
+ c.fmt = c.hasTarget ? columnFormat(c.format ?? spec.format ?? hint, finite, locale, null) : makeFormat({ type: 'percent', scale: c.scale100 ? 1 : 100, decimals: 0 }, locale, finite);
10364
+ c.pctFmt = makeFormat({ type: 'percent', scale: 100, decimals: 0 }, locale, []);
10365
+ } else if (numeric || (type === 'number' && kind === 'year')) {
10366
+ c.fmt = kind === 'year' && !c.format ? (v) => String(Math.round(v)) : columnFormat(c.format ?? spec.format ?? hint, finite, locale, null);
10367
+ } else if (type === 'sparkline') {
10368
+ const all = present.flatMap((v) => (Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []));
10369
+ c.fmt = columnFormat(c.format ?? spec.format, all, locale, null);
10370
+ c.sparkAll = all;
10371
+ } else if (type === 'date') {
10372
+ const ts = present.map(parseDate).filter(Number.isFinite);
10373
+ c.unit = ts.length ? dateUnit(ts) : 'day';
10374
+ c.withYear = c.yearless !== true;
10375
+ }
10376
+ c.align = c.align ?? (['number', 'bar', 'heat', 'delta', 'progress'].includes(type) ? 'right' : type === 'check' ? 'center' : type === 'sparkline' ? 'left' : 'left');
10377
+ if (type === 'bar' || type === 'heat') {
10378
+ c.lo = Math.min(0, ...finite);
10379
+ c.hi = Math.max(0, ...finite);
10380
+ if (type === 'heat') {
10381
+ c.hlo = Math.min(...finite); c.hhi = Math.max(...finite);
10382
+ const cs = resolveColorScale(c.colorScale ?? c.cmap);
10383
+ c.mid = c.midpoint ?? spec.midpoint ?? (cs && typeof c.colorScale === 'object' && c.colorScale.mid !== undefined ? +c.colorScale.mid : undefined) ?? ((!cs || cs.kind === 'diverging') && c.hlo < 0 && c.hhi > 0 ? 0 : undefined);
10384
+ c.stops = cs && cs.kind !== 'qualitative' ? sample(cs.stops, 17) : null;
10385
+ if (c.stops && cs.kind === 'diverging' && c.mid === undefined) c.mid = (c.hlo + c.hhi) / 2;
10386
+ }
10387
+ }
10388
+ if (type === 'badge') {
10389
+ const vals = uniq(present.map(String));
10390
+ const fixed = { ...(spec.colors || {}), ...(c.colors || {}) };
10391
+ c.badge = new Map(vals.map((v) => {
10392
+ const st = statusOf(v);
10393
+ const base = hexOr(fixed[v], st === 'good' ? theme.status.good : st === 'warning' ? theme.status.warning : st === 'critical' ? theme.status.critical : st === 'info' ? theme.categorical[0] : null);
10394
+ return [v, base];
10395
+ }));
10396
+ }
10397
+ c.good = c.good ?? spec.good ?? 'up';
10398
+ return c;
10399
+ });
10400
+ if (!barGiven && spec.bars !== false && data.length >= 3 && !(Array.isArray(spec.columns) && spec.columns.some((c) => c && typeof c === 'object' && c.type))) {
10401
+ const main = cols.find((c) => c.field === mainF && c.type === 'number' && c.kind === 'number') || (mainF === undefined || mainF === null ? cols.find((c) => c.type === 'number' && c.kind === 'number' && !c.isLabel) : null);
10402
+ if (main && main.finite.length >= 2) { main.type = 'bar'; main.lo = Math.min(0, ...main.finite); main.hi = Math.max(0, ...main.finite); main.auto = true; }
10403
+ }
10404
+ if (spec.rank && !cols.some((c) => c.type === 'rank')) cols.unshift({ type: 'rank', label: '#', align: 'right', index: -1 });
10405
+ return { cols, labelF, W };
10406
+ }
10407
+
10408
+ /** Texto visible de una celda (y su valor para ordenar). */
10409
+ function cellText(c, r, locale, W) {
10410
+ const v = r[c.field];
10411
+ if (c.type === 'rank') return String(r.__rank ?? '');
10412
+ if (isNil(v)) return '';
10413
+ switch (c.type) {
10414
+ case 'number': case 'bar': case 'heat': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
10415
+ case 'delta': { const n = num(v); if (!Number.isFinite(n)) return String(v); const s = c.fmt(Math.abs(n)); return n > 0 ? '+' + s : n < 0 ? '−' + s : s; }
10416
+ case 'progress': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
10417
+ case 'date': { const t = parseDate(v); return Number.isFinite(t) ? formatDate(t, c.unit, locale, c.withYear) : String(v); }
10418
+ case 'check': return v === true || YES.test(String(v).trim()) ? W.yes : W.no;
10419
+ case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? c.fmt(a[a.length - 1]) : ''; }
10420
+ case 'link': { const s = String(v).trim(); if (c.text && r[c.text] !== undefined) return String(r[c.text]); return s.replace(/^mailto:/i, '').replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, ''); }
10421
+ default:
10422
+ if (Array.isArray(v)) return v.map((x) => (x && typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ');
10423
+ if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return '…'; } }
10424
+ return String(v);
10425
+ }
10426
+ }
10427
+
10428
+ function sortKey(c, r) {
10429
+ const v = r[c.field];
10430
+ if (c.type === 'rank') return r.__rank;
10431
+ if (isNil(v)) return null;
10432
+ switch (c.type) {
10433
+ case 'number': case 'bar': case 'heat': case 'delta': case 'progress': { const n = num(v); return Number.isFinite(n) ? n : null; }
10434
+ case 'date': { const t = parseDate(v); return Number.isFinite(t) ? t : String(v); }
10435
+ case 'check': return v === true || YES.test(String(v).trim()) ? 1 : 0;
10436
+ case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? a[a.length - 1] : null; }
10437
+ default: return String(v);
10438
+ }
10439
+ }
10440
+
10441
+ const collatorCache = new Map();
10442
+ function compareText(locale) {
10443
+ const k = langOf(locale);
10444
+ if (!collatorCache.has(k)) {
10445
+ let cmp;
10446
+ try { const col = new Intl.Collator(k, { numeric: true, sensitivity: 'base' }); cmp = (a, b) => col.compare(a, b); } catch { cmp = (a, b) => (norm(a) < norm(b) ? -1 : norm(a) > norm(b) ? 1 : 0); }
10447
+ collatorCache.set(k, cmp);
10448
+ }
10449
+ return collatorCache.get(k);
10450
+ }
10451
+
10452
+ /** Orden actual: `sortBy` (campo o etiqueta) y `sort` ("desc"/"asc"). También `sort: "-ventas"`. */
10453
+ function sortState(spec, cols) {
10454
+ let by = spec.sortBy ?? spec.orderBy;
10455
+ let dir = spec.sortOrder ?? (['asc', 'desc'].includes(spec.sort) ? spec.sort : undefined);
10456
+ if (by === undefined && typeof spec.sort === 'string' && !['asc', 'desc', 'none', 'alpha', 'start'].includes(spec.sort)) {
10457
+ by = spec.sort.replace(/^[-+]/, '');
10458
+ if (dir === undefined) dir = spec.sort.startsWith('-') ? 'desc' : spec.sort.startsWith('+') ? 'asc' : undefined;
10459
+ }
10460
+ if (spec.sort && typeof spec.sort === 'object') { by = spec.sort.by ?? spec.sort.field; dir = spec.sort.order ?? spec.sort.dir; }
10461
+ if (by === undefined && dir) by = (cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat', 'progress'].includes(c.type)) || {}).field;
10462
+ if (by === undefined || spec.sort === 'none') return null;
10463
+ const col = cols.find((c) => c.field === by) || cols.find((c) => String(c.label) === String(by)) || (by === '#' || by === 'rank' ? cols.find((c) => c.type === 'rank') : null);
10464
+ if (!col) return null;
10465
+ const numericish = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(col.type);
10466
+ return { col, dir: dir === 'asc' || dir === 'desc' ? dir : numericish ? 'desc' : 'asc' };
10467
+ }
10468
+
10469
+ const BASE = { cell: 13, head: 12, pad: 10 };
10470
+
10471
+ function table(spec, theme) {
10472
+ const locale = spec.locale || 'es';
10473
+ const lang = langOf(locale);
10474
+ const all = ensureData(spec).map((r, i) => ({ ...r, __i: i }));
10475
+ const { cols, labelF, W } = prepareColumns(spec, all, theme, locale);
10476
+ if (!cols.length) throw new PlotError('La tabla no tiene columnas.', 'Pasa filas con campos, o `columns: ["region", "ventas"]`.');
10477
+ const X = ctx.textScale;
10478
+ const frame = createFrame(spec, theme, { height: 400 });
10479
+ const area = frame.layout([]);
10480
+ const density = spec.density === 'compact' ? 0.65 : spec.density === 'comfortable' ? 1.3 : 1;
10481
+ const padY = Math.round(9 * density);
10482
+ const FS = BASE.cell, HS = BASE.head;
10483
+ const LH = Math.round(FS * X * 1.38);
10484
+ const groupF = typeof spec.group === 'string' && all.length && spec.group in all[0] ? spec.group : null;
10485
+ const hl = spec.highlight === undefined || spec.highlight === null || spec.highlight === false ? null : new Set([].concat(spec.highlight).map(String));
10486
+ const isHL = (r) => hl && labelF !== undefined && hl.has(String(r[labelF]));
10487
+
10488
+ let rows = all;
10489
+ const query = typeof spec.query === 'string' ? spec.query.trim() : '';
10490
+ if (query) {
10491
+ const terms = norm(query).split(/\s+/).filter(Boolean);
10492
+ rows = rows.filter((r) => {
10493
+ const hay = norm(cols.map((c) => (c.type === 'rank' ? '' : cellText(c, r, locale, W) + ' ' + (r[c.field] ?? ''))).join(' ') + ' ' + (groupF ? r[groupF] : ''));
10494
+ return terms.every((t) => hay.includes(t));
10495
+ });
10496
+ }
10497
+ const st = sortState(spec, cols);
10498
+ if (st && st.col.type !== 'rank') {
10499
+ const cmpT = compareText(locale);
10500
+ const sgn = st.dir === 'asc' ? 1 : -1;
10501
+ rows = [...rows].sort((a, b) => {
10502
+ const ka = sortKey(st.col, a), kb = sortKey(st.col, b);
10503
+ if (ka === null && kb === null) return a.__i - b.__i;
10504
+ if (ka === null) return 1;
10505
+ if (kb === null) return -1;
10506
+ const d = typeof ka === 'number' && typeof kb === 'number' ? ka - kb : cmpT(String(ka), String(kb));
10507
+ return d * sgn || a.__i - b.__i;
10508
+ });
10509
+ } else if (st && st.col.type === 'rank' && st.dir === 'desc') rows = [...rows].reverse();
10510
+ if (groupF) {
10511
+ const order = new Map(uniq(all.map((r) => String(r[groupF] ?? ''))).map((k, i) => [k, i]));
10512
+ rows = [...rows].sort((a, b) => order.get(String(a[groupF] ?? '')) - order.get(String(b[groupF] ?? '')));
10513
+ }
10514
+ if (Number.isFinite(+spec.top) && +spec.top > 0) rows = rows.slice(0, +spec.top);
10515
+ rows = rows.map((r, i) => ({ ...r, __rank: i + 1 }));
10516
+
10517
+ const nRows = rows.length;
10518
+ const psRaw = spec.pageSize ?? spec.perPage ?? (spec.paginate === false ? Infinity : undefined);
10519
+ const pageSize = psRaw === false || psRaw === 0 || psRaw === Infinity ? Infinity : Number.isFinite(+psRaw) && +psRaw > 0 ? Math.round(+psRaw) : nRows > 15 ? 10 : Infinity;
10520
+ const pages = pageSize === Infinity ? 1 : Math.max(1, Math.ceil(nRows / pageSize));
10521
+ const page = Math.max(1, Math.min(pages, Math.round(+spec.page || 1)));
10522
+ const pageRows = pageSize === Infinity ? rows : rows.slice((page - 1) * pageSize, page * pageSize);
10523
+
10524
+ const totalOf = (c) => {
10525
+ const pick = c.total ?? (spec.total === true || typeof spec.total === 'string' ? (typeof spec.total === 'string' && spec.total !== 'true' ? spec.total : 'auto') : spec.total && typeof spec.total === 'object' ? spec.total[c.field] ?? 'auto' : undefined);
10526
+ if (pick === undefined || pick === false || c.isLabel || c.type === 'rank') return null;
10527
+ if (!['number', 'bar', 'heat', 'progress', 'delta'].includes(c.type) || c.kind === 'year') return null;
10528
+ const how = pick === true || pick === 'auto' ? (c.type === 'delta' ? null : (c.type === 'progress' && !c.hasTarget) || c.fmt?.type === 'percent' ? 'mean' : 'sum') : pick; // con meta (gasto vs presupuesto) se suma
10529
+ if (!how) return null;
10530
+ const v = aggregate(rows.map((r) => num(r[c.field])), how);
10531
+ return Number.isFinite(v) ? v : null;
10532
+ };
10533
+ const hasTotal = spec.total !== false && cols.some((c) => totalOf(c) !== null);
10534
+ const totalLabel = spec.totalLabel ?? (spec.total && typeof spec.total === 'object' ? spec.total.label : undefined) ?? W.total;
10535
+
10536
+ const edge = Math.min(BASE.pad, Math.max(4, frame.pad - 6));
10537
+ const tableX = area.x - edge, tableW = area.w + edge * 2;
10538
+ const sample = all.length > 1500 ? all.filter((_, i) => i % Math.ceil(all.length / 1500) === 0) : all;
10539
+ const P = BASE.pad;
10540
+ const pillH = Math.round(Math.min(22, LH + 3));
10541
+ for (const c of cols) {
10542
+ const texts = c.type === 'rank' ? [String(Math.max(1, nRows))] : sample.map((r) => cellText(c, r, locale, W));
10543
+ const weight = c.isLabel ? 500 : 400;
10544
+ const widths = texts.map((t) => measure(t, FS, weight));
10545
+ let tw = Math.max(0, ...widths);
10546
+ if (hasTotal && c.isLabel) tw = Math.max(tw, measure(totalLabel, FS, 600));
10547
+ const tot = hasTotal ? totalOf(c) : null;
10548
+ if (tot !== null) tw = Math.max(tw, measure(c.type === 'progress' && !c.hasTarget ? c.pctFmt(tot / (c.scale100 ? 100 : 1)) : c.fmt(tot), FS, 600));
10549
+ const longest = Math.max(0, ...texts.map((t) => Math.max(0, ...String(t).split(/\s+/).map((w) => measure(w, FS, weight)))));
10550
+ const avgLen = texts.reduce((a, t) => a + String(t).length, 0) / Math.max(1, texts.length);
10551
+ c.lines = c.lines ?? (c.type === 'text' || c.type === 'link' ? (avgLen > 36 ? 3 : 2) : 1);
10552
+ c.valueW = tw;
10553
+ let nat, min;
10554
+ switch (c.type) {
10555
+ case 'bar': nat = tw + 8 + 120; min = tw + 8 + 36; break;
10556
+ case 'heat': nat = tw + 16; min = nat; break;
10557
+ case 'delta': nat = tw + 13; min = nat; break;
10558
+ case 'sparkline': nat = 104; min = 56; break;
10559
+ case 'progress': nat = tw + 8 + 96; min = tw + 8 + 40; break;
10560
+ case 'badge': { const pw = Math.max(0, ...texts.map((t) => measure(t, 12, 500) + 18)); nat = pw; min = Math.min(pw, 84); break; }
10561
+ case 'check': nat = 18; min = 18; break;
10562
+ case 'text': case 'link': nat = tw; min = Math.min(tw, Math.max(longest, Math.min(tw, 96))); break;
10563
+ default: nat = tw; min = tw;
10564
+ }
10565
+ const hw = measure(c.label, HS, 600) + (c.type === 'rank' ? 0 : 12); // + flecha de orden
10566
+ const hmin = Math.max(...String(c.label).split(/\s+/).map((w) => measure(w, HS, 600))) + 12;
10567
+ c.nat = Math.max(nat, Math.min(hw, Math.max(nat, hw))) + 2 * P;
10568
+ c.min = Math.max(min, Math.min(hmin, hw)) + 2 * P;
10569
+ if (Number.isFinite(+c.width) && +c.width > 0) { c.nat = c.min = +c.width; c.fixed = true; }
10570
+ c.minW = Math.min(c.min, c.nat);
10571
+ }
10572
+ const sumMin = cols.reduce((a, c) => a + c.minW, 0);
10573
+ const sumNat = cols.reduce((a, c) => a + c.nat, 0);
10574
+ const cardsOpt = spec.cards ?? spec.stacked ?? 'auto';
10575
+ const cards = cardsOpt === true || cardsOpt === 'always' || (cardsOpt !== false && cardsOpt !== 'never' && (sumMin > tableW + 0.5 || (tableW < 360 && cols.length > 3)));
10576
+
10577
+ if (!cards) {
10578
+ for (const c of cols) c.w = c.nat;
10579
+ if (sumNat <= tableW) {
10580
+ let extra = tableW - sumNat;
10581
+ const tracks = cols.filter((c) => !c.fixed && (c.type === 'bar' || c.type === 'progress'));
10582
+ for (const c of tracks) { const add = Math.min(extra / tracks.length, 110); c.w += add; }
10583
+ extra = tableW - cols.reduce((a, c) => a + c.w, 0);
10584
+ const flex = cols.filter((c) => !c.fixed && (c.type === 'text' || c.type === 'link' || c.isLabel));
10585
+ const share = flex.length ? flex : cols.filter((c) => !c.fixed);
10586
+ const base = share.reduce((a, c) => a + c.w, 0) || 1;
10587
+ for (const c of share) c.w += (extra * c.w) / base;
10588
+ } else {
10589
+ let need = sumNat - tableW;
10590
+ const ORDER = [['sparkline', 'bar', 'progress'], ['text', 'link'], null];
10591
+ for (const group of ORDER) {
10592
+ if (need <= 0) break;
10593
+ const take = cols.filter((c) => !c.fixed && (group ? group.includes(c.type) : true));
10594
+ const slack = take.reduce((a, c) => a + (c.w - c.minW), 0);
10595
+ if (slack <= 0) continue;
10596
+ const k = Math.min(1, need / slack);
10597
+ for (const c of take) { const d = (c.w - c.minW) * k; c.w -= d; need -= d; }
10598
+ }
10599
+ if (need > 0.5) { const k = tableW / cols.reduce((a, c) => a + c.w, 0); for (const c of cols) c.w *= k; }
10600
+ }
10601
+ let x = tableX;
10602
+ for (const c of cols) { c.x = x; x += c.w; }
10603
+ }
10604
+
10605
+ const body = [];
10606
+ const accent = theme.categorical[0];
10607
+ const negColor = theme.categorical[1] || theme.status.critical;
10608
+ const isDark = theme.mode === 'dark';
10609
+ const goodC = readable(theme.status.good, theme), badC = readable(theme.status.critical, theme);
10610
+ const ink = theme.ink;
10611
+ const dimC = mix(theme.muted, theme.surface, isDark ? 0.4 : 0.45);
10612
+ const baseline = (top) => top + padY + Math.round(FS * X * 0.96);
10613
+ const midLine = (top) => top + padY + LH / 2;
10614
+
10615
+ const pill = (x, cy, label, color, maxW, align = 'left') => {
10616
+ const bg = color ? mix(color, theme.surface, isDark ? 0.72 : 0.86) : theme.tint;
10617
+ const fg = color ? legible(isDark ? mix(color, '#FFFFFF', 0.25) : color, bg, isDark ? '#FFFFFF' : ink) : theme.lede;
10618
+ const t = truncate(label, maxW - 18, 12, 500);
10619
+ const w = Math.min(maxW, measure(t, 12, 500) + 18);
10620
+ const x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;
10621
+ return [
10622
+ el('rect', { x: x0, y: cy - pillH / 2, width: w, height: pillH, rx: pillH / 2, fill: bg }),
10623
+ color ? el('circle', { cx: x0 + 9, cy, r: 3, fill: fg }) : '',
10624
+ text(x0 + (color ? 16 : 9), cy + 4.2 * X, t, { fill: fg, fontSize: 12, fontWeight: 500 }),
10625
+ ];
10626
+ };
10627
+ const heatColor = (c, v) => {
10628
+ if (c.mid !== undefined) { const span = Math.max(c.hhi - c.mid, c.mid - c.hlo) || 1; return ramp(c.stops || theme.diverging, 0.5 + 0.85 * ((v - c.mid) / span / 2)); }
10629
+ const t = (v - c.hlo) / (c.hhi - c.hlo || 1);
10630
+ return c.stops ? ramp(c.stops, 0.05 + 0.75 * t) : ramp(theme.sequential.slice(0, -1), 0.04 + 0.72 * t);
10631
+ };
10632
+ const arrow = (x, cy, up, color) => el('path', { d: up ? `M${x},${cy + 3}L${x + 3.5},${cy - 3}L${x + 7},${cy + 3}Z` : `M${x},${cy - 3}L${x + 3.5},${cy + 3}L${x + 7},${cy - 3}Z`, fill: color, 'aria-hidden': 'true' });
10633
+ const deltaColor = (c, n) => (n === 0 ? theme.muted : (n > 0) === (c.good !== 'down') ? goodC : badC);
10634
+ const check = (cx, cy, yes) => yes
10635
+ ? el('path', { d: `M${cx - 4.5},${cy + 0.2}L${cx - 1.4},${cy + 3.3}L${cx + 4.8},${cy - 3.6}`, fill: 'none', stroke: goodC, strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' })
10636
+ : el('path', { d: `M${cx - 3.5},${cy}H${cx + 3.5}`, stroke: theme.muted, strokeWidth: 1.6, strokeLinecap: 'round' });
10637
+ const spark = (x, cy, w, h, vals, muted) => {
10638
+ if (vals.length < 2) return '';
10639
+ const mn = Math.min(...vals), mx = Math.max(...vals);
10640
+ const pts = vals.map((v, k) => [x + (k / (vals.length - 1)) * (w - 4) + 1, cy + h / 2 - ((v - mn) / (mx - mn || 1)) * h]);
10641
+ const d = pts.map(([a, b], k) => (k ? 'L' : 'M') + a.toFixed(1) + ',' + b.toFixed(1)).join('');
10642
+ const [lx, ly] = pts[pts.length - 1];
10643
+ return [
10644
+ el('path', { d: d + `L${lx.toFixed(1)},${(cy + h / 2).toFixed(1)}L${pts[0][0].toFixed(1)},${(cy + h / 2).toFixed(1)}Z`, fill: muted ? dimC : accent, fillOpacity: 0.08, stroke: 'none' }),
10645
+ el('path', { d, fill: 'none', stroke: muted ? dimC : theme.muted, strokeWidth: 1.5, strokeLinejoin: 'round', strokeLinecap: 'round' }),
10646
+ el('circle', { cx: lx, cy: ly, r: 2.6, fill: muted ? dimC : accent }),
10647
+ ];
10648
+ };
10649
+ const sparkTip = (c, vals) => (vals.length ? `${c.fmt(vals[0])} → ${c.fmt(vals[vals.length - 1])}` + (vals.length > 2 ? ` (min ${c.fmt(Math.min(...vals))}, max ${c.fmt(Math.max(...vals))})` : '') : '');
10650
+ const anyHL = hl && rows.some(isHL);
10651
+
10652
+ /** Contenido de una celda dentro de una caja (x, w) y una fila (top, alto). */
10653
+ function cell(c, r, x, w, top, h, opts = {}) {
10654
+ const out = [];
10655
+ const ix = x + P, iw = Math.max(4, w - 2 * P);
10656
+ const by = baseline(top), my = midLine(top);
10657
+ const v = c.type === 'rank' ? r.__rank : r[c.field];
10658
+ const weight = opts.total ? 600 : c.isLabel ? (opts.hl ? 600 : 500) : 400;
10659
+ const muted = anyHL && !opts.hl && !opts.total;
10660
+ const tx = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix;
10661
+ const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
10662
+ const put = (s, { x: px, ...attrs } = {}) => out.push(text(px ?? tx, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor, ...attrs }));
10663
+ if (opts.total && c.isLabel) { put(truncate(totalLabel, iw, FS, 600)); return { out }; }
10664
+ if (opts.total) {
10665
+ const tv = totalOf(c);
10666
+ if (tv === null) return { out };
10667
+ const ts = c.type === 'delta' ? (tv > 0 ? '+' : tv < 0 ? '−' : '') + c.fmt(Math.abs(tv)) : c.fmt(tv);
10668
+ if ((c.type === 'bar' || c.type === 'progress') && c.align === 'right') put(ts, { x: ix + c.valueW, textAnchor: 'end' });
10669
+ else put(ts);
10670
+ return { out };
10671
+ }
10672
+ if (c.type !== 'rank' && isNil(v)) { put('—', { fill: theme.muted, fontWeight: 400 }); return { out }; }
10673
+ const s = cellText(c, r, locale, W);
10674
+ switch (c.type) {
10675
+ case 'rank': put(s, { fill: theme.muted, fontWeight: 400 }); break;
10676
+ case 'bar': {
10677
+ const n = num(v);
10678
+ if (!Number.isFinite(n)) { put(s); break; }
10679
+ const vw = c.valueW;
10680
+ const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
10681
+ const span = c.hi - c.lo || 1;
10682
+ const z = trackX + (-c.lo / span) * trackW;
10683
+ const len = (Math.abs(n) / span) * trackW;
10684
+ const bh = Math.max(6, Math.min(12, Math.round(LH * 0.58)));
10685
+ const fill = muted ? dimC : n < 0 ? negColor : accent;
10686
+ out.push(text(ix + vw, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
10687
+ if (c.lo < 0) out.push(el('line', { x1: z, x2: z, y1: my - bh / 2 - 3, y2: my + bh / 2 + 3, stroke: theme.axis, strokeWidth: 1 }));
10688
+ if (len > 0.3) out.push(el('rect', { x: n < 0 ? z - len : z, y: my - bh / 2, width: Math.max(1.5, len), height: bh, rx: Math.min(2, len / 2), fill }));
10689
+ break;
10690
+ }
10691
+ case 'heat': {
10692
+ const n = num(v);
10693
+ if (!Number.isFinite(n)) { put(s); break; }
10694
+ const fill = heatColor(c, n);
10695
+ if (opts.chip) {
10696
+ const bw = Math.min(w - P, measure(s, FS, weight) + 16), right = x + w - P;
10697
+ out.push(el('rect', { x: right - bw, y: top + 5, width: bw, height: Math.max(0, h - 10), rx: 5, fill }));
10698
+ put(s, { fill: textOn(fill, theme), x: right - 8 });
10699
+ break;
10700
+ }
10701
+ out.push(el('rect', { x: x + 3, y: top + 4, width: Math.max(0, w - 6), height: Math.max(0, h - 8), rx: 5, fill }));
10702
+ put(s, { fill: textOn(fill, theme) });
10703
+ break;
10704
+ }
10705
+ case 'delta': {
10706
+ const n = num(v);
10707
+ if (!Number.isFinite(n)) { put(s); break; }
10708
+ const color = deltaColor(c, n);
10709
+ const tw = measure(s, FS, 500);
10710
+ const right = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 + (tw + 11) / 2 : ix + tw + 11;
10711
+ out.push(text(right, by, s, { fill: color, fontSize: FS, fontWeight: 500, textAnchor: 'end' }));
10712
+ if (n !== 0) out.push(arrow(right - tw - 11, my, n > 0, color));
10713
+ break;
10714
+ }
10715
+ case 'progress': {
10716
+ const n = num(v);
10717
+ if (!Number.isFinite(n)) { put(s); break; }
10718
+ const t = c.hasTarget ? c.targetOf(r) : c.scale100 ? 100 : 1;
10719
+ const ratio = Number.isFinite(t) && t ? n / t : NaN;
10720
+ const vw = c.valueW;
10721
+ const shown = c.hasTarget && Number.isFinite(ratio) ? c.pctFmt(ratio) : s;
10722
+ out.push(text(ix + vw, by, shown, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
10723
+ const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
10724
+ out.push(el('rect', { x: trackX, y: my - 3, width: trackW, height: 6, rx: 3, fill: theme.tint2 }));
10725
+ if (Number.isFinite(ratio) && ratio > 0) {
10726
+ const goodUp = c.good !== 'down';
10727
+ const fill = muted ? dimC : goodUp ? (ratio >= 1 ? theme.status.good : accent) : ratio > 1 ? theme.status.critical : theme.status.good;
10728
+ out.push(el('rect', { x: trackX, y: my - 3, width: Math.max(6, trackW * Math.min(1, ratio)), height: 6, rx: 3, fill }));
10729
+ }
10730
+ return { out, tip: c.hasTarget && Number.isFinite(t) ? `${c.label}: ${c.fmt(n)} / ${c.fmt(t)} (${c.pctFmt(ratio)})` : null };
10731
+ }
10732
+ case 'sparkline': {
10733
+ const vals = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : [];
10734
+ if (vals.length < 2) { put(vals.length ? c.fmt(vals[0]) : '—', { fill: theme.muted }); break; }
10735
+ const sw = Math.min(iw, 120), sh = Math.max(10, Math.min(18, h - 2 * padY - 2));
10736
+ const sx = c.align === 'right' ? x + w - P - sw : c.align === 'center' ? x + (w - sw) / 2 : ix;
10737
+ out.push(spark(sx, my, sw, sh, vals, muted));
10738
+ return { out, tip: `${c.label}: ${sparkTip(c, vals)}` };
10739
+ }
10740
+ case 'badge': {
10741
+ const color = c.badge.get(String(v));
10742
+ out.push(...pill(c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix, my, s, color, iw, c.align));
10743
+ return { out, tip: measure(s, 12, 500) + 18 > iw ? s : null };
10744
+ }
10745
+ case 'check': {
10746
+ const yes = v === true || YES.test(String(v).trim());
10747
+ const cx = c.align === 'right' ? x + w - P - 5 : c.align === 'center' ? x + w / 2 : ix + 5;
10748
+ out.push(g({ role: 'img', 'aria-label': s }, [check(cx, my, yes), el('title', {}, esc(s))]));
10749
+ break;
10750
+ }
10751
+ case 'link': {
10752
+ const href = String(v).trim();
10753
+ const lines = wrap(s, iw, FS, 400, c.lines);
10754
+ const color = readable(accent, theme);
10755
+ const t = lines.map((l, k) => text(tx, by + k * LH, l, { fill: color, fontSize: FS, textAnchor: anchor, textDecoration: 'underline' })).join('');
10756
+ out.push(SAFE_URL.test(href) ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, t) : t);
10757
+ return { out, lines: lines.length, tip: lines.join(' ') !== s ? s : null };
10758
+ }
10759
+ case 'text': case 'date': default: {
10760
+ const lines = c.type === 'text' ? wrap(s, iw, FS, weight, c.lines) : [truncate(s, iw, FS, weight)];
10761
+ lines.forEach((l, k) => out.push(text(tx, by + k * LH, l, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor })));
10762
+ return { out, lines: lines.length, tip: lines.join(' ').replace(/…$/, '') !== s ? s : null };
10763
+ }
10764
+ }
10765
+ return { out };
10766
+ }
10767
+
10768
+ const groupCounts = new Map();
10769
+ if (groupF) for (const r of rows) groupCounts.set(String(r[groupF] ?? ''), (groupCounts.get(String(r[groupF] ?? '')) || 0) + 1);
10770
+ const groupH = Math.round(LH + 12 * density + 4);
10771
+ const groupRow = (name, top) => {
10772
+ const n = groupCounts.get(name) || 0;
10773
+ const lab = truncate(name || '—', area.w - 60, 12.5, 600);
10774
+ return g({ class: 'pa-tgroup', role: 'row' }, [
10775
+ el('rect', { x: tableX, y: top + 4, width: tableW, height: groupH - 6, rx: 6, fill: theme.tint }),
10776
+ text(area.x, top + 4 + (groupH - 6) / 2 + 4.3 * X, lab, { fill: ink, fontSize: 12.5, fontWeight: 600, role: 'rowheader' }),
10777
+ text(area.x + measure(lab, 12.5, 600) + 8, top + 4 + (groupH - 6) / 2 + 4.3 * X, String(n), { fill: theme.muted, fontSize: 12 }),
10778
+ ]);
10779
+ };
10780
+
10781
+ const tableRows = [];
10782
+ let y = area.y;
10783
+ if (spec._search) {
10784
+ body.push(el('rect', { class: 'pa-search-slot', x: area.x, y, width: Math.min(area.w, 300), height: Math.round(32 * Math.max(1, 0.6 + 0.4 * X)), fill: 'transparent' }));
10785
+ y += Math.round(32 * Math.max(1, 0.6 + 0.4 * X)) + 12;
10786
+ }
10787
+ const label = labelF !== undefined ? cols.find((c) => c.field === labelF) : null;
10788
+ const rowAttrs = (r, i) => ({ class: 'pa-tr' + (isHL(r) ? ' pa-tr-hl' : ''), role: 'row', 'aria-rowindex': (page - 1) * (pageSize === Infinity ? 0 : pageSize) + i + 2, 'data-row': r.__i });
10789
+
10790
+ if (!cards) {
10791
+ const HLH = Math.round(HS * X * 1.3);
10792
+ const headLines = cols.map((c) => wrap(c.label, Math.max(10, c.w - 2 * P - (c.type === 'rank' ? 0 : 10)), HS, 600, 2));
10793
+ const nHead = Math.max(1, ...headLines.map((l) => l.length));
10794
+ const headH = nHead * HLH + Math.round(10 * Math.max(0.8, density)) + 4;
10795
+ const heads = cols.map((c, i) => {
10796
+ const lines = headLines[i];
10797
+ const sorted = st && st.col === c;
10798
+ const overValue = (c.type === 'bar' || c.type === 'progress') && c.align === 'right';
10799
+ const headW = Math.max(...lines.map((l) => measure(l, HS, 600)));
10800
+ const ax = overValue ? Math.min(c.x + c.w - P, c.x + P + Math.max(c.valueW, headW + (sorted ? 10 : 0))) : c.align === 'right' ? c.x + c.w - P : c.align === 'center' ? c.x + c.w / 2 : c.x + P;
10801
+ const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
10802
+ const y0 = y + headH - 9 - (lines.length - 1) * HLH;
10803
+ const parts = [el('rect', { class: 'pa-thbg', x: c.x, y, width: c.w, height: headH, fill: 'transparent' })];
10804
+ lines.forEach((l, k) => parts.push(text(ax, y0 + k * HLH, l, { fill: sorted ? ink : theme.lede, fontSize: HS, fontWeight: 600, textAnchor: anchor })));
10805
+ const lw = measure(lines[lines.length - 1], HS, 600);
10806
+ const ly = y0 + (lines.length - 1) * HLH - HS * X * 0.36;
10807
+ const axx = c.align === 'right' ? ax - lw - 9 : c.align === 'center' ? ax + lw / 2 + 3 : ax + lw + 3;
10808
+ if (c.type !== 'rank' && c.sortable !== false) {
10809
+ const up = sorted ? st.dir === 'asc' : false;
10810
+ const d = up ? `M${axx},${ly + 2}L${axx + 3},${ly - 2}L${axx + 6},${ly + 2}Z` : `M${axx},${ly - 2}L${axx + 3},${ly + 2}L${axx + 6},${ly - 2}Z`;
10811
+ parts.push(el('path', { class: sorted ? 'pa-sort-on' : 'pa-sorthint', d, fill: sorted ? ink : theme.muted }));
10812
+ }
10813
+ const attrs = { class: 'pa-th', role: 'columnheader' };
10814
+ if (c.type !== 'rank' && c.sortable !== false && spec.sortable !== false) {
10815
+ attrs['data-sort'] = c.field;
10816
+ attrs['aria-sort'] = sorted ? (st.dir === 'asc' ? 'ascending' : 'descending') : 'none';
10817
+ attrs['data-num'] = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(c.type) ? '1' : null;
10818
+ }
10819
+ return g(attrs, parts);
10820
+ });
10821
+ tableRows.push(g({ class: 'pa-thead', role: 'row', 'aria-rowindex': 1 }, heads));
10822
+ y += headH;
10823
+ tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: theme.axis, strokeWidth: 1 }));
10824
+
10825
+ let lastGroup = null;
10826
+ if (!pageRows.length) {
10827
+ tableRows.push(text(area.x + area.w / 2, y + 30, query ? W.none : '—', { fill: theme.muted, fontSize: FS, textAnchor: 'middle' }));
10828
+ y += 48;
10829
+ }
10830
+ pageRows.forEach((r, i) => {
10831
+ if (groupF) {
10832
+ const gname = String(r[groupF] ?? '');
10833
+ if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
10834
+ }
10835
+ const h0 = LH + 2 * padY;
10836
+ const made = cols.map((c) => cell(c, r, c.x, c.w, y, h0, { hl: isHL(r) }));
10837
+ const nLines = Math.max(1, ...made.map((m) => m.lines || 1));
10838
+ const h = h0 + (nLines - 1) * LH;
10839
+ const made2 = nLines > 1 ? cols.map((c, k) => (c.type === 'heat' ? cell(c, r, c.x, c.w, y, h, { hl: isHL(r) }) : made[k])) : made;
10840
+ const zebra = spec.striped && i % 2 === 1;
10841
+ const parts = [el('rect', { class: 'pa-trbg', x: tableX, y: y + 1, width: tableW, height: h - 2, rx: 6, fill: isHL(r) ? theme.tint : zebra ? theme.tint : 'transparent', fillOpacity: zebra && !isHL(r) ? 0.55 : null })];
10842
+ if (isHL(r)) parts.push(el('rect', { x: tableX, y: y + 5, width: 3, height: h - 10, rx: 1.5, fill: accent }));
10843
+ made2.forEach((m, k) => {
10844
+ const c = cols[k];
10845
+ parts.push(g({ role: c.isLabel ? 'rowheader' : 'cell', 'data-tip': m.tip ? tip(label ? String(r[labelF] ?? '') : '', m.tip) : null }, m.out));
10846
+ });
10847
+ tableRows.push(g(rowAttrs(r, i), parts));
10848
+ y += h;
10849
+ if (i < pageRows.length - 1 || hasTotal) tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: theme.grid, strokeWidth: 1 }));
10850
+ });
10851
+ if (hasTotal) {
10852
+ const h = LH + 2 * padY + 2;
10853
+ tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y + 0.5, y2: y + 0.5, stroke: theme.axis, strokeWidth: 1.25 }));
10854
+ const firstText = cols.find((c) => c.isLabel) || cols.find((c) => c.type === 'text') || cols[0];
10855
+ const made = cols.map((c) => (c === firstText ? cell({ ...c, isLabel: true }, {}, c.x, c.w, y + 2, h, { total: true }) : cell(c, {}, c.x, c.w, y + 2, h, { total: true })));
10856
+ tableRows.push(g({ class: 'pa-ttotal', role: 'row' }, made.map((m, k) => g({ role: cols[k] === firstText ? 'rowheader' : 'cell' }, m.out))));
10857
+ y += h + 2;
10858
+ }
10859
+ } else {
10860
+ const labelCol = label || cols.find((c) => c.type === 'text') || null;
10861
+ const rankCol = cols.find((c) => c.type === 'rank');
10862
+ const mainCol = cols.find((c) => c.type === 'bar');
10863
+ const rest = cols.filter((c) => c !== labelCol && c !== rankCol && c !== mainCol);
10864
+ const KL = 12.5; // tamaño de las etiquetas
10865
+ const lineH = Math.round(Math.max(LH, pillH) + 6);
10866
+ const CP = 12;
10867
+ const keyW = Math.min(area.w * 0.46, Math.max(60, ...rest.map((c) => measure(c.label, KL) + 12)));
10868
+ if (!pageRows.length) { body.push(text(area.x + area.w / 2, y + 30, query ? W.none : '—', { fill: theme.muted, fontSize: FS, textAnchor: 'middle' })); y += 48; }
10869
+ let lastGroup = null;
10870
+ const cardOf = (r, i, total = false) => {
10871
+ const top = y;
10872
+ const parts = [];
10873
+ const hlr = !total && isHL(r);
10874
+ const muted = anyHL && !hlr && !total;
10875
+ let cy = top + CP;
10876
+ const rankW = rankCol && !total ? measure(String(r.__rank), 12) + 8 : 0;
10877
+ const mainS = mainCol ? (total ? (totalOf(mainCol) !== null ? mainCol.fmt(totalOf(mainCol)) : '') : cellText(mainCol, r, locale, W)) : '';
10878
+ const mainW = mainS ? measure(mainS, 14, 600) + 12 : 0;
10879
+ const titleS = total ? totalLabel : labelCol ? cellText(labelCol, r, locale, W) || '—' : `#${r.__rank}`;
10880
+ const tl = wrap(titleS, area.w - rankW - mainW, 14, 600, 2);
10881
+ const TLH = Math.round(14 * X * 1.3);
10882
+ if (rankW) parts.push(text(area.x, cy + 14 * X * 0.92, String(r.__rank), { fill: theme.muted, fontSize: 12 }));
10883
+ tl.forEach((l, k) => parts.push(text(area.x + rankW, cy + 14 * X * 0.92 + k * TLH, l, { fill: ink, fontSize: 14, fontWeight: 600, role: k ? null : 'rowheader' })));
10884
+ if (mainS) parts.push(text(area.x + area.w, cy + 14 * X * 0.92, mainS, { fill: ink, fontSize: 14, fontWeight: 600, textAnchor: 'end' }));
10885
+ cy += tl.length * TLH;
10886
+ if (mainCol && !total) {
10887
+ const n = num(r[mainCol.field]);
10888
+ if (Number.isFinite(n)) {
10889
+ const span = mainCol.hi - mainCol.lo || 1;
10890
+ const z = area.x + (-mainCol.lo / span) * area.w;
10891
+ const len = (Math.abs(n) / span) * area.w;
10892
+ cy += 5;
10893
+ parts.push(el('rect', { x: area.x, y: cy, width: area.w, height: 5, rx: 2.5, fill: theme.tint }));
10894
+ if (len > 0.3) parts.push(el('rect', { x: n < 0 ? z - len : z, y: cy, width: Math.max(2, len), height: 5, rx: 2.5, fill: muted ? dimC : n < 0 ? negColor : accent }));
10895
+ cy += 5;
10896
+ }
10897
+ }
10898
+ cy += 6;
10899
+ for (const c of rest) {
10900
+ if (total && totalOf(c) === null) continue;
10901
+ const long = c.type === 'text' && !total && measure(cellText(c, r, locale, W), FS) > area.w - keyW;
10902
+ const rowTop = cy + (lineH - (LH + 2 * padY)) / 2;
10903
+ parts.push(text(area.x, baseline(rowTop), truncate(c.label, long ? area.w : keyW - 8, KL), { fill: theme.lede, fontSize: KL }));
10904
+ if (long) {
10905
+ const lines = wrap(cellText(c, r, locale, W), area.w, FS, 400, 4);
10906
+ lines.forEach((l, k) => parts.push(text(area.x, cy + lineH + LH * (k + 0.75), l, { fill: ink, fontSize: FS })));
10907
+ cy += lineH + lines.length * LH + 2;
10908
+ continue;
10909
+ }
10910
+ const cc = { ...c, align: 'right' };
10911
+ const boxW = cc.type === 'progress' || cc.type === 'bar' ? Math.min(area.w - keyW, cc.valueW + 8 + 110) : area.w - keyW;
10912
+ const vx = area.x + area.w - boxW - P, vw = boxW + 2 * P;
10913
+ const m = cell(cc, r, vx, vw, rowTop, LH + 2 * padY, { total, hl: hlr, chip: true });
10914
+ parts.push(g({ role: 'cell', 'data-tip': m.tip ? tip(titleS, m.tip) : null }, m.out));
10915
+ cy += lineH;
10916
+ }
10917
+ cy += CP - 4;
10918
+ const h = cy - top;
10919
+ const bg = el('rect', { class: 'pa-trbg', x: tableX, y: top + 2, width: tableW, height: h - 4, rx: 8, fill: hlr ? theme.tint : 'transparent' });
10920
+ const mark = hlr ? el('rect', { x: tableX, y: top + 8, width: 3, height: h - 16, rx: 1.5, fill: accent }) : '';
10921
+ y = cy;
10922
+ return g(total ? { class: 'pa-ttotal', role: 'row' } : rowAttrs(r, i), [bg, mark, ...parts]);
10923
+ };
10924
+ pageRows.forEach((r, i) => {
10925
+ if (groupF) {
10926
+ const gname = String(r[groupF] ?? '');
10927
+ if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
10928
+ }
10929
+ tableRows.push(cardOf(r, i));
10930
+ if (i < pageRows.length - 1 || hasTotal) tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: hasTotal && i === pageRows.length - 1 ? theme.axis : theme.grid, strokeWidth: hasTotal && i === pageRows.length - 1 ? 1.25 : 1 }));
10931
+ });
10932
+ if (hasTotal) tableRows.push(cardOf({}, -1, true));
10933
+ }
10934
+
10935
+ const colCount = cols.length;
10936
+ body.push(g({ class: 'pa-table' + (cards ? ' pa-cards' : ''), role: 'table', 'aria-label': spec.title || W.table, 'aria-rowcount': nRows + 1, 'aria-colcount': colCount, style: 'font-variant-numeric:tabular-nums' }, tableRows));
10937
+
10938
+ if (pages > 1) {
10939
+ y += 14;
10940
+ const B = Math.round(28 * Math.max(1, 0.55 + 0.45 * X));
10941
+ const cy = y + B / 2;
10942
+ const from = (page - 1) * pageSize + 1, to = Math.min(nRows, page * pageSize);
10943
+ const fmtI = makeFormat({ type: 'number', decimals: 0 }, locale, []);
10944
+ const rangeS = `${fmtI(from)}–${fmtI(to)} / ${fmtI(nRows)}`;
10945
+ const items = pagerItems(page, pages);
10946
+ const rangeW = measure(rangeS, 12);
10947
+ const btnW = (s) => Math.max(B, measure(s, 12.5, 600) + 14);
10948
+ const full = items.reduce((a, it) => a + (it === '…' ? 14 : btnW(fmtI(it)) + 2), 0) + 2 * (B + 2);
10949
+ const compact = full + rangeW + 16 > area.w;
10950
+ const parts = [];
10951
+ if (!compact || rangeW + 16 + 2 * B + measure(`${page} / ${pages}`, 12.5, 600) + 16 < area.w) {
10952
+ parts.push(text(area.x, cy + 4.2 * X, rangeS, { fill: theme.muted, fontSize: 12 }));
10953
+ }
10954
+ let x = area.x + area.w;
10955
+ const btn = (n, content, label, opts = {}) => {
10956
+ const w = opts.w ?? B;
10957
+ x -= w;
10958
+ const disabled = n === null;
10959
+ const attrs = disabled ? { class: 'pa-pg pa-pg-off', opacity: 0.32, 'aria-hidden': 'true' } : { class: 'pa-pg' + (opts.current ? ' pa-pg-on' : ''), 'data-page': n, 'data-arrow': opts.arrow, 'aria-label': label, 'aria-current': opts.current ? 'page' : null };
10960
+ const out = g(attrs, [el('rect', { class: 'pa-pgbg', x, y, width: w, height: B, rx: 7, fill: opts.current ? theme.tint2 : 'transparent' }), content(x, w)]);
10961
+ x -= 2;
10962
+ return out;
10963
+ };
10964
+ const chev = (dir) => (x0, w) => el('path', { d: dir < 0 ? `M${x0 + w / 2 + 2.5},${cy - 5}L${x0 + w / 2 - 2.5},${cy}L${x0 + w / 2 + 2.5},${cy + 5}` : `M${x0 + w / 2 - 2.5},${cy - 5}L${x0 + w / 2 + 2.5},${cy}L${x0 + w / 2 - 2.5},${cy + 5}`, fill: 'none', stroke: ink, strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' });
10965
+ const num0 = (s, cur) => (x0, w) => text(x0 + w / 2, cy + 4.3 * X, s, { fill: cur ? ink : theme.lede, fontSize: 12.5, fontWeight: cur ? 600 : 400, textAnchor: 'middle' });
10966
+ const out = [];
10967
+ out.push(btn(page < pages ? page + 1 : null, chev(1), `${W.next} (${W.page} ${page + 1})`, { arrow: 'next' }));
10968
+ if (compact) {
10969
+ const s = `${fmtI(page)} / ${fmtI(pages)}`;
10970
+ const w = measure(s, 12.5, 600) + 16;
10971
+ x -= w;
10972
+ out.push(text(x + w / 2, cy + 4.3 * X, s, { fill: ink, fontSize: 12.5, fontWeight: 600, textAnchor: 'middle' }));
10973
+ x -= 2;
10974
+ } else {
10975
+ for (const it of [...items].reverse()) {
10976
+ if (it === '…') { x -= 14; out.push(text(x + 7, cy + 4.3 * X, '…', { fill: theme.muted, fontSize: 12.5, textAnchor: 'middle' })); x -= 2; continue; }
10977
+ const s = fmtI(it);
10978
+ out.push(btn(it, num0(s, it === page), `${W.page} ${it}`, { current: it === page, w: btnW(s) }));
10979
+ }
10980
+ }
10981
+ out.push(btn(page > 1 ? page - 1 : null, chev(-1), `${W.prev} (${W.page} ${page - 1})`, { arrow: 'prev' }));
10982
+ parts.push(...out);
10983
+ body.push(g({ class: 'pa-pager', role: 'navigation', 'aria-label': `${W.page} ${page} ${W.of} ${pages}` }, parts));
10984
+ y += B;
10985
+ }
10986
+ if (spec.height === undefined) frame.setHeight(y + 12 + frame.chrome);
10987
+
10988
+ const desc = describeTable({ lang, cols, rows, nRows, page, pages, st, label, query });
10989
+ const css = `.pa-live .pa-th[data-sort]{cursor:pointer}.pa-sorthint{opacity:0;transition:opacity .15s}.pa-live .pa-th[data-sort]:hover .pa-sorthint,.pa-live .pa-th[data-sort]:focus-visible .pa-sorthint{opacity:.7}`
10990
+ + `.pa-live .pa-th[data-sort]:hover .pa-thbg{fill:${theme.tint};fill-opacity:.6}.pa-tr .pa-trbg{transition:fill .12s}.pa-live .pa-tr:hover .pa-trbg{fill:${theme.tint}}`
10991
+ + `.pa-live .pa-pg{cursor:pointer}.pa-live .pa-pg:hover .pa-pgbg{fill:${theme.tint}}.pa-live .pa-pg-on:hover .pa-pgbg{fill:${theme.tint2}}`
10992
+ + `.pa-th:focus-visible,.pa-pg:focus-visible{outline:2px solid ${accent};outline-offset:-2px}@media (prefers-reduced-motion:reduce){.pa-sorthint,.pa-tr .pa-trbg{transition:none}}`;
10993
+ return frame.finish(body, { desc, type: 'table', role: 'figure', css });
10994
+ }
10995
+
10996
+ /** Botones de página: 1 … 4 5 6 … 20 (como mucho 7 entradas). */
10997
+ function pagerItems(page, pages) {
10998
+ if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
10999
+ if (page <= 4) return [1, 2, 3, 4, 5, '…', pages];
11000
+ if (page >= pages - 3) return [1, '…', pages - 4, pages - 3, pages - 2, pages - 1, pages];
11001
+ return [1, '…', page - 1, page, page + 1, '…', pages];
11002
+ }
11003
+
11004
+ function describeTable({ lang, cols, rows, nRows, page, pages, st, label, query }) {
11005
+ const T = {
11006
+ es: { t: 'Tabla de', and: 'y', r: 'filas', c: 'columnas', p: 'Página', of: 'de', s: 'Ordenada por', desc: 'de mayor a menor', asc: 'de menor a mayor', hi: 'Mayor', lo: 'menor', q: 'Filtrada por' },
11007
+ en: { t: 'Table with', and: 'and', r: 'rows', c: 'columns', p: 'Page', of: 'of', s: 'Sorted by', desc: 'high to low', asc: 'low to high', hi: 'Highest', lo: 'lowest', q: 'Filtered by' },
11008
+ pt: { t: 'Tabela com', and: 'e', r: 'linhas', c: 'colunas', p: 'Página', of: 'de', s: 'Ordenada por', desc: 'do maior para o menor', asc: 'do menor para o maior', hi: 'Maior', lo: 'menor', q: 'Filtrada por' },
11009
+ fr: { t: 'Tableau de', and: 'et', r: 'lignes', c: 'colonnes', p: 'Page', of: 'sur', s: 'Trié par', desc: 'décroissant', asc: 'croissant', hi: 'Maximum', lo: 'minimum', q: 'Filtré par' },
11010
+ de: { t: 'Tabelle mit', and: 'und', r: 'Zeilen', c: 'Spalten', p: 'Seite', of: 'von', s: 'Sortiert nach', desc: 'absteigend', asc: 'aufsteigend', hi: 'Höchster Wert', lo: 'niedrigster', q: 'Gefiltert nach' },
11011
+ }[lang];
11012
+ const names = cols.filter((c) => c.type !== 'rank').map((c) => c.label);
11013
+ let s = `${T.t} ${nRows} ${T.r} ${T.and} ${names.length} ${T.c}: ${names.join(', ')}.`;
11014
+ if (query) s += ` ${T.q} "${query}".`;
11015
+ if (pages > 1) s += ` ${T.p} ${page} ${T.of} ${pages}.`;
11016
+ if (st) s += ` ${T.s} ${st.col.label}, ${st.dir === 'desc' ? T.desc : T.asc}.`;
11017
+ const m = cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat'].includes(c.type) && !c.isLabel && c.kind === 'number');
11018
+ if (m && label && rows.length > 1) {
11019
+ let best = null, worst = null;
11020
+ for (const r of rows) { const v = num(r[m.field]); if (!Number.isFinite(v)) continue; if (!best || v > best[1]) best = [r, v]; if (!worst || v < worst[1]) worst = [r, v]; }
11021
+ if (best && worst && best[0] !== worst[0]) s += ` ${T.hi} ${m.label}: ${best[0][label.field]} (${m.fmt(best[1])}); ${T.lo}: ${worst[0][label.field]} (${m.fmt(worst[1])}).`;
11022
+ }
11023
+ return s;
11024
+ }
11025
+
11026
+ return { "sortState": sortState, "table": table, "pagerItems": pagerItems, "COLUMN_TYPES": COLUMN_TYPES, "columnType": columnType, "statusOf": statusOf };
11027
+ });
9487
11028
  __def("src/facet.js", function () {
9488
11029
  const { el, g } = __req("src/svg.js");
9489
11030
  const { createFrame } = __req("src/frame.js");
@@ -9515,8 +11056,8 @@ function facet(spec, theme, renderOne) {
9515
11056
  const frame = createFrame({ ...spec, height: 200 }, theme, { width: 720 });
9516
11057
  const area = frame.layout(legend);
9517
11058
  const gap = spec.panelGap ?? 14;
9518
- const fit = Math.max(1, Math.floor((area.w + gap) / 150));
9519
- const want = spec.columns ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
11059
+ const fit = Math.max(1, Math.floor((area.w + gap) / 240));
11060
+ const want = (typeof spec.columns === 'number' ? spec.columns : undefined) ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
9520
11061
  const cols = Math.max(1, Math.min(keys.length, want, fit));
9521
11062
  const pw = (area.w - gap * (cols - 1)) / cols;
9522
11063
  const ph = spec.panelHeight ?? Math.round(Math.min(300, Math.max(160, pw * 0.66)));
@@ -9568,7 +11109,7 @@ return { "facet": facet };
9568
11109
  __def("src/infer.js", function () {
9569
11110
  const { fieldsOf, fieldKind } = __req("src/data.js");
9570
11111
 
9571
- const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap'];
11112
+ const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap', 'table', 'corr', 'pairplot'];
9572
11113
 
9573
11114
  const ALIASES = {
9574
11115
  barplot: ['bar', { ci: true }], countplot: 'count', columns: 'column', lineplot: 'line', areaplot: 'area',
@@ -9582,6 +11123,11 @@ const ALIASES = {
9582
11123
  map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
9583
11124
  words: 'wordcloud', 'word-cloud': 'wordcloud', wordclouds: 'wordcloud', tagcloud: 'wordcloud', 'tag-cloud': 'wordcloud', nube: 'wordcloud', 'nube-de-palabras': 'wordcloud', cloud: 'wordcloud',
9584
11125
  timeline: 'gantt', roadmap: 'gantt', schedule: 'gantt', cronograma: 'gantt', 'carta-gantt': 'gantt', ganttchart: 'gantt', 'gantt-chart': 'gantt', planning: 'gantt',
11126
+ tabla: 'table', tables: 'table', datatable: 'table', 'data-table': 'table', grid: 'table', tableau: 'table', tabelle: 'table', tabela: 'table', spreadsheet: 'table', planilla: 'table',
11127
+ correlation: 'corr', correlations: 'corr', corrplot: 'corr', corrmatrix: 'corr', 'corr-matrix': 'corr', 'correlation-matrix': 'corr', correlogram: 'corr', correlograma: 'corr',
11128
+ correlacion: 'corr', 'correlación': 'corr', 'matriz-correlacion': 'corr', 'matriz-de-correlacion': 'corr', 'matriz-de-correlación': 'corr',
11129
+ clustermap: ['corr', { cluster: true }], 'cluster-map': ['corr', { cluster: true }],
11130
+ pairs: 'pairplot', pairgrid: 'pairplot', splom: 'pairplot', scattermatrix: 'pairplot', 'scatter-matrix': 'pairplot', 'scatterplot-matrix': 'pairplot', 'matriz-de-dispersion': 'pairplot', 'matriz-de-dispersión': 'pairplot',
9585
11131
  tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
9586
11132
  };
9587
11133
 
@@ -9595,6 +11141,7 @@ function normalizeType(t) {
9595
11141
 
9596
11142
  function inferType(spec, data = []) {
9597
11143
  if (spec.items) return 'kpi';
11144
+ if (Array.isArray(spec.columns) || spec.pageSize !== undefined) return 'table';
9598
11145
  if (spec.geo) return 'choropleth';
9599
11146
  if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
9600
11147
  { const f = fieldsOf(data); if (f.some((k) => /^(lat|latitude|latitud)$/i.test(k)) && f.some((k) => /^(lon|lng|long|longitude|longitud)$/i.test(k))) return 'pointmap'; }
@@ -9605,6 +11152,8 @@ function inferType(spec, data = []) {
9605
11152
  if (spec.matrix) return 'chord';
9606
11153
  if (spec.from || spec.to) return 'sankey';
9607
11154
  if (spec.path || spec.group) return 'treemap';
11155
+ if (spec.triangle !== undefined || spec.cluster !== undefined || spec.method !== undefined) return 'corr';
11156
+ if (spec.diag !== undefined || spec.corner !== undefined || spec.upper !== undefined) return 'pairplot';
9608
11157
  if (spec.size) return 'bubble';
9609
11158
  const fields = fieldsOf(data);
9610
11159
  if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
@@ -9632,14 +11181,26 @@ const { themeFor, themes, palettes, ramps } = __req("src/theme.js");
9632
11181
  const { resolveColorScale, colorScaleNames, RAINBOW } = __req("src/colorscales.js");
9633
11182
  const { schema } = __req("src/schema.js");
9634
11183
  const { parseDate } = __req("src/format.js");
11184
+ const { autoLocale } = __req("src/i18n.js");
11185
+ const { COLUMN_TYPES, columnType } = __req("src/charts/table.js");
9635
11186
 
9636
11187
  /** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
9637
11188
  let RENDER_PROBE = null;
9638
11189
  const _setProbe = (fn) => { RENDER_PROBE = fn; };
9639
11190
 
11191
+ /** Nunca lanza: si la revisión misma falla con un spec raro, lo dice como aviso y sigue. */
9640
11192
  function lint(spec = {}) {
11193
+ try { return lintSpec(spec); }
11194
+ catch (e) {
11195
+ const type = spec && typeof spec === 'object' && typeof spec.type === 'string' ? spec.type : 'auto';
11196
+ return { ok: true, type, errors: [], warnings: [{ code: 'lint-failed', message: `No se pudo revisar el spec por completo: ${String(e && e.message).split('\n')[0]}`, fix: 'Revisa que cada opción tenga el tipo del schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }], suggestions: [] };
11197
+ }
11198
+ }
11199
+
11200
+ function lintSpec(spec = {}) {
9641
11201
  if (!spec || typeof spec !== 'object') spec = {};
9642
- const en = String(spec.locale || '').toLowerCase().startsWith('en');
11202
+ const autoLang = spec.locale === 'auto' || (spec.locale === undefined && /^(table|tabla|tables|datatable|data-table|grid)$/i.test(String(spec.type || '')));
11203
+ const en = (autoLang ? autoLocale(spec, Array.isArray(spec.data) ? spec.data : []) : String(spec.locale || '')).toLowerCase().startsWith('en');
9643
11204
  /** Mensajes nuevos: se escriben directo en ambos idiomas. */
9644
11205
  const L = (es, english) => (en ? english : es);
9645
11206
  const errors = [];
@@ -9652,7 +11213,7 @@ function lint(spec = {}) {
9652
11213
  let data = [];
9653
11214
  const rawWords = !spec.data && (typeof spec.text === 'string' || Array.isArray(spec.text));
9654
11215
  try { data = spec.type === 'kpi' || spec.items || rawWords ? [] : ensureData(spec); } catch (e) { err('data', e.message.split('\n')[0], 'Pasa `data` como arreglo de filas: [{ categoria: "A", valor: 10 }]'); }
9655
- const type = spec.type && spec.type !== 'auto' ? normalizeType(spec.type)[0] : inferType(spec, data);
11216
+ const type = spec.type && spec.type !== 'auto' ? normalizeType(String(spec.type).trim())[0] : inferType(spec, data);
9656
11217
  if (!KNOWN_TYPES.has(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${[...KNOWN_TYPES].join(', ')}`);
9657
11218
  const fields = fieldsOf(data);
9658
11219
  const check = (f, role) => {
@@ -9665,7 +11226,7 @@ function lint(spec = {}) {
9665
11226
  } else if (type !== 'kpi' && !rawWords && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
9666
11227
 
9667
11228
  if (!spec.title) sug('title', 'Falta el título.', 'Escribe el hallazgo como una frase completa: "Biobío concentra el 32% de las ventas", no "Ventas por región".');
9668
- else if (!/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
11229
+ else if (typeof spec.title === 'string' && !/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
9669
11230
  if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
9670
11231
 
9671
11232
  const series = spec.series ?? spec.color;
@@ -9734,6 +11295,26 @@ function lint(spec = {}) {
9734
11295
  for (const f of [spec.x, spec.y]) if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: la regresión necesita dos medidas.`, 'Usa `type: "box"` para comparar una medida entre categorías.');
9735
11296
  if (data.length < 8) warn('few-obs', `Solo ${data.length} puntos: la recta y su intervalo son poco confiables.`, 'Muestra los puntos con `type: "scatter"`.');
9736
11297
  }
11298
+ if ((type === 'corr' || type === 'pairplot') && data.length) {
11299
+ const longForm = type === 'corr' && typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
11300
+ if (!longForm) {
11301
+ const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
11302
+ if (spec.vars !== undefined && !Array.isArray(spec.vars)) err('corr-vars', L('`vars` debe ser una lista de campos.', '`vars` must be a list of fields.'), 'vars: ["precio", "superficie", "antiguedad"]');
11303
+ if (Array.isArray(spec.vars)) check(spec.vars, 'vars');
11304
+ const hueF = spec.hue ?? spec.series;
11305
+ const nums = (Array.isArray(list) ? list : fields.filter((f) => f !== hueF && f !== spec.label && fieldKind(data, f) === 'number')).filter((f) => fields.includes(f));
11306
+ if (Array.isArray(list)) for (const f of list) if (fields.includes(f) && fieldKind(data, f) !== 'number') err('corr-numeric', L(`"${f}" no es numérico: una correlación necesita medidas.`, `"${f}" is not numeric: a correlation needs measures.`), L('Quítalo de `vars`; para comparar una medida entre categorías usa `type: "box"`.', 'Drop it from `vars`; to compare a measure across categories use `type: "box"`.'));
11307
+ if (nums.length < 2) err('corr-vars', L(`Hay ${nums.length} campo(s) numérico(s): se necesitan al menos dos.`, `There are ${nums.length} numeric field(s): at least two are needed.`), L('Entrega filas con dos o más medidas, o indícalas en `vars: [...]`.', 'Pass rows with two or more measures, or list them in `vars: [...]`.'));
11308
+ const constant = nums.filter((f) => { const vs = data.map((r) => num(r[f])).filter(Number.isFinite); return vs.length > 1 && vs.every((v) => v === vs[0]); });
11309
+ if (constant.length) warn('corr-constant', L(`Sin variación: ${constant.join(', ')}. Su correlación no está definida (celda vacía).`, `No variation: ${constant.join(', ')}. Its correlation is undefined (empty cell).`), L('Quítalos de `vars`.', 'Drop them from `vars`.'));
11310
+ if (data.length < 10) warn('corr-few-rows', L(`Solo ${data.length} filas: una correlación con tan pocos casos es inestable.`, `Only ${data.length} rows: a correlation from so few cases is unstable.`), L('Muestra los puntos con `type: "scatter"`, o agrega `alpha: 0.05` para apagar lo no significativo.', 'Show the points with `type: "scatter"`, or add `alpha: 0.05` to grey out what is not significant.'));
11311
+ if (type === 'corr' && nums.length > 12 && !spec.cluster) sug('corr-many', L(`${nums.length} variables: el orden de los datos esconde los grupos que se mueven juntos.`, `${nums.length} variables: data order hides the groups that move together.`), L('Agrega `cluster: true` (o usa `type: "clustermap"`).', 'Add `cluster: true` (or use `type: "clustermap"`).'));
11312
+ if (type === 'corr' && nums.length > 30) warn('corr-too-many', L(`${nums.length} variables: las celdas quedan demasiado chicas para leerse.`, `${nums.length} variables: cells get too small to read.`), L('Elige las que importan con `vars: [...]`.', 'Pick the ones that matter with `vars: [...]`.'));
11313
+ if (type === 'pairplot' && !Array.isArray(list) && nums.length > 6) warn('pairplot-many', L(`${nums.length} campos numéricos: se dibujan solo los 6 primeros (${nums.slice(0, 6).join(', ')}).`, `${nums.length} numeric fields: only the first 6 are drawn (${nums.slice(0, 6).join(', ')}).`), L('Elige con `vars: [...]`, o usa `type: "corr"` para verlos todos.', 'Choose with `vars: [...]`, or use `type: "corr"` to see them all.'));
11314
+ if (type === 'pairplot' && Array.isArray(list) && list.length > 7) warn('pairplot-many', L(`${list.length} variables son ${list.length * list.length} paneles diminutos.`, `${list.length} variables make ${list.length * list.length} tiny panels.`), L('Deja 3–6 en `vars`, o usa `type: "corr"`.', 'Keep 3–6 in `vars`, or use `type: "corr"`.'));
11315
+ if (type === 'pairplot' && data.length > 3000) sug('pairplot-dense', L(`${data.length} filas: los puntos se tapan en cada panel.`, `${data.length} rows: points hide each other in every panel.`), L('Usa `type: "corr"`, o `radius: 1` con una muestra.', 'Use `type: "corr"`, or `radius: 1` with a sample.'));
11316
+ }
11317
+ }
9737
11318
  if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
9738
11319
  const counts = new Map();
9739
11320
  for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
@@ -9786,6 +11367,29 @@ function lint(spec = {}) {
9786
11367
  if (data.length > 25 && spec.collapsed === undefined) sug('gantt-collapse', L(`${data.length} tareas: plegar las fases terminadas acorta la carta.`, `${data.length} tasks: collapsing finished phases shortens the chart.`), L(`Agrega \`collapsed: ["${phases[0]}"]\` (o \`true\` para ver solo el resumen).`, `Add \`collapsed: ["${phases[0]}"]\` (or \`true\` for the summary only).`));
9787
11368
  }
9788
11369
  }
11370
+ if (type === 'table' && data.length) {
11371
+ const cols = Array.isArray(spec.columns) ? spec.columns : [];
11372
+ for (const c of cols) {
11373
+ const f = typeof c === 'string' ? c : c && typeof c === 'object' ? c.field ?? c.key ?? c.name : undefined;
11374
+ if (c && typeof c === 'object' && c.type !== undefined && !columnType(c.type)) {
11375
+ const near = didYouMean(String(c.type), COLUMN_TYPES);
11376
+ err('table-column-type', L(`Tipo de columna "${c.type}" desconocido${f ? ` (columna "${f}")` : ''}.`, `Unknown column type "${c.type}"${f ? ` (column "${f}")` : ''}.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Tipos: ${COLUMN_TYPES.join(', ')}.`, `Types: ${COLUMN_TYPES.join(', ')}.`));
11377
+ }
11378
+ if (f === undefined && !(c && typeof c === 'object' && columnType(c.type) === 'rank')) err('table-column', L('Una columna no dice qué campo muestra.', 'A column does not say which field it shows.'), L('Usa `columns: ["region", { field: "ventas", type: "bar" }]`.', 'Use `columns: ["region", { field: "sales", type: "bar" }]`.'));
11379
+ else if (f !== undefined) check(f, 'columns');
11380
+ if (c && typeof c === 'object' && typeof c.target === 'string') check(c.target, 'columns.target');
11381
+ }
11382
+ const by = spec.sortBy ?? spec.orderBy;
11383
+ if (by !== undefined && !fields.includes(by) && !cols.some((c) => c && typeof c === 'object' && String(c.label) === String(by)) && !['#', 'rank'].includes(String(by))) {
11384
+ const near = didYouMean(String(by), fields);
11385
+ err('table-sort', L(`\`sortBy: "${by}"\` no es una columna.`, `\`sortBy: "${by}"\` is not a column.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Campos: ${fields.join(', ')}.`, `Fields: ${fields.join(', ')}.`));
11386
+ }
11387
+ if (spec.pageSize !== undefined && spec.pageSize !== false && !(Number.isFinite(+spec.pageSize) && +spec.pageSize >= 1)) err('table-page-size', L('`pageSize` debe ser un entero positivo (o false).', '`pageSize` must be a positive integer (or false).'), 'pageSize: 10');
11388
+ const nCols = cols.length || fields.length;
11389
+ if (nCols > 8) warn('table-wide', L(`${nCols} columnas: en pantallas angostas la tabla pasa a tarjetas largas y en escritorio se aprieta.`, `${nCols} columns: on narrow screens the table turns into long cards, and it gets cramped on desktop.`), L('Deja las 4–7 columnas que responden la pregunta del título con `columns: [...]`.', 'Keep the 4–7 columns that answer the title\'s question with `columns: [...]`.'));
11390
+ if (data.length > 15 && spec.pageSize === false) sug('table-long', L(`${data.length} filas sin paginar hacen una imagen muy alta.`, `${data.length} unpaginated rows make a very tall image.`), L('Quita `pageSize: false` (páginas de 10), o usa `top: 15` con un orden.', 'Drop `pageSize: false` (pages of 10), or use `top: 15` with a sort.'));
11391
+ if (data.length > 0 && data.length <= 3 && nCols <= 2) sug('table-tiny', L('Una tabla de pocas cifras dice más como indicadores.', 'A table with so few numbers works better as indicators.'), L('Usa `type: "kpi"` con `items`.', 'Use `type: "kpi"` with `items`.'));
11392
+ }
9789
11393
  if (type === 'kpi' && ![].concat(spec.items || spec.data || []).length) err('items', 'El KPI necesita `items`.', 'items: [{ label: "Ventas", value: 1250, delta: 0.12, period: "vs 2025" }]');
9790
11394
 
9791
11395
  checkOptions(spec, { type, data, fields, err, warn, sug, L });
@@ -9870,6 +11474,7 @@ const EXTRA_OPTIONS = `
9870
11474
  tileOpacity tileStyle tiles tilesKey todayColor truncate unit valueDomain valueFormat valueScale weekends word
9871
11475
  xFormat yFormat yTicks zoom curveness exclude colorScale filters kpis charts updated primary layout
9872
11476
  after area good minLength sizeKey x2
11477
+ orderBy perPage paginate stacked totalLabel
9873
11478
  id name description notes meta alt $schema comment
9874
11479
  `.trim().split(/\s+/);
9875
11480
 
@@ -9954,6 +11559,11 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
9954
11559
  if (pos !== undefined) enumCheck('legend', String(pos).toLowerCase(), LEGEND_POS, 'true, false, "top", "bottom", "left", "right" o { position }');
9955
11560
  }
9956
11561
  if (type === 'network' && spec.layout !== undefined) enumCheck('layout', String(spec.layout).toLowerCase(), NETWORK_LAYOUTS, '"force", "circular", "radial", "concentric", "clusters", "tree", "grid"');
11562
+ if (type === 'corr' && spec.method !== undefined) enumCheck('method', String(spec.method).toLowerCase(), ['pearson', 'spearman']);
11563
+ if (type === 'corr' && typeof spec.triangle === 'string') enumCheck('triangle', spec.triangle, ['lower', 'full']);
11564
+ if (type === 'corr' && spec.alpha !== undefined && !(+spec.alpha > 0 && +spec.alpha < 1)) err('corr-alpha', L('`alpha` es un nivel de significancia entre 0 y 1.', '`alpha` is a significance level between 0 and 1.'), 'alpha: 0.05');
11565
+ if (type === 'pairplot' && spec.diag !== undefined) enumCheck('diag', spec.diag, ['hist', 'kde']);
11566
+ if (type === 'pairplot' && typeof spec.upper === 'string') enumCheck('upper', spec.upper, ['corr', 'scatter']);
9957
11567
  if (type === 'pointmap') {
9958
11568
  if (typeof spec.tiles === 'string') enumCheck('tiles', spec.tiles.toLowerCase(), TILES, '"osm", "esri-gray", "esri-dark", "carto-light", "carto-dark", "mapbox-light", "mapbox-dark", { url, attribution } o false');
9959
11569
  if (spec.tileStyle !== undefined) enumCheck('tileStyle', spec.tileStyle, ['muted', 'original']);
@@ -9972,7 +11582,7 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
9972
11582
  const base = String(nameOf).replace(/_r$/i, '');
9973
11583
  const near = didYouMean(base, all);
9974
11584
  err('colorscale-unknown', L(`La escala de color "${nameOf}" no existe.`, `The color scale "${nameOf}" does not exist.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Usa un nombre de matplotlib ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` invierte) o una lista de colores hex.', 'Use a matplotlib name ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` reverses) or a list of hex colors.'));
9975
- } else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores hex (["#FFF", "#123"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two hex colors (["#FFF", "#123"]) or { name, reverse, mid }.'));
11585
+ } else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores (["#FFF", "steelblue"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two colors (["#FFF", "steelblue"]) or { name, reverse, mid }.'));
9976
11586
  } else if (cs.kind === 'qualitative') {
9977
11587
  err('colorscale-qualitative', L(`"${cs.name}" es una paleta cualitativa: no sirve para una escala continua.`, `"${cs.name}" is a qualitative palette: it cannot be a continuous scale.`), L(`Usa \`palette: "${cs.name}"\` para categorías, o una escala como "viridis" o "Blues".`, `Use \`palette: "${cs.name}"\` for categories, or a scale such as "viridis" or "Blues".`));
9978
11588
  } else if (RAINBOW.has(cs.name.replace(/_r$/, ''))) {
@@ -10064,7 +11674,7 @@ const schema = {
10064
11674
  type: 'string',
10065
11675
  enum: [...new Set(['auto', ...TYPES, 'pointmap', ...Object.keys(ALIASES)])],
10066
11676
  description:
10067
- 'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar. Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
11677
+ 'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar, corr (correlation matrix; clustermap), pairplot (scatter-plot matrix). Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
10068
11678
  },
10069
11679
  data: {
10070
11680
  description: 'Array of rows, e.g. [{ "region": "Biobío", "sales": 1240 }]. For kpi it can be omitted in favor of `items`.',
@@ -10073,7 +11683,7 @@ const schema = {
10073
11683
  title: { type: 'string', description: 'The finding as a full sentence, ideally with the key number, e.g. "Biobío accounts for 32% of sales".' },
10074
11684
  subtitle: { type: 'string', description: 'How to read the chart: measure, unit and period, e.g. "Sales by region, CLP millions, 2025".' },
10075
11685
  source: { type: 'string', description: 'Data source, shown at the bottom right.' },
10076
- locale: { type: 'string', enum: ['es', 'en'], description: 'Language and number format. Default "es" (1.234,5); use "en" for 1,234.5.' },
11686
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'], description: 'Language and number format. Default "es" (1.234,5); "en" for 1,234.5; also "pt", "fr", "de". "auto" recognizes the language from the title, subtitle, column names and categories (tables do this when `locale` is omitted).' },
10077
11687
  x: field('x-axis or category field (bar, line, area, scatter, heatmap, box, violin…).'),
10078
11688
  y: fieldOrList('Value field. For line/bar a list of fields (wide table) turns each one into a series.'),
10079
11689
  series: field('Field that splits the data into colored series. Alias: hue.'),
@@ -10081,7 +11691,7 @@ const schema = {
10081
11691
  hue: field('Alias of series (seaborn naming).'),
10082
11692
  value: field('Value field for treemap, sunburst, donut, heatmap, sankey and radar.'),
10083
11693
  label: field('Name field (treemap, donut, point labels in scatter/regplot).'),
10084
- group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase).'),
11694
+ group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase). table: rows grouped under a header per value.'),
10085
11695
  path: { type: 'array', items: { type: 'string' }, description: 'Hierarchy for treemap/sunburst, any depth: [level1, level2, …].' },
10086
11696
  size: field('Bubble size field (area-proportional).'),
10087
11697
  from: field('Sankey source field.'),
@@ -10180,7 +11790,33 @@ const schema = {
10180
11790
  facet: field('One panel per value of this field (small multiples, seaborn col). Shared scales and colors.'),
10181
11791
  col: field('Alias of facet (seaborn naming).'),
10182
11792
  facetOrder: { type: 'array', items: { type: 'string' }, description: 'facet: panel order (values of the facet field; the rest follow).' },
10183
- columns: { type: 'integer', description: 'facet: panels per row (seaborn col_wrap). Reduced automatically on narrow screens. kpi: tiles per row.' },
11793
+ columns: {
11794
+ description: 'table: the columns, in order: field names or objects { field, label, type, format, align, width, good, target, colorScale, total, lines }. Column types: text, number, bar (data bar), heat (cell color), delta (▲/▼ change, colored by `good`), sparkline (field holds a list of numbers), progress (meter vs `target` field/number, or 0–1 / 0–100), badge (status pill; status words in es/en/pt/fr/de get good/warning/critical colors), check (booleans as ✓), date, link, rank. Omit to infer everything. facet: panels per row (an integer, seaborn col_wrap). kpi: tiles per row.',
11795
+ anyOf: [
11796
+ { type: 'integer' },
11797
+ { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: {
11798
+ field: { type: 'string' }, label: { type: 'string' },
11799
+ type: { type: 'string', enum: ['text', 'number', 'bar', 'heat', 'delta', 'sparkline', 'progress', 'badge', 'check', 'date', 'link', 'rank'] },
11800
+ format: { anyOf: [{ type: 'string' }, { type: 'object' }] }, align: { type: 'string', enum: ['left', 'right', 'center'] }, width: { type: 'number' },
11801
+ good: { type: 'string', enum: ['up', 'down'] }, target: { anyOf: [{ type: 'string' }, { type: 'number' }] }, colorScale: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }, { type: 'object' }] },
11802
+ total: { anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max', 'count'] }] }, lines: { type: 'integer', minimum: 1, maximum: 6 }, text: { type: 'string', description: 'link: field with the text to show instead of the URL.' }, sortable: { type: 'boolean' },
11803
+ } }] } },
11804
+ ],
11805
+ },
11806
+ sortBy: field('table: column to sort by (field or label). Numbers sort high→low by default, text A→Z. In the browser a click on a header sorts.'),
11807
+ sortOrder: { type: 'string', enum: ['asc', 'desc'], description: 'table: sort direction (same as `sort: "asc" | "desc"`).' },
11808
+ pageSize: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'boolean', enum: [false] }], description: 'table: rows per page. Default: all rows up to 15, then pages of 10. false shows every row.' },
11809
+ page: { type: 'integer', minimum: 1, description: 'table: page to show (1-based). In the browser the pager changes it.' },
11810
+ query: { type: 'string', description: 'table: show only rows that contain these words (accent- and case-insensitive).' },
11811
+ search: { type: 'boolean', description: 'table: search box in the browser (default: on above 20 rows).' },
11812
+ total: { description: 'table: totals row. true sums numbers (and averages percents and progress); "mean"/"sum"; or { field: "sum" | "mean" | false, label }.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max'] }, { type: 'object' }] },
11813
+ totalLabel: { type: 'string', description: 'table: label of the totals row (default "Total"/"Gesamt").' },
11814
+ rank: { type: 'boolean', description: 'table: add a "#" column with the position under the current sort.' },
11815
+ cards: { description: 'table: one card per row. "auto" (default) switches when the columns do not fit (phones); true always; false never (columns shrink and truncate).', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['auto'] }] },
11816
+ density: { type: 'string', enum: ['compact', 'normal', 'comfortable'], description: 'table: row height.' },
11817
+ striped: { type: 'boolean', description: 'table: tint every other row (helps on very wide tables).' },
11818
+ bars: { type: 'boolean', description: 'table: false turns off the automatic data bar on the main numeric column.' },
11819
+ sortable: { type: 'boolean', description: 'table: false disables click-to-sort on headers in the browser.' },
10184
11820
  sharex: bool('facet: same x scale in every panel (default true).'),
10185
11821
  panelHeight: number('facet: height of each panel in px.'),
10186
11822
  sharey: { type: 'boolean', description: 'facet: same y scale in every panel (default true).' },
@@ -10214,7 +11850,7 @@ const schema = {
10214
11850
  xFormat: formatSpec('Format of x values when x is a measure (scatter, regplot, hist).'),
10215
11851
  yFormat: formatSpec('Format of y values (overrides `format` on the y axis).'),
10216
11852
 
10217
- highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
11853
+ highlight: { description: 'Emphasize one or more categories/series and mute the rest. table: the rows whose label matches.', ...strOrList },
10218
11854
  dashed: { description: 'Draw some shapes dashed (hatched fill + dashed outline; dashed stroke for lines) to mark estimates, projections or provisional figures. A category/series/region name or a list of them, a data field whose truthy rows are dashed, { field, equals }, or true for all.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { type: 'object', properties: { field: { type: 'string' }, equals: {} } }] },
10219
11855
  dashedFrom: { description: 'line/area/bar on a time axis: x value from which marks are dashed (a projection). Lines turn dashed from the last firm point, over a soft labeled band.', anyOf: [{ type: 'string' }, { type: 'number' }] },
10220
11856
  dashedLabel: { description: 'What the dashed shapes mean ("Projection", "Preliminary"): legend entry, band label and tooltip line. false hides the band label.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
@@ -10224,7 +11860,7 @@ const schema = {
10224
11860
 
10225
11861
  stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
10226
11862
  horizontal: { type: 'boolean', description: 'Force horizontal orientation (otherwise chosen from label length and count).' },
10227
- top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
11863
+ top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other". table: keep the first N rows after sorting.' },
10228
11864
  endLabels: bool('line/regplot: false removes the series names at the end of each line (a legend is drawn instead).'),
10229
11865
  labelsFor: { type: 'object', additionalProperties: { type: 'string' }, description: 'Wide table (y as a list): display names per column, e.g. { "rev": "Revenue" }.' },
10230
11866
  index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
@@ -10240,12 +11876,12 @@ const schema = {
10240
11876
  kde: { type: 'boolean', description: 'hist: overlay a density curve.' },
10241
11877
  multiple: { type: 'string', enum: ['layer', 'stack', 'dodge', 'fill'], description: 'hist/kde with hue: how groups are combined.' },
10242
11878
  fill: { type: 'boolean', description: 'kde: fill the area. line: false removes the gradient under a single line.' },
10243
- order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
11879
+ order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list). corr: variable order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
10244
11880
  lowess: { type: 'boolean', description: 'regplot: local regression instead of a straight line.' },
10245
11881
  logx: { type: 'boolean', description: 'regplot: fit y ~ log(x) with a log x axis.' },
10246
11882
  trend: { type: 'boolean', description: 'scatter: add a least-squares line and R².' },
10247
11883
  annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
10248
- kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
11884
+ kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot. pairplot: "reg" adds a least-squares line to every scatter panel.' },
10249
11885
  marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
10250
11886
  inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'stick', 'none'], description: 'violin: what goes inside.' },
10251
11887
  bandwidth: number('violin: KDE bandwidth (default Scott\'s rule).'),
@@ -10269,6 +11905,17 @@ const schema = {
10269
11905
  centerValue: { type: 'string', description: 'donut/sunburst: text in the center (default: the total).' },
10270
11906
  centerLabel: { type: 'string', description: 'donut/sunburst: caption under the center value.' },
10271
11907
  cellHeight: number('heatmap: maximum cell height in px.'),
11908
+ vars: { type: 'array', items: { type: 'string' }, description: 'corr/pairplot: the numeric fields to relate, in order (seaborn `vars`). Default: every numeric field (pairplot: the first 6). `y: [...]` works too.' },
11909
+ method: { type: 'string', enum: ['pearson', 'spearman'], description: 'corr: correlation coefficient. "pearson" (default, linear) or "spearman" (ranks: monotonic relations, robust to outliers).' },
11910
+ triangle: { anyOf: [{ type: 'string', enum: ['lower', 'full'] }, { type: 'boolean' }], description: 'corr: "lower" (default) shows each pair once, below the diagonal; "full" shows the whole symmetric matrix (default with `cluster`).' },
11911
+ cluster: bool('corr: reorder variables by hierarchical clustering (average linkage on 1 − r) and draw the dendrogram on top, like sns.clustermap. `type: "clustermap"` sets it.'),
11912
+ dendrogram: bool('corr with `cluster`: false hides the dendrogram but keeps the clustered order.'),
11913
+ alpha: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1, description: 'corr: significance level (e.g. 0.05). Cells with p ≥ alpha are greyed out and a legend key explains it. p-values use Fisher\'s z.' },
11914
+ cellSize: number('corr: maximum cell size in px (cells are square).'),
11915
+ diag: { type: 'string', enum: ['hist', 'kde'], description: 'pairplot: what goes on the diagonal. Default "hist", or "kde" when `hue` is set.' },
11916
+ upper: { anyOf: [{ type: 'string', enum: ['corr', 'scatter'] }, { type: 'boolean', enum: [false] }], description: 'pairplot: above the diagonal, "corr" (default: Pearson r, tinted by strength, per `hue` group when there is room), "scatter" (mirror), or false (empty).' },
11917
+ corner: bool('pairplot: draw only the lower triangle and the diagonal (seaborn corner=True).'),
11918
+ panelSize: number('pairplot: maximum panel size in px.'),
10272
11919
  diverging: bool('heatmap/choropleth: diverging color scale around 0 (or `midpoint`).'),
10273
11920
 
10274
11921
  colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities: { "Biobío": "#3A47B8" }.' },
@@ -10309,13 +11956,12 @@ const dashboardSchema = {
10309
11956
  title: 'plotagents dashboard',
10310
11957
  description: 'A dashboard with best-practice defaults: KPIs on top, the main chart wide, the rest in balanced full rows of a 12-column grid, one color per entity across charts, shared filters, one source line.',
10311
11958
  type: 'object',
10312
- required: ['charts'],
10313
11959
  properties: {
10314
11960
  title: { type: 'string', description: 'The question the dashboard answers, e.g. "How are southern sales doing?"' },
10315
11961
  subtitle: { type: 'string', description: 'Scope: measure, unit, period.' },
10316
11962
  source: { type: 'string', description: 'Data source, shown once at the bottom.' },
10317
11963
  updated: { type: 'string', description: 'Last update, e.g. "2026-01-05".' },
10318
- locale: { type: 'string', enum: ['es', 'en'] },
11964
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'] },
10319
11965
  theme: { description: 'One theme for every chart: "papel" (light) or "noche" (dark), or a theme object.', anyOf: [{ type: 'string' }, { type: 'object' }] },
10320
11966
  data: { type: 'array', items: { type: 'object' }, description: 'Shared rows: charts without their own `data` use these, and computed KPIs and filters work on them.' },
10321
11967
  kpis: {
@@ -10326,6 +11972,7 @@ const dashboardSchema = {
10326
11972
  scale: { type: 'number', description: 'Multiply the computed value (e.g. 1000 to show thousands).' },
10327
11973
  compare: { type: 'object', description: 'Current vs previous period: { field: "year", current: 2025, previous: 2024 }.', properties: { field: { type: 'string' }, current: {}, previous: {} } },
10328
11974
  trendBy: { type: 'string', description: 'Field to draw a sparkline by (e.g. "month").' },
11975
+ previous: { type: 'number', description: 'Previous value: the change is computed from it.' }, display: { type: 'string', description: 'Text shown instead of the formatted value.' },
10329
11976
  format: {}, good: { type: 'string', enum: ['up', 'down'] }, trend: { type: 'array', items: { type: 'number' } },
10330
11977
  } },
10331
11978
  },
@@ -10333,6 +11980,12 @@ const dashboardSchema = {
10333
11980
  filters: { type: 'array', description: 'Fields to filter every chart and computed KPI by: "region" or { field, label, control: "buttons" | "select", value }.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
10334
11981
  layout: { type: 'string', enum: ['auto', 'executive', 'grid'], description: 'auto (default): balanced rows by chart type. executive: main chart full width, the rest in pairs. grid: equal panels.' },
10335
11982
  colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities across every chart.' },
11983
+ order: { type: 'string', enum: ['given'], description: '"given" keeps the charts in the order you wrote them (otherwise the main time series goes first).' },
11984
+ note: { type: 'string', description: 'Short note next to the source, at the bottom.' },
11985
+ font: { description: 'Font for every chart: a Google Fonts name, a CSS stack or { family, import }.', anyOf: [{ type: 'string' }, { type: 'object' }] },
11986
+ palette: { description: 'Categorical palette for every chart: a name ("tab10", "okabe-ito") or a list of colors.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
11987
+ ramp: { description: 'Sequential ramp for every chart.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
11988
+ textScale: { type: 'number', description: 'Multiplies every text (1.5 for presentations).' },
10336
11989
  },
10337
11990
  };
10338
11991
 
@@ -10346,7 +11999,7 @@ function toolDefinition({ name, flavor = 'anthropic', kind = 'chart' } = {}) {
10346
11999
  }
10347
12000
  name = name ?? 'plot';
10348
12001
  const description =
10349
- 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
12002
+ 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, corr (correlation matrix of numeric fields, optional clustering: clustermap), pairplot (scatter-plot matrix with distributions and r), hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, table (exact values with per-column indicators: data bars, ▲/▼ changes, cell colors, sparklines, progress meters, status pills; paginated, cards on phones), choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
10350
12003
  const { $schema, $id, title, ...params } = schema;
10351
12004
  if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
10352
12005
  return { name, description, input_schema: params };
@@ -10812,6 +12465,7 @@ const { ensureData, fieldKind, uniq, num, PlotError } = __req("src/data.js");
10812
12465
  const { parseDate, formatDate } = __req("src/format.js");
10813
12466
  const { dateUnit } = __req("src/scale.js");
10814
12467
  const { isGeoUrl } = __req("src/geo/registry.js");
12468
+ const { words } = __req("src/i18n.js");
10815
12469
 
10816
12470
  let plotFn = null;
10817
12471
  let resolveFn = null;
@@ -10861,7 +12515,12 @@ function ensureAssets() {
10861
12515
  .pa-live .pa-chev{opacity:1!important}
10862
12516
  .pa-chart .pa-gh:hover .pa-hit{fill:var(--pa-tint,#ECEEF8);fill-opacity:.5}
10863
12517
  .pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
10864
- @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}}`;
12518
+ .pa-search{position:absolute;z-index:2;box-sizing:border-box;margin:0;font:inherit;font-size:13px;color:var(--pa-ink);background-color:var(--pa-surface);background-repeat:no-repeat;background-position:10px center;border:1px solid var(--pa-line);border-radius:8px;padding:0 10px 0 32px;outline:none;transition:border-color .15s,box-shadow .15s;-webkit-appearance:none;appearance:none}
12519
+ .pa-search::placeholder{color:var(--pa-muted);opacity:1}
12520
+ .pa-search:hover{border-color:var(--pa-muted)}
12521
+ .pa-search:focus-visible,.pa-search:focus{border-color:var(--pa-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--pa-accent) 22%,transparent)}
12522
+ .pa-chart .pa-th[data-sort]:focus-visible,.pa-chart .pa-pg:focus-visible{outline:2px solid var(--pa-accent);outline-offset:-2px}
12523
+ @media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}.pa-search{transition:none}}`;
10865
12524
  document.head.appendChild(st);
10866
12525
  }
10867
12526
 
@@ -10921,6 +12580,38 @@ function render(target, spec, opts = {}) {
10921
12580
  let timer = null, ro = null, raf = 0;
10922
12581
 
10923
12582
  const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
12583
+ let search = null, searchTimer = 0;
12584
+ if (state.type === 'table' && state.search !== false) {
12585
+ let n = 0;
12586
+ try { n = ensureData(state).length; } catch { n = 0; }
12587
+ if (state.search === true || n > 20) {
12588
+ const th = themeFor(state);
12589
+ const W = words(state.locale);
12590
+ search = document.createElement('input');
12591
+ search.type = 'search'; search.className = 'pa-search';
12592
+ search.placeholder = W.search + '…'; search.setAttribute('aria-label', W.search);
12593
+ search.autocomplete = 'off'; search.spellcheck = false;
12594
+ if (state.query) search.value = state.query;
12595
+ const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.25" fill="none" stroke="${th.muted}" stroke-width="1.5"/><path d="M9.2 9.2l3.3 3.3" stroke="${th.muted}" stroke-width="1.5" stroke-linecap="round"/></svg>`;
12596
+ search.style.cssText = `--pa-accent:${th.categorical[0]};--pa-surface:${th.surface};--pa-ink:${th.ink};--pa-muted:${th.muted};--pa-line:${th.axis};font-family:${(th.font?.family || 'system-ui').replace(/"/g, "'")};background-image:url("data:image/svg+xml,${encodeURIComponent(icon)}")`;
12597
+ search.addEventListener('input', () => {
12598
+ clearTimeout(searchTimer);
12599
+ searchTimer = setTimeout(() => { state = { ...state, query: search.value.trim() || undefined, page: 1 }; draw(); }, 120);
12600
+ });
12601
+ search.addEventListener('keydown', (e) => { if (e.key === 'Escape' && search.value) { e.stopPropagation(); search.value = ''; state = { ...state, query: undefined, page: 1 }; draw(); } });
12602
+ if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
12603
+ host.appendChild(search);
12604
+ }
12605
+ }
12606
+ const placeSearch = () => {
12607
+ if (!search) return;
12608
+ const slot = chart.querySelector('.pa-search-slot');
12609
+ if (!slot) { search.hidden = true; return; }
12610
+ search.hidden = false;
12611
+ const r = slot.getBoundingClientRect(), h = host.getBoundingClientRect();
12612
+ search.style.left = r.left - h.left + 'px'; search.style.top = r.top - h.top + 'px';
12613
+ search.style.width = r.width + 'px'; search.style.height = r.height + 'px';
12614
+ };
10924
12615
  const nodePos = () => {
10925
12616
  const m = new Map();
10926
12617
  for (const c of chart.querySelectorAll('circle[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
@@ -10980,7 +12671,7 @@ function render(target, spec, opts = {}) {
10980
12671
  const draw = () => {
10981
12672
  const before = nodePos();
10982
12673
  const fills = regionFills();
10983
- chart.innerHTML = plotFn({ ...state, width: width() });
12674
+ chart.innerHTML = plotFn({ ...state, width: width(), _search: !!search });
10984
12675
  animateFrom(before);
10985
12676
  tweenFills(fills);
10986
12677
  chart.__pa = false;
@@ -11025,6 +12716,49 @@ function render(target, spec, opts = {}) {
11025
12716
  n.addEventListener('click', go);
11026
12717
  n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
11027
12718
  }
12719
+ placeSearch();
12720
+ const refocus = (sel) => { const m = chart.querySelector(sel); if (m) m.focus(); return !!m; };
12721
+ const refocusSame = (arrow, p) => (arrow ? refocus(`[data-arrow="${arrow}"][data-page]`) : refocus(`[data-page="${p}"]:not([data-arrow])`));
12722
+ for (const n of chart.querySelectorAll('[data-page]')) {
12723
+ const p = +n.getAttribute('data-page');
12724
+ const arrow = n.getAttribute('data-arrow');
12725
+ n.setAttribute('tabindex', '0');
12726
+ n.setAttribute('role', 'button');
12727
+ const go = (e) => {
12728
+ state = { ...state, page: p };
12729
+ draw();
12730
+ if (e && e.type === 'keydown' && !refocusSame(arrow, p)) refocus('.pa-pg-on');
12731
+ };
12732
+ n.addEventListener('click', go);
12733
+ n.addEventListener('keydown', (e) => {
12734
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); return; }
12735
+ const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
12736
+ if (!d) return;
12737
+ const pages = [...chart.querySelectorAll('[data-page]')];
12738
+ const k = pages.indexOf(n) + d;
12739
+ if (pages[k]) { e.preventDefault(); pages[k].focus(); }
12740
+ });
12741
+ }
12742
+ for (const n of chart.querySelectorAll('.pa-th[data-sort]')) {
12743
+ const f = n.getAttribute('data-sort');
12744
+ n.setAttribute('tabindex', '0');
12745
+ n.setAttribute('aria-label', `${words(state.locale).sort} ${n.textContent}`);
12746
+ const go = (e) => {
12747
+ const cur = n.getAttribute('aria-sort');
12748
+ const numeric = n.getAttribute('data-num') === '1';
12749
+ const first = numeric ? 'desc' : 'asc';
12750
+ const firstAria = first === 'desc' ? 'descending' : 'ascending';
12751
+ let next;
12752
+ if (cur === 'none' || !cur) next = { sortBy: f, sort: first };
12753
+ else if (cur === firstAria) next = { sortBy: f, sort: first === 'desc' ? 'asc' : 'desc' };
12754
+ else next = { sortBy: undefined, sort: undefined };
12755
+ state = { ...state, ...next, sortOrder: undefined, page: 1 };
12756
+ draw();
12757
+ if (e && e.type === 'keydown') refocus(`.pa-th[data-sort="${CSS.escape ? CSS.escape(f) : f}"]`);
12758
+ };
12759
+ n.addEventListener('click', go);
12760
+ n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
12761
+ }
11028
12762
  };
11029
12763
  const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
11030
12764
 
@@ -11135,12 +12869,13 @@ function render(target, spec, opts = {}) {
11135
12869
  set();
11136
12870
  }
11137
12871
 
11138
- host.addEventListener('keydown', (e) => {
12872
+ const onEsc = (e) => {
11139
12873
  if (e.key !== 'Escape') return;
11140
12874
  if (state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); return; }
11141
12875
  if (Array.isArray(state.focus) && state.focus.length) { state = { ...state, focus: state.focus.slice(0, -1) }; draw(); return; }
11142
12876
  if (state.exclude && [].concat(state.exclude).length) { state = { ...state, exclude: undefined }; draw(); }
11143
- });
12877
+ };
12878
+ host.addEventListener('keydown', onEsc);
11144
12879
 
11145
12880
  draw();
11146
12881
  if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
@@ -11151,8 +12886,8 @@ function render(target, spec, opts = {}) {
11151
12886
  return {
11152
12887
  get svg() { return chart.querySelector('svg'); },
11153
12888
  get spec() { return state; },
11154
- update(next) { clearInterval(timer); ro && ro.disconnect(); return render(host, next, opts); },
11155
- destroy() { clearInterval(timer); ro && ro.disconnect(); host.innerHTML = ''; },
12889
+ update(next) { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); return render(host, next, opts); },
12890
+ destroy() { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); host.innerHTML = ''; },
11156
12891
  };
11157
12892
  }
11158
12893
 
@@ -11216,11 +12951,18 @@ let core = null;
11216
12951
  /** index.js registra plot/resolve/lint aquí (evita una importación circular). */
11217
12952
  function _bindDashboard(fns) { core = fns; }
11218
12953
 
12954
+ /** Spec tolerante: charts/kpis/filters siempre listas de objetos (los nulos y valores sueltos se descartan). */
12955
+ function tidy(spec) {
12956
+ const list = (v) => (v == null || v === false ? [] : [].concat(v)).filter((x) => x != null);
12957
+ return { ...spec, charts: list(spec.charts).filter((c) => typeof c === 'object'), kpis: list(spec.kpis), filters: list(spec.filters).filter((f) => typeof f === 'string' || (typeof f === 'object' && typeof f.field === 'string')) };
12958
+ }
12959
+ const hasField = (r, f) => r !== null && typeof r === 'object' && f in r;
12960
+
11219
12961
  const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
11220
12962
 
11221
12963
  const WIDE = new Set(['line', 'area', 'gantt', 'heatmap', 'sankey', 'flowchart', 'network', 'jointplot']);
11222
12964
  const NARROW = new Set(['donut', 'pie', 'radar', 'kpi', 'hist', 'kde', 'ecdf', 'count']);
11223
- const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart']);
12965
+ const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart', 'table']);
11224
12966
 
11225
12967
  function spanFor(s) {
11226
12968
  if (s.size === 'full' || s.span === 12) return 12;
@@ -11316,6 +13058,7 @@ const colorField = (s) => s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0
11316
13058
  function planDashboard(spec = {}) {
11317
13059
  if (!core) throw new PlotError('plotagents no está inicializado.');
11318
13060
  if (!spec || typeof spec !== 'object') throw new PlotError('El dashboard debe ser un objeto.', 'dashboard({ title, kpis, charts: [...] })');
13061
+ spec = tidy(spec);
11319
13062
  const locale = spec.locale || 'es';
11320
13063
  const shared = spec.data ? ensureData(spec) : null;
11321
13064
  const base = {};
@@ -11368,7 +13111,7 @@ function planDashboard(spec = {}) {
11368
13111
  const filters = [].concat(spec.filters || []).map((f) => {
11369
13112
  const o = typeof f === 'string' ? { field: f } : { ...f };
11370
13113
  const pool = [...(shared || []), ...panels.flatMap((p) => (Array.isArray(p.spec.data) && p.spec.data !== shared ? p.spec.data : []))];
11371
- const vals = uniq(pool.map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
13114
+ const vals = uniq(pool.filter((r) => hasField(r, o.field)).map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
11372
13115
  const kind = pool.length ? fieldKind(pool, o.field) : 'category';
11373
13116
  vals.sort((a, b) => (kind === 'date' ? parseDate(a) - parseDate(b) : kind === 'year' || kind === 'number' ? num(a) - num(b) : 0));
11374
13117
  return { field: o.field, label: o.label ?? o.field.charAt(0).toUpperCase() + o.field.slice(1), values: vals.map(String), control: o.control ?? (vals.length <= 6 ? 'buttons' : 'select'), value: o.value ?? o.default ?? null, all: o.all ?? T(locale, 'Todos', 'All') };
@@ -11381,6 +13124,7 @@ function planDashboard(spec = {}) {
11381
13124
  }
11382
13125
 
11383
13126
  function lintDashboard(spec = {}) {
13127
+ spec = tidy(spec && typeof spec === 'object' ? spec : {});
11384
13128
  const locale = spec.locale || 'es';
11385
13129
  const L = (es, en) => T(locale, es, en);
11386
13130
  const errors = [], warnings = [], suggestions = [];
@@ -11402,7 +13146,7 @@ function lintDashboard(spec = {}) {
11402
13146
  if (untitled) suggestions.push({ code: 'dash-chart-titles', message: L(`${untitled} gráfico(s) sin título.`, `${untitled} chart(s) without a title.`), fix: L('Cada panel necesita su hallazgo como título.', 'Every panel needs its finding as the title.') });
11403
13147
  for (const f of [].concat(spec.filters || [])) {
11404
13148
  const field = typeof f === 'string' ? f : f.field;
11405
- const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => r && field in r));
13149
+ const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => hasField(r, field)));
11406
13150
  if (!has) warnings.push({ code: 'dash-filter-field', message: L(`El filtro "${field}" no existe en los datos de ningún gráfico.`, `The filter "${field}" is not in any chart's data.`), fix: L('Usa un campo de `data`.', 'Use a field of `data`.') });
11407
13151
  }
11408
13152
  charts.forEach((c, i) => {
@@ -11463,7 +13207,8 @@ function kpiMarkup(plan, rows) {
11463
13207
  if (k.trendBy && !k.trend && rows && rows.length) {
11464
13208
  const by = new Map();
11465
13209
  for (const r of cmp ? inPeriod(cmp.current) : rows) { const key = r[k.trendBy]; if (!by.has(key)) by.set(key, []); by.get(key).push(r); }
11466
- const ks = [...by.keys()].sort((x, y) => (String(x) < String(y) ? -1 : 1));
13210
+ const allNum = [...by.keys()].every((x) => Number.isFinite(num(x)));
13211
+ const ks = [...by.keys()].sort((x, y) => (allNum ? num(x) - num(y) : String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0));
11467
13212
  k = { ...k, trend: ks.map((x) => computeKpi(k, by.get(x))) };
11468
13213
  }
11469
13214
  const fmt = makeFormat(k.format, locale, [v]);
@@ -11515,7 +13260,7 @@ function filterSpec(spec, plan, active, locale) {
11515
13260
  const v = active[i];
11516
13261
  if (v === null || v === undefined || v === '') return;
11517
13262
  if (s.time === f.field) { s = { ...s, timeValue: v }; return; }
11518
- if (Array.isArray(s.data) && s.data.some((r) => r && f.field in r)) { s = { ...s, data: s.data.filter((r) => String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
13263
+ if (Array.isArray(s.data) && s.data.some((r) => hasField(r, f.field))) { s = { ...s, data: s.data.filter((r) => hasField(r, f.field) && String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
11519
13264
  });
11520
13265
  if (tags.length) s = { ...s, subtitle: [s.subtitle, tags.join(' · ')].filter(Boolean).join(' · ') };
11521
13266
  return s;
@@ -11537,7 +13282,7 @@ function mountDashboard(root, plan) {
11537
13282
  } catch (e) { fig.innerHTML = `<p class="pa-err">${esc(e.message)}</p>`; }
11538
13283
  }
11539
13284
  if (plan.data && plan.kpis.some((k) => k.value === undefined)) {
11540
- const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !(f.field in r) || String(r[f.field]) === String(active[i])));
13285
+ const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !hasField(r, f.field) || String(r[f.field]) === String(active[i])));
11541
13286
  const slot = root.querySelector('.pa-kslot');
11542
13287
  if (slot) slot.innerHTML = kpiMarkup(plan, rows);
11543
13288
  }
@@ -11598,10 +13343,12 @@ ${bundle ? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>
11598
13343
 
11599
13344
  /** Nunca lanza: { ok, html, plan, errors, warnings, suggestions } (para agentes). */
11600
13345
  function safeDashboard(spec) {
11601
- const rep = lintDashboard(spec || {});
13346
+ let rep;
13347
+ try { rep = lintDashboard(spec || {}); }
13348
+ catch (e) { return { ok: false, html: null, plan: null, errors: [{ code: 'render', message: String(e.message).split('\n')[0], fix: e.hint || 'Revisa que `charts` sea una lista de specs y `kpis` una lista de objetos.' }], warnings: [], suggestions: [] }; }
11602
13349
  if (!rep.ok) return { ...rep, html: null, plan: null };
11603
13350
  try { const html = dashboard(spec); return { ...rep, ok: true, html, plan: planDashboard(spec) }; }
11604
- catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint }] }; }
13351
+ catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa `charts`, `kpis` y `data` del dashboard.' }] }; }
11605
13352
  }
11606
13353
 
11607
13354