plotagents 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -0,0 +1,882 @@
1
+ // Tablas: para cuando el lector necesita buscar el número exacto, no ver una
2
+ // forma. Sin rejas ni cajas: filetes finos entre filas, números alineados a la
3
+ // derecha con cifras tabulares y decimales parejos por columna, y en cada
4
+ // columna el indicador que la explica (barra, variación con flecha, color,
5
+ // minigráfico, avance contra la meta, estado). En pantallas angostas cada fila
6
+ // se vuelve una tarjeta. Las filas se paginan; en el navegador, render()
7
+ // agrega orden por columna, búsqueda y paso de página.
8
+ import { el, g, text, tip, esc } from '../svg.js';
9
+ import { measure, truncate, wrap } from '../text.js';
10
+ import { makeFormat, formatDate, parseDate, currencyPrefix } from '../format.js';
11
+ import { ensureData, fieldsOf, fieldKind, num, uniq, humanize, PlotError, aggregate } from '../data.js';
12
+ import { createFrame } from '../frame.js';
13
+ import { ctx } from '../context.js';
14
+ import { ramp, textOn, readable, hexToRgb, rgbToHex, contrast } from '../theme.js';
15
+ import { dateUnit } from '../scale.js';
16
+ import { resolveColorScale, sample } from '../colorscales.js';
17
+ import { words, langOf } from '../i18n.js';
18
+
19
+ // Tipos de columna (y sus alias): lo que el agente escriba se entiende.
20
+ const TYPE_ALIAS = {
21
+ text: 'text', string: 'text', label: 'text', category: 'text',
22
+ number: 'number', numeric: 'number', value: 'number',
23
+ bar: 'bar', bars: 'bar', databar: 'bar',
24
+ heat: 'heat', heatmap: 'heat', color: 'heat', colour: 'heat', scale: 'heat',
25
+ delta: 'delta', change: 'delta', variation: 'delta', variacion: 'delta', 'variación': 'delta', arrow: 'delta', diff: 'delta',
26
+ sparkline: 'sparkline', spark: 'sparkline', trend: 'sparkline', line: 'sparkline', tendencia: 'sparkline',
27
+ progress: 'progress', meter: 'progress', target: 'progress', bullet: 'progress', avance: 'progress', goal: 'progress',
28
+ badge: 'badge', pill: 'badge', tag: 'badge', status: 'badge', estado: 'badge', chip: 'badge',
29
+ check: 'check', boolean: 'check', bool: 'check', icon: 'check',
30
+ date: 'date', fecha: 'date', time: 'date',
31
+ link: 'link', url: 'link',
32
+ rank: 'rank',
33
+ };
34
+ export const COLUMN_TYPES = [...new Set(Object.values(TYPE_ALIAS))];
35
+ export const columnType = (t) => TYPE_ALIAS[String(t || '').toLowerCase()] || null;
36
+
37
+ 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;
38
+ const PCT_NAME_RE = /(%|pct|percent|porc|porcentaje|tasa|rate|share|participaci|anteil|taux|part\b)/i;
39
+ const PROGRESS_RE = /(progress|avance|progreso|complet|cumplim|ejecuci[oó]n|achiev|logro|fortschritt|avancement|andamento)/i;
40
+ // Nombres que solo indican avance si los valores son fracciones (0,96 = 96% de la meta).
41
+ const GOAL_RE = /^(meta|metas|target|goal|objetivo|objective|ziel|objectif|cumplimiento|attainment|ratio)$|(^|[\s_.-])(meta|target|goal|objetivo)([\s_.-]|$)/i;
42
+ 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;
43
+
44
+ // Estados reconocidos en varios idiomas: el color dice algo, no decora.
45
+ const STATUS = [
46
+ ['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],
47
+ ['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],
48
+ ['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],
49
+ ['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],
50
+ ];
51
+ export const statusOf = (v) => { const s = String(v ?? '').trim(); for (const [k, re] of STATUS) if (re.test(s)) return k; return null; };
52
+
53
+ const YES = /^(true|yes|y|s[ií]|sim|oui|ja|x|✓|✔|☑)$/i;
54
+ const NO = /^(false|no|n|não|nao|non|nein|✗|✘|☐|-|–|—)$/i;
55
+ const SAFE_URL = /^(https?:\/\/|mailto:)[^\s"'<>]+$/i;
56
+
57
+ const norm = (s) => String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
58
+ const isNil = (v) => v === null || v === undefined || v === '' || (typeof v === 'number' && Number.isNaN(v));
59
+ 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; };
60
+
61
+ function mix(a, b, t) {
62
+ const A = hexToRgb(a), B = hexToRgb(b);
63
+ return rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
64
+ }
65
+ /** Acerca `fg` a `toward` hasta lograr contraste ≥ min sobre `bg`. */
66
+ function legible(fg, bg, toward, min = 4.5) {
67
+ let c = fg;
68
+ for (let t = 0.05; contrast(c, bg) < min && t <= 1.0001; t += 0.05) c = mix(fg, toward, t);
69
+ return c;
70
+ }
71
+ const hexOr = (c, d) => (typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim()) ? c.trim() : d);
72
+
73
+ /** Lista de columnas: la que da el agente o una inferida de los datos. */
74
+ function columnList(spec, data) {
75
+ const fields = fieldsOf(data);
76
+ const groupF = typeof spec.group === 'string' && fields.includes(spec.group) ? spec.group : null;
77
+ if (Array.isArray(spec.columns) && spec.columns.length) {
78
+ 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'));
79
+ }
80
+ const labelF = spec.label ?? spec.x;
81
+ let list;
82
+ if (spec.y !== undefined || spec.value !== undefined) {
83
+ list = [labelF, ...[].concat(spec.y ?? []), ...[].concat(spec.value ?? [])].filter((f) => f !== undefined && f !== null);
84
+ if (!labelF) { const first = fields.find((f) => fieldKind(data, f) === 'category' && f !== groupF); if (first) list.unshift(first); }
85
+ } else {
86
+ list = fields.filter((f) => f !== groupF && !String(f).startsWith('_'));
87
+ if (labelF && list.includes(labelF)) list = [labelF, ...list.filter((f) => f !== labelF)];
88
+ }
89
+ // Las filas de un spec con `dashed: "campo"` marcan provisorios; ese campo no es una columna.
90
+ if (typeof spec.dashed === 'string') list = list.filter((f) => f !== spec.dashed);
91
+ return uniq(list).map((f) => ({ field: f }));
92
+ }
93
+
94
+ /** Pista de formato a partir del texto crudo: "12%" → porcentaje, "$ 3.000" → moneda. */
95
+ function rawHint(values) {
96
+ const strs = values.filter((v) => typeof v === 'string' && v.trim());
97
+ if (!strs.length || strs.length < values.length * 0.6) return null;
98
+ if (strs.filter((s) => /%\s*$/.test(s)).length >= strs.length * 0.6) return { type: 'percent', scale: 1 };
99
+ const cur = strs.map((s) => (/^\s*[-−]?\s*(US\$|\$|€|£|R\$|S\/|UF)/.exec(s) || [])[1]).filter(Boolean);
100
+ if (cur.length >= strs.length * 0.6) return { type: 'number', prefix: cur[0] === 'UF' ? 'UF ' : cur[0] };
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Formateador de una columna numérica: mismos decimales en toda la columna
106
+ * (los números se leen alineados) y cifras completas; nada de "1,2 M" en una
107
+ * tabla salvo que se pida `format: "compact"`.
108
+ */
109
+ function columnFormat(fmt, values, locale, hint) {
110
+ let o = typeof fmt === 'string' ? { type: fmt } : fmt && typeof fmt === 'object' ? { ...fmt } : hint ? { ...hint } : { type: 'number' };
111
+ if (!o.type) o.type = 'number';
112
+ const nums = values.filter(Number.isFinite);
113
+ const maxAbs = nums.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
114
+ 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; }
115
+ if (o.type === 'number' && o.decimals === undefined) {
116
+ const cap = maxAbs >= 1000 ? 0 : maxAbs >= 100 ? 1 : maxAbs >= 1 ? 2 : 3;
117
+ o.decimals = Math.min(cap, Math.max(0, ...nums.map(decimalsOf)));
118
+ }
119
+ if (o.type === 'percent' && o.decimals === undefined) {
120
+ const scale = o.scale ?? (maxAbs > 1.5 ? 1 : 100);
121
+ o.scale = scale;
122
+ const sc = nums.map((v) => v * scale);
123
+ const m = sc.reduce((a, v) => Math.max(a, Math.abs(v)), 0);
124
+ o.decimals = Math.min(m >= 10 ? 1 : 2, Math.max(0, ...sc.map((v) => decimalsOf(+v.toPrecision(10)))));
125
+ }
126
+ return makeFormat(o, locale, nums);
127
+ }
128
+
129
+ /** Prepara cada columna: tipo, alineación, formato y estadísticas sobre TODAS las filas. */
130
+ function prepareColumns(spec, data, theme, locale) {
131
+ const W = words(locale);
132
+ const list = columnList(spec, data);
133
+ const fields = fieldsOf(data);
134
+ for (const c of list) {
135
+ if (c.field !== undefined && columnType(c.type) !== 'rank' && !fields.includes(c.field)) {
136
+ throw new PlotError(`La columna "${c.field}" no existe en los datos.`, `Campos disponibles: ${fields.map((f) => `"${f}"`).join(', ') || '(ninguno)'}`);
137
+ }
138
+ }
139
+ const labelF = spec.label ?? spec.x ?? (list.find((c) => c.field !== undefined && fieldKind(data, c.field) === 'category') || list[0] || {}).field;
140
+ const mainF = spec.bars === false ? null : spec.value ?? (typeof spec.y === 'string' ? spec.y : undefined);
141
+ let barGiven = false;
142
+ const cols = list.map((c0, i) => {
143
+ const c = { ...c0, index: i };
144
+ const t = columnType(c.type);
145
+ if (c.type !== undefined && !t) c.type = undefined;
146
+ c.label = c.label ?? c.title ?? c.header ?? (t === 'rank' && c.field === undefined ? '#' : humanize(c.field));
147
+ if (t === 'rank') { c.type = 'rank'; c.align = c.align ?? 'right'; return c; }
148
+ const raw = data.map((r) => r[c.field]);
149
+ const present = raw.filter((v) => !isNil(v));
150
+ const kind = present.length ? fieldKind(data, c.field) : 'empty';
151
+ const isArr = present.length && present.filter((v) => Array.isArray(v)).length >= present.length * 0.5;
152
+ 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));
153
+ const nums = kind === 'number' || kind === 'year' ? raw.map(num) : [];
154
+ const finite = nums.filter(Number.isFinite);
155
+ c.isLabel = c.field === labelF;
156
+ let type = t;
157
+ if (!type) {
158
+ if (isArr) type = 'sparkline';
159
+ else if (isBool) type = 'check';
160
+ else if (kind === 'date') type = 'date';
161
+ else if (kind === 'number' || (kind === 'year' && !c.isLabel)) {
162
+ const lo = Math.min(...finite), hi = Math.max(...finite);
163
+ if (ID_RE.test(String(c.field))) type = 'text';
164
+ else if (kind === 'year') type = 'number';
165
+ else if (DELTA_RE.test(String(c.field)) || DELTA_RE.test(String(c.label))) type = 'delta';
166
+ else if ((PROGRESS_RE.test(String(c.field)) || PROGRESS_RE.test(String(c.label))) && lo >= 0 && hi <= 150) type = 'progress';
167
+ 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';
168
+ else type = 'number';
169
+ } else if (present.length && present.filter((v) => typeof v === 'string' && SAFE_URL.test(v.trim())).length >= present.length * 0.8) type = 'link';
170
+ else if (present.length >= 2 && present.filter((v) => statusOf(v)).length >= present.length * 0.8) type = 'badge';
171
+ else type = 'text';
172
+ }
173
+ if (type === 'bar' || type === 'heat' || type === 'progress') barGiven = barGiven || type === 'bar';
174
+ c.type = type;
175
+ c.kind = kind;
176
+ c.values = nums;
177
+ c.finite = finite;
178
+ const numeric = ['number', 'bar', 'heat', 'delta', 'progress'].includes(type);
179
+ if (numeric && !finite.length && !(type === 'number' && kind === 'year')) c.type = type = 'text';
180
+ // Formato: el de la columna, el del spec (para números) o el que delata el texto crudo.
181
+ const hint = rawHint(raw);
182
+ if (type === 'delta') {
183
+ 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));
184
+ c.fmt = columnFormat(c.format ?? (pctLike ? (hint?.type === 'percent' ? hint : { type: 'percent' }) : hint), finite.map(Math.abs), locale, null);
185
+ } else if (type === 'progress') {
186
+ const target = c.target ?? c.max;
187
+ c.targetOf = (r) => { const t = typeof target === 'string' ? num(r[target]) : Number.isFinite(+target) && target !== null && target !== undefined ? +target : NaN; return t; };
188
+ c.hasTarget = target !== undefined && target !== null;
189
+ const hi = Math.max(0, ...finite);
190
+ c.scale100 = !c.hasTarget && hi > 1.5;
191
+ 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);
192
+ c.pctFmt = makeFormat({ type: 'percent', scale: 100, decimals: 0 }, locale, []);
193
+ } else if (numeric || (type === 'number' && kind === 'year')) {
194
+ c.fmt = kind === 'year' && !c.format ? (v) => String(Math.round(v)) : columnFormat(c.format ?? spec.format ?? hint, finite, locale, null);
195
+ } else if (type === 'sparkline') {
196
+ const all = present.flatMap((v) => (Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []));
197
+ c.fmt = columnFormat(c.format ?? spec.format, all, locale, null);
198
+ c.sparkAll = all;
199
+ } else if (type === 'date') {
200
+ const ts = present.map(parseDate).filter(Number.isFinite);
201
+ c.unit = ts.length ? dateUnit(ts) : 'day';
202
+ c.withYear = c.yearless !== true;
203
+ }
204
+ c.align = c.align ?? (['number', 'bar', 'heat', 'delta', 'progress'].includes(type) ? 'right' : type === 'check' ? 'center' : type === 'sparkline' ? 'left' : 'left');
205
+ if (type === 'bar' || type === 'heat') {
206
+ c.lo = Math.min(0, ...finite);
207
+ c.hi = Math.max(0, ...finite);
208
+ if (type === 'heat') {
209
+ c.hlo = Math.min(...finite); c.hhi = Math.max(...finite);
210
+ const cs = resolveColorScale(c.colorScale ?? c.cmap);
211
+ 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);
212
+ c.stops = cs && cs.kind !== 'qualitative' ? sample(cs.stops, 17) : null;
213
+ if (c.stops && cs.kind === 'diverging' && c.mid === undefined) c.mid = (c.hlo + c.hhi) / 2;
214
+ }
215
+ }
216
+ if (type === 'badge') {
217
+ const vals = uniq(present.map(String));
218
+ const fixed = { ...(spec.colors || {}), ...(c.colors || {}) };
219
+ c.badge = new Map(vals.map((v) => {
220
+ const st = statusOf(v);
221
+ 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);
222
+ return [v, base];
223
+ }));
224
+ }
225
+ c.good = c.good ?? spec.good ?? 'up';
226
+ return c;
227
+ });
228
+ // Indicador por defecto: barra en la medida principal (la de `value`/`y`, o
229
+ // la primera numérica), si nadie pidió otra cosa y hay algo que comparar.
230
+ if (!barGiven && spec.bars !== false && data.length >= 3 && !(Array.isArray(spec.columns) && spec.columns.some((c) => c && typeof c === 'object' && c.type))) {
231
+ 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);
232
+ 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; }
233
+ }
234
+ // Rango: una columna "#" al inicio con `rank: true`.
235
+ if (spec.rank && !cols.some((c) => c.type === 'rank')) cols.unshift({ type: 'rank', label: '#', align: 'right', index: -1 });
236
+ return { cols, labelF, W };
237
+ }
238
+
239
+ /** Texto visible de una celda (y su valor para ordenar). */
240
+ function cellText(c, r, locale, W) {
241
+ const v = r[c.field];
242
+ if (c.type === 'rank') return String(r.__rank ?? '');
243
+ if (isNil(v)) return '';
244
+ switch (c.type) {
245
+ case 'number': case 'bar': case 'heat': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
246
+ 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; }
247
+ case 'progress': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
248
+ case 'date': { const t = parseDate(v); return Number.isFinite(t) ? formatDate(t, c.unit, locale, c.withYear) : String(v); }
249
+ case 'check': return v === true || YES.test(String(v).trim()) ? W.yes : W.no;
250
+ case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? c.fmt(a[a.length - 1]) : ''; }
251
+ 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(/\/$/, ''); }
252
+ default:
253
+ if (Array.isArray(v)) return v.map((x) => (x && typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ');
254
+ if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return '…'; } }
255
+ return String(v);
256
+ }
257
+ }
258
+
259
+ function sortKey(c, r) {
260
+ const v = r[c.field];
261
+ if (c.type === 'rank') return r.__rank;
262
+ if (isNil(v)) return null;
263
+ switch (c.type) {
264
+ case 'number': case 'bar': case 'heat': case 'delta': case 'progress': { const n = num(v); return Number.isFinite(n) ? n : null; }
265
+ case 'date': { const t = parseDate(v); return Number.isFinite(t) ? t : String(v); }
266
+ case 'check': return v === true || YES.test(String(v).trim()) ? 1 : 0;
267
+ case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? a[a.length - 1] : null; }
268
+ default: return String(v);
269
+ }
270
+ }
271
+
272
+ const collatorCache = new Map();
273
+ function compareText(locale) {
274
+ const k = langOf(locale);
275
+ if (!collatorCache.has(k)) {
276
+ let cmp;
277
+ 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); }
278
+ collatorCache.set(k, cmp);
279
+ }
280
+ return collatorCache.get(k);
281
+ }
282
+
283
+ /** Orden actual: `sortBy` (campo o etiqueta) y `sort` ("desc"/"asc"). También `sort: "-ventas"`. */
284
+ export function sortState(spec, cols) {
285
+ let by = spec.sortBy ?? spec.orderBy;
286
+ let dir = spec.sortOrder ?? (['asc', 'desc'].includes(spec.sort) ? spec.sort : undefined);
287
+ if (by === undefined && typeof spec.sort === 'string' && !['asc', 'desc', 'none', 'alpha', 'start'].includes(spec.sort)) {
288
+ by = spec.sort.replace(/^[-+]/, '');
289
+ if (dir === undefined) dir = spec.sort.startsWith('-') ? 'desc' : spec.sort.startsWith('+') ? 'asc' : undefined;
290
+ }
291
+ if (spec.sort && typeof spec.sort === 'object') { by = spec.sort.by ?? spec.sort.field; dir = spec.sort.order ?? spec.sort.dir; }
292
+ if (by === undefined && dir) by = (cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat', 'progress'].includes(c.type)) || {}).field;
293
+ if (by === undefined || spec.sort === 'none') return null;
294
+ 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);
295
+ if (!col) return null;
296
+ const numericish = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(col.type);
297
+ return { col, dir: dir === 'asc' || dir === 'desc' ? dir : numericish ? 'desc' : 'asc' };
298
+ }
299
+
300
+ const BASE = { cell: 13, head: 12, pad: 10 };
301
+
302
+ export function table(spec, theme) {
303
+ const locale = spec.locale || 'es';
304
+ const lang = langOf(locale);
305
+ const all = ensureData(spec).map((r, i) => ({ ...r, __i: i }));
306
+ const { cols, labelF, W } = prepareColumns(spec, all, theme, locale);
307
+ if (!cols.length) throw new PlotError('La tabla no tiene columnas.', 'Pasa filas con campos, o `columns: ["region", "ventas"]`.');
308
+ const X = ctx.textScale;
309
+ const frame = createFrame(spec, theme, { height: 400 });
310
+ const area = frame.layout([]);
311
+ const density = spec.density === 'compact' ? 0.65 : spec.density === 'comfortable' ? 1.3 : 1;
312
+ const padY = Math.round(9 * density);
313
+ const FS = BASE.cell, HS = BASE.head;
314
+ const LH = Math.round(FS * X * 1.38);
315
+ const groupF = typeof spec.group === 'string' && all.length && spec.group in all[0] ? spec.group : null;
316
+ const hl = spec.highlight === undefined || spec.highlight === null || spec.highlight === false ? null : new Set([].concat(spec.highlight).map(String));
317
+ const isHL = (r) => hl && labelF !== undefined && hl.has(String(r[labelF]));
318
+
319
+ // 1. Búsqueda (texto visible de todas las columnas, sin tildes ni mayúsculas).
320
+ let rows = all;
321
+ const query = typeof spec.query === 'string' ? spec.query.trim() : '';
322
+ if (query) {
323
+ const terms = norm(query).split(/\s+/).filter(Boolean);
324
+ rows = rows.filter((r) => {
325
+ const hay = norm(cols.map((c) => (c.type === 'rank' ? '' : cellText(c, r, locale, W) + ' ' + (r[c.field] ?? ''))).join(' ') + ' ' + (groupF ? r[groupF] : ''));
326
+ return terms.every((t) => hay.includes(t));
327
+ });
328
+ }
329
+ // 2. Orden (los vacíos siempre al final) y grupos en su orden de aparición.
330
+ const st = sortState(spec, cols);
331
+ if (st && st.col.type !== 'rank') {
332
+ const cmpT = compareText(locale);
333
+ const sgn = st.dir === 'asc' ? 1 : -1;
334
+ rows = [...rows].sort((a, b) => {
335
+ const ka = sortKey(st.col, a), kb = sortKey(st.col, b);
336
+ if (ka === null && kb === null) return a.__i - b.__i;
337
+ if (ka === null) return 1;
338
+ if (kb === null) return -1;
339
+ const d = typeof ka === 'number' && typeof kb === 'number' ? ka - kb : cmpT(String(ka), String(kb));
340
+ return d * sgn || a.__i - b.__i;
341
+ });
342
+ } else if (st && st.col.type === 'rank' && st.dir === 'desc') rows = [...rows].reverse();
343
+ if (groupF) {
344
+ const order = new Map(uniq(all.map((r) => String(r[groupF] ?? ''))).map((k, i) => [k, i]));
345
+ rows = [...rows].sort((a, b) => order.get(String(a[groupF] ?? '')) - order.get(String(b[groupF] ?? '')));
346
+ }
347
+ if (Number.isFinite(+spec.top) && +spec.top > 0) rows = rows.slice(0, +spec.top);
348
+ rows = rows.map((r, i) => ({ ...r, __rank: i + 1 }));
349
+
350
+ // 3. Páginas: todas las filas hasta 15; desde ahí, de a 10 (o `pageSize`).
351
+ const nRows = rows.length;
352
+ const psRaw = spec.pageSize ?? spec.perPage ?? (spec.paginate === false ? Infinity : undefined);
353
+ const pageSize = psRaw === false || psRaw === 0 || psRaw === Infinity ? Infinity : Number.isFinite(+psRaw) && +psRaw > 0 ? Math.round(+psRaw) : nRows > 15 ? 10 : Infinity;
354
+ const pages = pageSize === Infinity ? 1 : Math.max(1, Math.ceil(nRows / pageSize));
355
+ const page = Math.max(1, Math.min(pages, Math.round(+spec.page || 1)));
356
+ const pageRows = pageSize === Infinity ? rows : rows.slice((page - 1) * pageSize, page * pageSize);
357
+
358
+ // 4. Fila de totales: `total: true` (suma, o promedio en porcentajes y avances).
359
+ const totalOf = (c) => {
360
+ 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);
361
+ if (pick === undefined || pick === false || c.isLabel || c.type === 'rank') return null;
362
+ if (!['number', 'bar', 'heat', 'progress', 'delta'].includes(c.type) || c.kind === 'year') return null;
363
+ 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
364
+ if (!how) return null;
365
+ const v = aggregate(rows.map((r) => num(r[c.field])), how);
366
+ return Number.isFinite(v) ? v : null;
367
+ };
368
+ const hasTotal = spec.total !== false && cols.some((c) => totalOf(c) !== null);
369
+ const totalLabel = spec.totalLabel ?? (spec.total && typeof spec.total === 'object' ? spec.total.label : undefined) ?? W.total;
370
+
371
+ // 5. Anchos: se miden sobre TODAS las filas, así no saltan al cambiar de página.
372
+ const edge = Math.min(BASE.pad, Math.max(4, frame.pad - 6));
373
+ const tableX = area.x - edge, tableW = area.w + edge * 2;
374
+ const sample = all.length > 1500 ? all.filter((_, i) => i % Math.ceil(all.length / 1500) === 0) : all;
375
+ const P = BASE.pad;
376
+ const pillH = Math.round(Math.min(22, LH + 3));
377
+ for (const c of cols) {
378
+ const texts = c.type === 'rank' ? [String(Math.max(1, nRows))] : sample.map((r) => cellText(c, r, locale, W));
379
+ const weight = c.isLabel ? 500 : 400;
380
+ const widths = texts.map((t) => measure(t, FS, weight));
381
+ let tw = Math.max(0, ...widths);
382
+ if (hasTotal && c.isLabel) tw = Math.max(tw, measure(totalLabel, FS, 600));
383
+ const tot = hasTotal ? totalOf(c) : null;
384
+ 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));
385
+ const longest = Math.max(0, ...texts.map((t) => Math.max(0, ...String(t).split(/\s+/).map((w) => measure(w, FS, weight)))));
386
+ const avgLen = texts.reduce((a, t) => a + String(t).length, 0) / Math.max(1, texts.length);
387
+ c.lines = c.lines ?? (c.type === 'text' || c.type === 'link' ? (avgLen > 36 ? 3 : 2) : 1);
388
+ c.valueW = tw;
389
+ let nat, min;
390
+ switch (c.type) {
391
+ case 'bar': nat = tw + 8 + 120; min = tw + 8 + 36; break;
392
+ case 'heat': nat = tw + 16; min = nat; break;
393
+ case 'delta': nat = tw + 13; min = nat; break;
394
+ case 'sparkline': nat = 104; min = 56; break;
395
+ case 'progress': nat = tw + 8 + 96; min = tw + 8 + 40; break;
396
+ case 'badge': { const pw = Math.max(0, ...texts.map((t) => measure(t, 12, 500) + 18)); nat = pw; min = Math.min(pw, 84); break; }
397
+ case 'check': nat = 18; min = 18; break;
398
+ case 'text': case 'link': nat = tw; min = Math.min(tw, Math.max(longest, Math.min(tw, 96))); break;
399
+ default: nat = tw; min = tw;
400
+ }
401
+ const hw = measure(c.label, HS, 600) + (c.type === 'rank' ? 0 : 12); // + flecha de orden
402
+ const hmin = Math.max(...String(c.label).split(/\s+/).map((w) => measure(w, HS, 600))) + 12;
403
+ c.nat = Math.max(nat, Math.min(hw, Math.max(nat, hw))) + 2 * P;
404
+ c.min = Math.max(min, Math.min(hmin, hw)) + 2 * P;
405
+ if (Number.isFinite(+c.width) && +c.width > 0) { c.nat = c.min = +c.width; c.fixed = true; }
406
+ c.minW = Math.min(c.min, c.nat);
407
+ }
408
+ const sumMin = cols.reduce((a, c) => a + c.minW, 0);
409
+ const sumNat = cols.reduce((a, c) => a + c.nat, 0);
410
+ const cardsOpt = spec.cards ?? spec.stacked ?? 'auto';
411
+ const cards = cardsOpt === true || cardsOpt === 'always' || (cardsOpt !== false && cardsOpt !== 'never' && (sumMin > tableW + 0.5 || (tableW < 360 && cols.length > 3)));
412
+
413
+ if (!cards) {
414
+ for (const c of cols) c.w = c.nat;
415
+ if (sumNat <= tableW) {
416
+ // Espacio sobrante: a los indicadores (barras y avances), después al texto.
417
+ let extra = tableW - sumNat;
418
+ const tracks = cols.filter((c) => !c.fixed && (c.type === 'bar' || c.type === 'progress'));
419
+ for (const c of tracks) { const add = Math.min(extra / tracks.length, 110); c.w += add; }
420
+ extra = tableW - cols.reduce((a, c) => a + c.w, 0);
421
+ const flex = cols.filter((c) => !c.fixed && (c.type === 'text' || c.type === 'link' || c.isLabel));
422
+ const share = flex.length ? flex : cols.filter((c) => !c.fixed);
423
+ const base = share.reduce((a, c) => a + c.w, 0) || 1;
424
+ for (const c of share) c.w += (extra * c.w) / base;
425
+ } else {
426
+ // Falta espacio: ceden primero los minigráficos y las barras, después el
427
+ // texto (que se ajusta en líneas) y al final los estados y los números.
428
+ let need = sumNat - tableW;
429
+ const ORDER = [['sparkline', 'bar', 'progress'], ['text', 'link'], null];
430
+ for (const group of ORDER) {
431
+ if (need <= 0) break;
432
+ const take = cols.filter((c) => !c.fixed && (group ? group.includes(c.type) : true));
433
+ const slack = take.reduce((a, c) => a + (c.w - c.minW), 0);
434
+ if (slack <= 0) continue;
435
+ const k = Math.min(1, need / slack);
436
+ for (const c of take) { const d = (c.w - c.minW) * k; c.w -= d; need -= d; }
437
+ }
438
+ if (need > 0.5) { const k = tableW / cols.reduce((a, c) => a + c.w, 0); for (const c of cols) c.w *= k; }
439
+ }
440
+ let x = tableX;
441
+ for (const c of cols) { c.x = x; x += c.w; }
442
+ }
443
+
444
+ const body = [];
445
+ const accent = theme.categorical[0];
446
+ const negColor = theme.categorical[1] || theme.status.critical;
447
+ const isDark = theme.mode === 'dark';
448
+ const goodC = readable(theme.status.good, theme), badC = readable(theme.status.critical, theme);
449
+ const ink = theme.ink;
450
+ // Marcas fuera de foco (con `highlight`): tenues, pero visibles sobre el riel.
451
+ const dimC = mix(theme.muted, theme.surface, isDark ? 0.4 : 0.45);
452
+ const baseline = (top) => top + padY + Math.round(FS * X * 0.96);
453
+ const midLine = (top) => top + padY + LH / 2;
454
+
455
+ // Celda de estado: píldora clara del color, texto del mismo tono con contraste.
456
+ const pill = (x, cy, label, color, maxW, align = 'left') => {
457
+ const bg = color ? mix(color, theme.surface, isDark ? 0.72 : 0.86) : theme.tint;
458
+ const fg = color ? legible(isDark ? mix(color, '#FFFFFF', 0.25) : color, bg, isDark ? '#FFFFFF' : ink) : theme.lede;
459
+ const t = truncate(label, maxW - 18, 12, 500);
460
+ const w = Math.min(maxW, measure(t, 12, 500) + 18);
461
+ const x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;
462
+ return [
463
+ el('rect', { x: x0, y: cy - pillH / 2, width: w, height: pillH, rx: pillH / 2, fill: bg }),
464
+ color ? el('circle', { cx: x0 + 9, cy, r: 3, fill: fg }) : '',
465
+ text(x0 + (color ? 16 : 9), cy + 4.2 * X, t, { fill: fg, fontSize: 12, fontWeight: 500 }),
466
+ ];
467
+ };
468
+ // Color de celda: rampa del tema (o `colorScale` de la columna), sin llegar
469
+ // al extremo más oscuro para que la tabla no se vuelva un tablero de bloques.
470
+ const heatColor = (c, v) => {
471
+ 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)); }
472
+ const t = (v - c.hlo) / (c.hhi - c.hlo || 1);
473
+ return c.stops ? ramp(c.stops, 0.05 + 0.75 * t) : ramp(theme.sequential.slice(0, -1), 0.04 + 0.72 * t);
474
+ };
475
+ 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' });
476
+ const deltaColor = (c, n) => (n === 0 ? theme.muted : (n > 0) === (c.good !== 'down') ? goodC : badC);
477
+ const check = (cx, cy, yes) => yes
478
+ ? 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' })
479
+ : el('path', { d: `M${cx - 3.5},${cy}H${cx + 3.5}`, stroke: theme.muted, strokeWidth: 1.6, strokeLinecap: 'round' });
480
+ const spark = (x, cy, w, h, vals, muted) => {
481
+ if (vals.length < 2) return '';
482
+ const mn = Math.min(...vals), mx = Math.max(...vals);
483
+ const pts = vals.map((v, k) => [x + (k / (vals.length - 1)) * (w - 4) + 1, cy + h / 2 - ((v - mn) / (mx - mn || 1)) * h]);
484
+ const d = pts.map(([a, b], k) => (k ? 'L' : 'M') + a.toFixed(1) + ',' + b.toFixed(1)).join('');
485
+ const [lx, ly] = pts[pts.length - 1];
486
+ return [
487
+ 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' }),
488
+ el('path', { d, fill: 'none', stroke: muted ? dimC : theme.muted, strokeWidth: 1.5, strokeLinejoin: 'round', strokeLinecap: 'round' }),
489
+ el('circle', { cx: lx, cy: ly, r: 2.6, fill: muted ? dimC : accent }),
490
+ ];
491
+ };
492
+ 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))})` : '') : '');
493
+ const anyHL = hl && rows.some(isHL);
494
+
495
+ /** Contenido de una celda dentro de una caja (x, w) y una fila (top, alto). */
496
+ function cell(c, r, x, w, top, h, opts = {}) {
497
+ const out = [];
498
+ const ix = x + P, iw = Math.max(4, w - 2 * P);
499
+ const by = baseline(top), my = midLine(top);
500
+ const v = c.type === 'rank' ? r.__rank : r[c.field];
501
+ const weight = opts.total ? 600 : c.isLabel ? (opts.hl ? 600 : 500) : 400;
502
+ const muted = anyHL && !opts.hl && !opts.total;
503
+ const tx = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix;
504
+ const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
505
+ const put = (s, { x: px, ...attrs } = {}) => out.push(text(px ?? tx, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor, ...attrs }));
506
+ if (opts.total && c.isLabel) { put(truncate(totalLabel, iw, FS, 600)); return { out }; }
507
+ if (opts.total) {
508
+ const tv = totalOf(c);
509
+ if (tv === null) return { out };
510
+ const ts = c.type === 'delta' ? (tv > 0 ? '+' : tv < 0 ? '−' : '') + c.fmt(Math.abs(tv)) : c.fmt(tv);
511
+ // Barras y avances: el total bajo las cifras, no al final de la pista.
512
+ if ((c.type === 'bar' || c.type === 'progress') && c.align === 'right') put(ts, { x: ix + c.valueW, textAnchor: 'end' });
513
+ else put(ts);
514
+ return { out };
515
+ }
516
+ if (c.type !== 'rank' && isNil(v)) { put('—', { fill: theme.muted, fontWeight: 400 }); return { out }; }
517
+ const s = cellText(c, r, locale, W);
518
+ switch (c.type) {
519
+ case 'rank': put(s, { fill: theme.muted, fontWeight: 400 }); break;
520
+ case 'bar': {
521
+ const n = num(v);
522
+ if (!Number.isFinite(n)) { put(s); break; }
523
+ const vw = c.valueW;
524
+ const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
525
+ const span = c.hi - c.lo || 1;
526
+ const z = trackX + (-c.lo / span) * trackW;
527
+ const len = (Math.abs(n) / span) * trackW;
528
+ const bh = Math.max(6, Math.min(12, Math.round(LH * 0.58)));
529
+ const fill = muted ? dimC : n < 0 ? negColor : accent;
530
+ out.push(text(ix + vw, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
531
+ 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 }));
532
+ 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 }));
533
+ break;
534
+ }
535
+ case 'heat': {
536
+ const n = num(v);
537
+ if (!Number.isFinite(n)) { put(s); break; }
538
+ const fill = heatColor(c, n);
539
+ // Celda de color con aire alrededor; en tarjetas, solo una ficha en torno a la cifra.
540
+ if (opts.chip) {
541
+ const bw = Math.min(w - P, measure(s, FS, weight) + 16), right = x + w - P;
542
+ out.push(el('rect', { x: right - bw, y: top + 5, width: bw, height: Math.max(0, h - 10), rx: 5, fill }));
543
+ put(s, { fill: textOn(fill, theme), x: right - 8 });
544
+ break;
545
+ }
546
+ 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 }));
547
+ put(s, { fill: textOn(fill, theme) });
548
+ break;
549
+ }
550
+ case 'delta': {
551
+ const n = num(v);
552
+ if (!Number.isFinite(n)) { put(s); break; }
553
+ const color = deltaColor(c, n);
554
+ const tw = measure(s, FS, 500);
555
+ const right = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 + (tw + 11) / 2 : ix + tw + 11;
556
+ out.push(text(right, by, s, { fill: color, fontSize: FS, fontWeight: 500, textAnchor: 'end' }));
557
+ if (n !== 0) out.push(arrow(right - tw - 11, my, n > 0, color));
558
+ break;
559
+ }
560
+ case 'progress': {
561
+ const n = num(v);
562
+ if (!Number.isFinite(n)) { put(s); break; }
563
+ const t = c.hasTarget ? c.targetOf(r) : c.scale100 ? 100 : 1;
564
+ const ratio = Number.isFinite(t) && t ? n / t : NaN;
565
+ const vw = c.valueW;
566
+ const shown = c.hasTarget && Number.isFinite(ratio) ? c.pctFmt(ratio) : s;
567
+ out.push(text(ix + vw, by, shown, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
568
+ const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
569
+ out.push(el('rect', { x: trackX, y: my - 3, width: trackW, height: 6, rx: 3, fill: theme.tint2 }));
570
+ if (Number.isFinite(ratio) && ratio > 0) {
571
+ // Subir es bueno: llegar a la meta es verde. Bajar es bueno (gasto): bajo la meta verde, sobre ella crítico.
572
+ const goodUp = c.good !== 'down';
573
+ const fill = muted ? dimC : goodUp ? (ratio >= 1 ? theme.status.good : accent) : ratio > 1 ? theme.status.critical : theme.status.good;
574
+ out.push(el('rect', { x: trackX, y: my - 3, width: Math.max(6, trackW * Math.min(1, ratio)), height: 6, rx: 3, fill }));
575
+ }
576
+ return { out, tip: c.hasTarget && Number.isFinite(t) ? `${c.label}: ${c.fmt(n)} / ${c.fmt(t)} (${c.pctFmt(ratio)})` : null };
577
+ }
578
+ case 'sparkline': {
579
+ const vals = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : [];
580
+ if (vals.length < 2) { put(vals.length ? c.fmt(vals[0]) : '—', { fill: theme.muted }); break; }
581
+ const sw = Math.min(iw, 120), sh = Math.max(10, Math.min(18, h - 2 * padY - 2));
582
+ const sx = c.align === 'right' ? x + w - P - sw : c.align === 'center' ? x + (w - sw) / 2 : ix;
583
+ out.push(spark(sx, my, sw, sh, vals, muted));
584
+ return { out, tip: `${c.label}: ${sparkTip(c, vals)}` };
585
+ }
586
+ case 'badge': {
587
+ const color = c.badge.get(String(v));
588
+ out.push(...pill(c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix, my, s, color, iw, c.align));
589
+ return { out, tip: measure(s, 12, 500) + 18 > iw ? s : null };
590
+ }
591
+ case 'check': {
592
+ const yes = v === true || YES.test(String(v).trim());
593
+ const cx = c.align === 'right' ? x + w - P - 5 : c.align === 'center' ? x + w / 2 : ix + 5;
594
+ out.push(g({ role: 'img', 'aria-label': s }, [check(cx, my, yes), el('title', {}, esc(s))]));
595
+ break;
596
+ }
597
+ case 'link': {
598
+ const href = String(v).trim();
599
+ const lines = wrap(s, iw, FS, 400, c.lines);
600
+ const color = readable(accent, theme);
601
+ const t = lines.map((l, k) => text(tx, by + k * LH, l, { fill: color, fontSize: FS, textAnchor: anchor, textDecoration: 'underline' })).join('');
602
+ out.push(SAFE_URL.test(href) ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, t) : t);
603
+ return { out, lines: lines.length, tip: lines.join(' ') !== s ? s : null };
604
+ }
605
+ case 'text': case 'date': default: {
606
+ const lines = c.type === 'text' ? wrap(s, iw, FS, weight, c.lines) : [truncate(s, iw, FS, weight)];
607
+ lines.forEach((l, k) => out.push(text(tx, by + k * LH, l, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor })));
608
+ return { out, lines: lines.length, tip: lines.join(' ').replace(/…$/, '') !== s ? s : null };
609
+ }
610
+ }
611
+ return { out };
612
+ }
613
+
614
+ // Encabezado de grupo: banda tenue con el nombre y cuántas filas tiene.
615
+ const groupCounts = new Map();
616
+ if (groupF) for (const r of rows) groupCounts.set(String(r[groupF] ?? ''), (groupCounts.get(String(r[groupF] ?? '')) || 0) + 1);
617
+ const groupH = Math.round(LH + 12 * density + 4);
618
+ const groupRow = (name, top) => {
619
+ const n = groupCounts.get(name) || 0;
620
+ const lab = truncate(name || '—', area.w - 60, 12.5, 600);
621
+ return g({ class: 'pa-tgroup', role: 'row' }, [
622
+ el('rect', { x: tableX, y: top + 4, width: tableW, height: groupH - 6, rx: 6, fill: theme.tint }),
623
+ text(area.x, top + 4 + (groupH - 6) / 2 + 4.3 * X, lab, { fill: ink, fontSize: 12.5, fontWeight: 600, role: 'rowheader' }),
624
+ text(area.x + measure(lab, 12.5, 600) + 8, top + 4 + (groupH - 6) / 2 + 4.3 * X, String(n), { fill: theme.muted, fontSize: 12 }),
625
+ ]);
626
+ };
627
+
628
+ const tableRows = [];
629
+ let y = area.y;
630
+ // En el navegador, render() pone una caja de búsqueda real sobre esta franja.
631
+ if (spec._search) {
632
+ 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' }));
633
+ y += Math.round(32 * Math.max(1, 0.6 + 0.4 * X)) + 12;
634
+ }
635
+ const label = labelF !== undefined ? cols.find((c) => c.field === labelF) : null;
636
+ 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 });
637
+
638
+ if (!cards) {
639
+ // ── Encabezado ─────────────────────────────────────────────────────────
640
+ const HLH = Math.round(HS * X * 1.3);
641
+ const headLines = cols.map((c) => wrap(c.label, Math.max(10, c.w - 2 * P - (c.type === 'rank' ? 0 : 10)), HS, 600, 2));
642
+ const nHead = Math.max(1, ...headLines.map((l) => l.length));
643
+ const headH = nHead * HLH + Math.round(10 * Math.max(0.8, density)) + 4;
644
+ const heads = cols.map((c, i) => {
645
+ const lines = headLines[i];
646
+ const sorted = st && st.col === c;
647
+ // Barras y avances: el encabezado va sobre la cifra (alineada a la derecha), no sobre la pista.
648
+ const overValue = (c.type === 'bar' || c.type === 'progress') && c.align === 'right';
649
+ const headW = Math.max(...lines.map((l) => measure(l, HS, 600)));
650
+ 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;
651
+ const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
652
+ const y0 = y + headH - 9 - (lines.length - 1) * HLH;
653
+ const parts = [el('rect', { class: 'pa-thbg', x: c.x, y, width: c.w, height: headH, fill: 'transparent' })];
654
+ lines.forEach((l, k) => parts.push(text(ax, y0 + k * HLH, l, { fill: sorted ? ink : theme.lede, fontSize: HS, fontWeight: 600, textAnchor: anchor })));
655
+ // Flecha de orden: llena en la columna ordenada; tenue al pasar en el navegador.
656
+ const lw = measure(lines[lines.length - 1], HS, 600);
657
+ const ly = y0 + (lines.length - 1) * HLH - HS * X * 0.36;
658
+ const axx = c.align === 'right' ? ax - lw - 9 : c.align === 'center' ? ax + lw / 2 + 3 : ax + lw + 3;
659
+ if (c.type !== 'rank' && c.sortable !== false) {
660
+ const up = sorted ? st.dir === 'asc' : false;
661
+ 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`;
662
+ parts.push(el('path', { class: sorted ? 'pa-sort-on' : 'pa-sorthint', d, fill: sorted ? ink : theme.muted }));
663
+ }
664
+ const attrs = { class: 'pa-th', role: 'columnheader' };
665
+ if (c.type !== 'rank' && c.sortable !== false && spec.sortable !== false) {
666
+ attrs['data-sort'] = c.field;
667
+ attrs['aria-sort'] = sorted ? (st.dir === 'asc' ? 'ascending' : 'descending') : 'none';
668
+ attrs['data-num'] = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(c.type) ? '1' : null;
669
+ }
670
+ return g(attrs, parts);
671
+ });
672
+ tableRows.push(g({ class: 'pa-thead', role: 'row', 'aria-rowindex': 1 }, heads));
673
+ y += headH;
674
+ 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 }));
675
+
676
+ // ── Filas ──────────────────────────────────────────────────────────────
677
+ let lastGroup = null;
678
+ if (!pageRows.length) {
679
+ tableRows.push(text(area.x + area.w / 2, y + 30, query ? W.none : '—', { fill: theme.muted, fontSize: FS, textAnchor: 'middle' }));
680
+ y += 48;
681
+ }
682
+ pageRows.forEach((r, i) => {
683
+ if (groupF) {
684
+ const gname = String(r[groupF] ?? '');
685
+ if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
686
+ }
687
+ const h0 = LH + 2 * padY;
688
+ const made = cols.map((c) => cell(c, r, c.x, c.w, y, h0, { hl: isHL(r) }));
689
+ const nLines = Math.max(1, ...made.map((m) => m.lines || 1));
690
+ const h = h0 + (nLines - 1) * LH;
691
+ // Celdas de calor: ocupan el alto real de la fila.
692
+ 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;
693
+ const zebra = spec.striped && i % 2 === 1;
694
+ 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 })];
695
+ if (isHL(r)) parts.push(el('rect', { x: tableX, y: y + 5, width: 3, height: h - 10, rx: 1.5, fill: accent }));
696
+ made2.forEach((m, k) => {
697
+ const c = cols[k];
698
+ parts.push(g({ role: c.isLabel ? 'rowheader' : 'cell', 'data-tip': m.tip ? tip(label ? String(r[labelF] ?? '') : '', m.tip) : null }, m.out));
699
+ });
700
+ tableRows.push(g(rowAttrs(r, i), parts));
701
+ y += h;
702
+ 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 }));
703
+ });
704
+ if (hasTotal) {
705
+ const h = LH + 2 * padY + 2;
706
+ 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 }));
707
+ const firstText = cols.find((c) => c.isLabel) || cols.find((c) => c.type === 'text') || cols[0];
708
+ 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 })));
709
+ tableRows.push(g({ class: 'pa-ttotal', role: 'row' }, made.map((m, k) => g({ role: cols[k] === firstText ? 'rowheader' : 'cell' }, m.out))));
710
+ y += h + 2;
711
+ }
712
+ } else {
713
+ // ── Tarjetas (pantallas angostas): una fila = un bloque ────────────────
714
+ const labelCol = label || cols.find((c) => c.type === 'text') || null;
715
+ const rankCol = cols.find((c) => c.type === 'rank');
716
+ const mainCol = cols.find((c) => c.type === 'bar');
717
+ const rest = cols.filter((c) => c !== labelCol && c !== rankCol && c !== mainCol);
718
+ const KL = 12.5; // tamaño de las etiquetas
719
+ const lineH = Math.round(Math.max(LH, pillH) + 6);
720
+ const CP = 12;
721
+ const keyW = Math.min(area.w * 0.46, Math.max(60, ...rest.map((c) => measure(c.label, KL) + 12)));
722
+ 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; }
723
+ let lastGroup = null;
724
+ const cardOf = (r, i, total = false) => {
725
+ const top = y;
726
+ const parts = [];
727
+ const hlr = !total && isHL(r);
728
+ const muted = anyHL && !hlr && !total;
729
+ let cy = top + CP;
730
+ // Título: nombre de la fila (y el número de rango); a la derecha, la medida principal.
731
+ const rankW = rankCol && !total ? measure(String(r.__rank), 12) + 8 : 0;
732
+ const mainS = mainCol ? (total ? (totalOf(mainCol) !== null ? mainCol.fmt(totalOf(mainCol)) : '') : cellText(mainCol, r, locale, W)) : '';
733
+ const mainW = mainS ? measure(mainS, 14, 600) + 12 : 0;
734
+ const titleS = total ? totalLabel : labelCol ? cellText(labelCol, r, locale, W) || '—' : `#${r.__rank}`;
735
+ const tl = wrap(titleS, area.w - rankW - mainW, 14, 600, 2);
736
+ const TLH = Math.round(14 * X * 1.3);
737
+ if (rankW) parts.push(text(area.x, cy + 14 * X * 0.92, String(r.__rank), { fill: theme.muted, fontSize: 12 }));
738
+ 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' })));
739
+ if (mainS) parts.push(text(area.x + area.w, cy + 14 * X * 0.92, mainS, { fill: ink, fontSize: 14, fontWeight: 600, textAnchor: 'end' }));
740
+ cy += tl.length * TLH;
741
+ if (mainCol && !total) {
742
+ const n = num(r[mainCol.field]);
743
+ if (Number.isFinite(n)) {
744
+ const span = mainCol.hi - mainCol.lo || 1;
745
+ const z = area.x + (-mainCol.lo / span) * area.w;
746
+ const len = (Math.abs(n) / span) * area.w;
747
+ cy += 5;
748
+ parts.push(el('rect', { x: area.x, y: cy, width: area.w, height: 5, rx: 2.5, fill: theme.tint }));
749
+ 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 }));
750
+ cy += 5;
751
+ }
752
+ }
753
+ cy += 6;
754
+ for (const c of rest) {
755
+ if (total && totalOf(c) === null) continue;
756
+ const long = c.type === 'text' && !total && measure(cellText(c, r, locale, W), FS) > area.w - keyW;
757
+ const rowTop = cy + (lineH - (LH + 2 * padY)) / 2;
758
+ parts.push(text(area.x, baseline(rowTop), truncate(c.label, long ? area.w : keyW - 8, KL), { fill: theme.lede, fontSize: KL }));
759
+ if (long) {
760
+ const lines = wrap(cellText(c, r, locale, W), area.w, FS, 400, 4);
761
+ lines.forEach((l, k) => parts.push(text(area.x, cy + lineH + LH * (k + 0.75), l, { fill: ink, fontSize: FS })));
762
+ cy += lineH + lines.length * LH + 2;
763
+ continue;
764
+ }
765
+ const cc = { ...c, align: 'right' };
766
+ // Barras y avances: la cifra y una pista corta, pegadas a la derecha.
767
+ const boxW = cc.type === 'progress' || cc.type === 'bar' ? Math.min(area.w - keyW, cc.valueW + 8 + 110) : area.w - keyW;
768
+ const vx = area.x + area.w - boxW - P, vw = boxW + 2 * P;
769
+ const m = cell(cc, r, vx, vw, rowTop, LH + 2 * padY, { total, hl: hlr, chip: true });
770
+ parts.push(g({ role: 'cell', 'data-tip': m.tip ? tip(titleS, m.tip) : null }, m.out));
771
+ cy += lineH;
772
+ }
773
+ cy += CP - 4;
774
+ const h = cy - top;
775
+ const bg = el('rect', { class: 'pa-trbg', x: tableX, y: top + 2, width: tableW, height: h - 4, rx: 8, fill: hlr ? theme.tint : 'transparent' });
776
+ const mark = hlr ? el('rect', { x: tableX, y: top + 8, width: 3, height: h - 16, rx: 1.5, fill: accent }) : '';
777
+ y = cy;
778
+ return g(total ? { class: 'pa-ttotal', role: 'row' } : rowAttrs(r, i), [bg, mark, ...parts]);
779
+ };
780
+ pageRows.forEach((r, i) => {
781
+ if (groupF) {
782
+ const gname = String(r[groupF] ?? '');
783
+ if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
784
+ }
785
+ tableRows.push(cardOf(r, i));
786
+ 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 }));
787
+ });
788
+ if (hasTotal) tableRows.push(cardOf({}, -1, true));
789
+ }
790
+
791
+ const colCount = cols.length;
792
+ 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));
793
+
794
+ // ── Paginación: símbolos y cifras, sin palabras ─────────────────────────
795
+ if (pages > 1) {
796
+ y += 14;
797
+ const B = Math.round(28 * Math.max(1, 0.55 + 0.45 * X));
798
+ const cy = y + B / 2;
799
+ const from = (page - 1) * pageSize + 1, to = Math.min(nRows, page * pageSize);
800
+ const fmtI = makeFormat({ type: 'number', decimals: 0 }, locale, []);
801
+ const rangeS = `${fmtI(from)}–${fmtI(to)} / ${fmtI(nRows)}`;
802
+ const items = pagerItems(page, pages);
803
+ const rangeW = measure(rangeS, 12);
804
+ const btnW = (s) => Math.max(B, measure(s, 12.5, 600) + 14);
805
+ const full = items.reduce((a, it) => a + (it === '…' ? 14 : btnW(fmtI(it)) + 2), 0) + 2 * (B + 2);
806
+ const compact = full + rangeW + 16 > area.w;
807
+ const parts = [];
808
+ if (!compact || rangeW + 16 + 2 * B + measure(`${page} / ${pages}`, 12.5, 600) + 16 < area.w) {
809
+ parts.push(text(area.x, cy + 4.2 * X, rangeS, { fill: theme.muted, fontSize: 12 }));
810
+ }
811
+ let x = area.x + area.w;
812
+ const btn = (n, content, label, opts = {}) => {
813
+ const w = opts.w ?? B;
814
+ x -= w;
815
+ const disabled = n === null;
816
+ 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 };
817
+ 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)]);
818
+ x -= 2;
819
+ return out;
820
+ };
821
+ 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' });
822
+ 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' });
823
+ const out = [];
824
+ out.push(btn(page < pages ? page + 1 : null, chev(1), `${W.next} (${W.page} ${page + 1})`, { arrow: 'next' }));
825
+ if (compact) {
826
+ const s = `${fmtI(page)} / ${fmtI(pages)}`;
827
+ const w = measure(s, 12.5, 600) + 16;
828
+ x -= w;
829
+ out.push(text(x + w / 2, cy + 4.3 * X, s, { fill: ink, fontSize: 12.5, fontWeight: 600, textAnchor: 'middle' }));
830
+ x -= 2;
831
+ } else {
832
+ for (const it of [...items].reverse()) {
833
+ if (it === '…') { x -= 14; out.push(text(x + 7, cy + 4.3 * X, '…', { fill: theme.muted, fontSize: 12.5, textAnchor: 'middle' })); x -= 2; continue; }
834
+ const s = fmtI(it);
835
+ out.push(btn(it, num0(s, it === page), `${W.page} ${it}`, { current: it === page, w: btnW(s) }));
836
+ }
837
+ }
838
+ out.push(btn(page > 1 ? page - 1 : null, chev(-1), `${W.prev} (${W.page} ${page - 1})`, { arrow: 'prev' }));
839
+ parts.push(...out);
840
+ body.push(g({ class: 'pa-pager', role: 'navigation', 'aria-label': `${W.page} ${page} ${W.of} ${pages}` }, parts));
841
+ y += B;
842
+ }
843
+ if (spec.height === undefined) frame.setHeight(y + 12 + frame.chrome);
844
+
845
+ // ── Texto alternativo ────────────────────────────────────────────────────
846
+ const desc = describeTable({ lang, cols, rows, nRows, page, pages, st, label, query });
847
+ 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}`
848
+ + `.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}}`
849
+ + `.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}}`
850
+ + `.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}}`;
851
+ return frame.finish(body, { desc, type: 'table', role: 'figure', css });
852
+ }
853
+
854
+ /** Botones de página: 1 … 4 5 6 … 20 (como mucho 7 entradas). */
855
+ export function pagerItems(page, pages) {
856
+ if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
857
+ if (page <= 4) return [1, 2, 3, 4, 5, '…', pages];
858
+ if (page >= pages - 3) return [1, '…', pages - 4, pages - 3, pages - 2, pages - 1, pages];
859
+ return [1, '…', page - 1, page, page + 1, '…', pages];
860
+ }
861
+
862
+ function describeTable({ lang, cols, rows, nRows, page, pages, st, label, query }) {
863
+ const T = {
864
+ 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' },
865
+ 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' },
866
+ 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' },
867
+ 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' },
868
+ 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' },
869
+ }[lang];
870
+ const names = cols.filter((c) => c.type !== 'rank').map((c) => c.label);
871
+ let s = `${T.t} ${nRows} ${T.r} ${T.and} ${names.length} ${T.c}: ${names.join(', ')}.`;
872
+ if (query) s += ` ${T.q} "${query}".`;
873
+ if (pages > 1) s += ` ${T.p} ${page} ${T.of} ${pages}.`;
874
+ if (st) s += ` ${T.s} ${st.col.label}, ${st.dir === 'desc' ? T.desc : T.asc}.`;
875
+ const m = cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat'].includes(c.type) && !c.isLabel && c.kind === 'number');
876
+ if (m && label && rows.length > 1) {
877
+ let best = null, worst = null;
878
+ 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]; }
879
+ 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])}).`;
880
+ }
881
+ return s;
882
+ }