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,460 @@
1
+ // Carta Gantt: tareas como barras en un eje de tiempo, agrupadas por fase.
2
+ //
3
+ // plot({ type: 'gantt', data, task: 'tarea', start: 'inicio', end: 'fin',
4
+ // group: 'fase', progress: 'avance', dependsOn: 'depende', today: '2026-03-12' })
5
+ //
6
+ // Decisiones de diseño: el eje de tiempo va arriba (se lee como un
7
+ // calendario); cada fase abre con su nombre y una barra-resumen fina de su
8
+ // mismo color, así no hace falta leyenda; el avance se ve como la parte
9
+ // llena de la barra (lo pendiente queda en un tono claro del mismo color);
10
+ // los hitos son rombos con su fecha al lado; las dependencias, flechas
11
+ // finas en ángulo recto. Las fechas de término son inclusivas: una tarea
12
+ // del 3 al 5 de marzo dura 3 días.
13
+ //
14
+ // `collapsed: ["Fase 2"]` (o `true`) resume una fase en su cabecera; en el
15
+ // navegador un clic en la fila la abre o la cierra (data-group /
16
+ // data-collapsed). `critical: true` marca la ruta crítica y, en planes de
17
+ // hasta 120 días, los fines de semana van sombreados (`weekends: false`).
18
+ import { el, g, text, tip } from '../svg.js';
19
+ import { measure, truncate } from '../text.js';
20
+ import { makeFormat, parseDate, formatDate, formatNumber } from '../format.js';
21
+ import { timeTicks, niceStep, linear } from '../scale.js';
22
+ import { colorMap, ramp, textOn, readable } from '../theme.js';
23
+ import { ensureData, requireField, num, uniq, fieldsOf, fieldKind, PlotError } from '../data.js';
24
+ import { xLabels } from '../axes.js';
25
+ import { createFrame, hash } from '../frame.js';
26
+ import { dashedMatcher, hatcher, dashedAttrs, dashedDefaultLabel } from '../dashed.js';
27
+
28
+ const DAY = 864e5;
29
+ const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
30
+ const TRUE = /^(true|1|s[ií]|yes|y|x|✓)$/i;
31
+ const truthy = (v) => v === true || v === 1 || (typeof v === 'string' && TRUE.test(v.trim()));
32
+ const isDay = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v.trim());
33
+ const blank = (v) => v === undefined || v === null || v === '';
34
+
35
+ /** Lista de dependencias: "A", ["A", "B"] o "A, B". */
36
+ const depsOf = (v) => (blank(v) ? [] : Array.isArray(v) ? v.map(String) : String(v).split(/\s*[,;|]\s*/)).map((s) => s.trim()).filter(Boolean);
37
+
38
+ export function gantt(spec, theme) {
39
+ const locale = spec.locale || 'es';
40
+ const en = String(locale).startsWith('en');
41
+ const data = ensureData(spec);
42
+ if (!data.length) throw new PlotError(T(locale, 'La carta Gantt no tiene tareas.', 'The Gantt chart has no tasks.'), 'data: [{ task: "Diseño", start: "2026-03-02", end: "2026-03-20" }]');
43
+ const fields = fieldsOf(data);
44
+ const kind = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
45
+ const pick = (re, ok = () => true) => fields.find((f) => re.test(f) && ok(f));
46
+ const timeLike = (f) => kind[f] === 'date' || kind[f] === 'number' || kind[f] === 'year';
47
+ const taskF = spec.task ?? spec.label ?? (typeof spec.y === 'string' ? spec.y : null)
48
+ ?? pick(/^(task|tarea|actividad|activity|item|nombre|name|hito|entregable|deliverable)/i, (f) => kind[f] === 'category')
49
+ ?? fields.find((f) => kind[f] === 'category');
50
+ const startF = spec.start ?? (typeof spec.x === 'string' ? spec.x : null) ?? pick(/^(start|inicio|desde|begin|comienzo|from)/i) ?? fields.find((f) => kind[f] === 'date');
51
+ const durF = spec.duration ?? null;
52
+ const endF = spec.end ?? spec.x2 ?? (durF ? null : pick(/^(end|fin|hasta|finish|termino|término|due|vence|to)$/i) ?? fields.find((f) => f !== startF && kind[f] === 'date'));
53
+ const groupF = spec.group ?? spec.series ?? spec.hue ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null)
54
+ ?? pick(/^(phase|fase|group|grupo|stream|workstream|etapa|equipo|team|area|área|categoria|categoría)$/i, (f) => f !== taskF && kind[f] === 'category');
55
+ const progF = spec.progress ?? pick(/^(progress|avance|progreso|complete|completado|done|pct)$/i);
56
+ const depF = spec.dependsOn ?? spec.after ?? pick(/^(depends|dependson|depends_on|after|predecessor|predecesor|depende|despues|después)/i);
57
+ const msF = spec.milestone ?? pick(/^(milestone|hito)$/i, (f) => f !== taskF);
58
+ requireField(data, taskF, 'task');
59
+ requireField(data, startF, 'start');
60
+ if (endF) requireField(data, endF, 'end');
61
+ if (durF) requireField(data, durF, 'duration');
62
+ if (groupF) requireField(data, groupF, 'group');
63
+ if (!startF) throw new PlotError(T(locale, 'La carta Gantt necesita una fecha de inicio por tarea.', 'The Gantt chart needs a start date per task.'), 'Indica `start` (y `end` o `duration`): start: "inicio", end: "fin"');
64
+
65
+ const isTime = kind[startF] === 'date' || (endF && kind[endF] === 'date');
66
+ const toT = (v) => (isTime ? parseDate(v) : num(v));
67
+ // ── Tareas ──────────────────────────────────────────────────────────────
68
+ const tasks = [];
69
+ for (const [i, r] of data.entries()) {
70
+ const name = String(r[taskF] ?? '').trim();
71
+ const s = toT(r[startF]);
72
+ if (!name || !Number.isFinite(s)) continue;
73
+ let e;
74
+ const rawEnd = endF ? r[endF] : undefined;
75
+ if (durF && Number.isFinite(num(r[durF]))) e = s + num(r[durF]) * (isTime ? DAY : 1);
76
+ else if (!blank(rawEnd)) e = toT(rawEnd) + (isTime && isDay(rawEnd) ? DAY : 0); // fin inclusivo
77
+ const milestone = (msF && truthy(r[msF])) || blank(rawEnd) && !durF || (!durF && String(rawEnd).trim() === String(r[startF]).trim() && !isDay(rawEnd));
78
+ if (!milestone && !Number.isFinite(e)) continue;
79
+ let p = progF ? num(r[progF]) : NaN;
80
+ if (Number.isFinite(p) && (p > 1 || String(r[progF]).includes('%'))) p /= 100;
81
+ tasks.push({
82
+ i, row: r, name, start: s, end: milestone ? s : Math.max(e, s), milestone,
83
+ group: groupF ? String(r[groupF] ?? '') : null,
84
+ progress: Number.isFinite(p) ? Math.max(0, Math.min(1, p)) : null,
85
+ deps: depF ? depsOf(r[depF]) : [],
86
+ });
87
+ }
88
+ if (!tasks.length) throw new PlotError(T(locale, 'Ninguna tarea tiene fechas válidas.', 'No task has valid dates.'), T(locale, 'Usa fechas como "2026-03-02" o números (semanas) en `start` y `end`.', 'Use dates like "2026-03-02" or numbers (weeks) in `start` and `end`.'));
89
+ if (spec.sort === 'start') tasks.sort((a, b) => a.start - b.start || a.end - b.end);
90
+
91
+ // ── Filas: fase (cabecera con barra-resumen) y sus tareas ───────────────
92
+ // Las fases se pueden contraer (`collapsed`): la cabecera muestra entonces
93
+ // una barra-resumen gruesa con el avance de la fase y sus hitos. El alto de
94
+ // fila, la columna de nombres y el eje se calculan con TODAS las filas, así
95
+ // nada salta al expandir o contraer en el navegador.
96
+ const groups = groupF ? uniq(tasks.map((t) => t.group)) : [];
97
+ const useHeaders = groups.length > 0 && spec.groupHeaders !== false;
98
+ const collapsible = useHeaders && spec.collapsible !== false;
99
+ const collapsedSet = new Set(!useHeaders ? [] : spec.collapsed === true ? groups
100
+ : [].concat(spec.collapsed ?? []).filter((v) => v !== false && !blank(v)).map(String).filter((v) => groups.includes(v)));
101
+ const cmap = colorMap(groups.length ? groups : ['__all'], theme, spec.colors);
102
+ const colorOf = (t) => (groups.length ? cmap.get(t.group) : (spec.colors && spec.colors[t.name]) || theme.categorical[0]);
103
+ const allRows = [], rows = [];
104
+ if (useHeaders) {
105
+ for (const gname of groups) {
106
+ const ts = tasks.filter((t) => t.group === gname);
107
+ const head = { type: 'group', name: gname, start: Math.min(...ts.map((t) => t.start)), end: Math.max(...ts.map((t) => t.end)), color: cmap.get(gname), tasks: ts, collapsed: collapsedSet.has(gname) };
108
+ allRows.push(head); rows.push(head);
109
+ for (const t of ts) { allRows.push({ type: 'task', t }); if (!head.collapsed) rows.push({ type: 'task', t }); }
110
+ }
111
+ } else for (const t of tasks) { allRows.push({ type: 'task', t }); rows.push({ type: 'task', t }); }
112
+ const hiddenGroup = (t) => (t && useHeaders && collapsedSet.has(t.group) ? t.group : null);
113
+
114
+ // ── Dominio ─────────────────────────────────────────────────────────────
115
+ const todayV = spec.today === true ? (isTime ? Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()) : NaN) : blank(spec.today) || spec.today === false ? NaN : toT(spec.today);
116
+ const refs = (spec.refLines || []).filter((r) => r && r.x !== undefined).map((r) => ({ ...r, t: toT(r.x) })).filter((r) => Number.isFinite(r.t));
117
+ let t0 = Math.min(...tasks.map((t) => t.start)), t1 = Math.max(...tasks.map((t) => t.end));
118
+ for (const v of [todayV, ...refs.map((r) => r.t)]) if (Number.isFinite(v)) { t0 = Math.min(t0, v); t1 = Math.max(t1, v); }
119
+ if (Array.isArray(spec.xDomain)) { const [a, b] = spec.xDomain.map(toT); if (Number.isFinite(a)) t0 = a; if (Number.isFinite(b)) t1 = b; }
120
+ if (t1 <= t0) t1 = t0 + (isTime ? 7 * DAY : 1);
121
+ const padT = (t1 - t0) * 0.015;
122
+ t0 -= padT; t1 += padT;
123
+ // Fechas al día: la holgura de la ruta crítica se cuenta en días hábiles
124
+ // y los fines de semana se sombrean si el plan es corto.
125
+ const byDay = isTime && tasks.every((t) => isDay(t.row[startF]) && (t.milestone || !!durF || isDay(t.row[endF])));
126
+
127
+ // ── Ruta crítica: pasada hacia atrás sobre dependsOn ────────────────────
128
+ // Las fechas son fijas, así que la holgura total de una tarea es lo que
129
+ // puede atrasarse sin mover el fin del proyecto: el mínimo entre su
130
+ // distancia al fin y (brecha a cada sucesora + holgura de esa sucesora).
131
+ // Holgura cero = ruta crítica.
132
+ const critical = new Set(), critLinks = new Set();
133
+ const workGap = (a, b) => {
134
+ if (!byDay || b <= a) return isTime ? (b - a) / DAY : b - a;
135
+ let k = 0;
136
+ for (let d = a; d < b && k < 4000; d += DAY) { const w = new Date(d).getUTCDay(); if (w !== 0 && w !== 6) k++; }
137
+ return k;
138
+ };
139
+ if (spec.critical) {
140
+ const byName = new Map(tasks.map((t) => [t.name, t]));
141
+ const succ = new Map();
142
+ for (const t of tasks) for (const d of t.deps) if (byName.has(d)) { if (!succ.has(d)) succ.set(d, []); succ.get(d).push(t); }
143
+ const finish = Math.max(...tasks.map((t) => t.end));
144
+ const slack = new Map(), seen = new Set();
145
+ const slackOf = (t) => {
146
+ if (slack.has(t.name)) return slack.get(t.name);
147
+ if (seen.has(t.name)) return Infinity; // ciclo: no hay ruta
148
+ seen.add(t.name);
149
+ let v = workGap(t.end, finish);
150
+ for (const s of succ.get(t.name) || []) v = Math.min(v, workGap(t.end, s.start) + slackOf(s));
151
+ seen.delete(t.name);
152
+ slack.set(t.name, v);
153
+ return v;
154
+ };
155
+ for (const t of tasks) if (slackOf(t) <= 1e-9) critical.add(t.name);
156
+ for (const t of tasks) for (const d of t.deps) {
157
+ const a = byName.get(d);
158
+ if (a && critical.has(d) && critical.has(t.name) && workGap(a.end, t.start) + slackOf(t) <= 1e-9) critLinks.add(d + '\u0000' + t.name);
159
+ }
160
+ }
161
+ const critColor = spec.criticalColor || theme.status?.critical || theme.categorical[1];
162
+
163
+ // ── Marco ───────────────────────────────────────────────────────────────
164
+ const gid = 'pa-gantt-' + Math.abs(hash(String(spec.title) + tasks.length)).toString(36);
165
+ const H = hatcher(theme, gid);
166
+ const dm = dashedMatcher(spec, data, taskF, groupF);
167
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
168
+ const isDashed = (t) => !!dm && dm(t.name, t.group ?? undefined);
169
+ const legend = [];
170
+ if (groups.length && !useHeaders) for (const gname of groups) legend.push({ label: gname, color: cmap.get(gname), shape: 'square' });
171
+ if (tasks.some(isDashed) && spec.legend !== false && dashedLabel) legend.push({ label: dashedLabel, color: theme.muted, fill: H.fill(theme.muted), stroke: H.stroke(theme.muted), shape: 'square', dashed: true, key: 'dashed' });
172
+ if (critical.size && spec.legend !== false) legend.push({ label: T(locale, 'Ruta crítica', 'Critical path'), color: critColor, shape: 'line', key: 'critical' });
173
+ const n = allRows.length;
174
+ const rowH0 = spec.rowHeight ?? (n > 36 ? 20 : n > 22 ? 24 : 30);
175
+ const frame = createFrame(spec, theme, { height: 200 + n * rowH0 });
176
+ const area = frame.layout(legend);
177
+ const axisH = 26;
178
+ let rowH = rowH0;
179
+ if (spec.height !== undefined) rowH = Math.max(14, (area.h - axisH - 6) / Math.max(1, n));
180
+ const rowHeight = (r) => (r.type === 'group' ? rowH * (spec.groupHeaders === 'compact' ? 0.8 : 1.02) : rowH);
181
+ const plotH = rows.reduce((a, r) => a + rowHeight(r), 0);
182
+ if (spec.height === undefined) frame.setHeight(area.y + axisH + plotH + 8 + frame.chrome);
183
+ const fs = rowH < 22 ? 11 : 12.5;
184
+ // Columna de nombres: un margen para el triángulo de las fases y las
185
+ // tareas alineadas con el nombre de su fase.
186
+ const GUT = collapsible ? 14 : 0;
187
+ const IND = useHeaders ? (collapsible ? GUT : 12) : 0;
188
+ // (SVG descarta el espacio inicial de un texto: el hueco va en la posición.)
189
+ const countText = (r) => `· ${r.tasks.length}`;
190
+ const countW = (r) => measure(countText(r), fs) + 5;
191
+ const labelW = Math.min(area.w * (area.w < 480 ? 0.42 : 0.34), Math.max(...allRows.map((r) => (r.type === 'group' ? GUT + measure(r.name, fs, 600) + countW(r) : measure(r.t.name, fs) + IND)))) + 16;
192
+ const inner = { x: area.x + labelW, y: area.y + axisH, w: area.w - labelW, h: plotH };
193
+ const x = linear(t0, t1, inner.x, inner.x + inner.w);
194
+
195
+ // ── Eje de tiempo (arriba) y grilla ─────────────────────────────────────
196
+ const fmtDay = (ms, withYear = true) => (isTime ? formatDate(ms, 'day', locale, withYear) : formatNumber(ms, Number.isInteger(ms) ? 0 : 1, locale, true));
197
+ const body = [];
198
+ const grid = [];
199
+ // Fines de semana: bandas tenues detrás de la grilla en planes cortos.
200
+ if (byDay && spec.weekends !== false && t1 - t0 <= 121 * DAY && inner.w / ((t1 - t0) / DAY) >= 3) {
201
+ const wk = [];
202
+ let d = Math.floor(t0 / DAY) * DAY;
203
+ while (new Date(d).getUTCDay() !== 6) d += DAY;
204
+ for (; d < t1; d += 7 * DAY) {
205
+ const a = Math.max(inner.x, x(d)), b = Math.min(inner.x + inner.w, x(d + 2 * DAY));
206
+ if (b - a > 0.5) wk.push(el('rect', { x: +a.toFixed(2), y: inner.y, width: +(b - a).toFixed(2), height: inner.h, fill: theme.tint, opacity: theme.mode === 'dark' ? 0.55 : 0.42 }));
207
+ }
208
+ if (wk.length) body.push(g({ class: 'pa-weekends' }, wk));
209
+ }
210
+ // Las etiquetas del eje que chocarían con la de "Hoy" se omiten.
211
+ const pills = [];
212
+ const pillLabel = (v, label) => { const px = x(v), lw = measure(label, 11, 600) + 10; const lx = Math.min(Math.max(px - lw / 2, inner.x), inner.x + inner.w - lw); pills.push([lx - 6, lx + lw + 6]); return { px, lw, lx }; };
213
+ const todayLabel = Number.isFinite(todayV) ? `${T(locale, 'Hoy', 'Today')} · ${fmtDay(todayV, false)}` : null;
214
+ if (todayLabel) pillLabel(todayV, todayLabel);
215
+ for (const r of refs) pillLabel(r.t, r.label || fmtDay(r.t, false));
216
+ const freeOf = (items) => items.filter((tk) => { const w = measure(tk.label, theme.type?.axis ?? 11.5) / 2; return !pills.some(([a, b]) => tk.pos + w > a && tk.pos - w < b); });
217
+ const timeItems = (count) => timeTicks(t0, t1, count, locale).ticks.map((tk) => ({ pos: x(tk.value), label: tk.label, v: tk.value }));
218
+ let tickItems;
219
+ if (isTime) {
220
+ const c0 = Math.max(2, Math.floor(inner.w / 58));
221
+ tickItems = timeItems(c0);
222
+ // En gráficos angostos la etiqueta de "Hoy" puede tapar todas las marcas:
223
+ // se prueba con un paso más fino hasta que queden al menos dos.
224
+ for (let c = c0 + 1; c <= c0 + 4 && freeOf(tickItems).length < 2; c++) { const more = timeItems(c); if (freeOf(more).length >= 2) tickItems = more; }
225
+ } else {
226
+ let st = niceStep(t1 - t0, Math.max(2, Math.floor(inner.w / 70)));
227
+ // Semanas, días o meses enteros: marcas enteras.
228
+ if (tasks.every((t) => Number.isInteger(t.start) && Number.isInteger(t.end))) st = Math.max(1, Math.round(st));
229
+ const fmtN = makeFormat(spec.format, locale, [t0, t1]);
230
+ tickItems = [];
231
+ for (let v = Math.ceil(t0 / st) * st; v <= t1 + 1e-9; v += st) tickItems.push({ pos: x(v), label: fmtN(+v.toPrecision(12)), v });
232
+ }
233
+ for (const tk of tickItems) {
234
+ const px = Math.round(tk.pos) + 0.5;
235
+ grid.push(el('line', { x1: px, x2: px, y1: inner.y - 4, y2: inner.y + inner.h, stroke: theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
236
+ }
237
+ body.push(g({ class: 'pa-grid' }, grid));
238
+ const freeTicks = freeOf(tickItems);
239
+ body.push(g({ class: 'pa-axis pa-axis-x' }, [xLabels(freeTicks, inner, theme, { y: area.y + 13 })]));
240
+ if (spec.xLabel) body.push(text(inner.x + inner.w, area.y - 2, spec.xLabel, { fill: theme.muted, fontSize: 11, textAnchor: 'end' }));
241
+ body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(inner.y - 4) + 0.5, y2: Math.round(inner.y - 4) + 0.5, stroke: theme.axis, strokeWidth: 1, shapeRendering: 'crispEdges' }));
242
+
243
+ // ── Filas ──────────────────────────────────────────────────────────────
244
+ const fmtEnd = (t) => fmtDay(isTime && !t.milestone && isDay(t.row[endF]) ? t.end - DAY : t.end);
245
+ const lenOf = (t) => (isTime ? Math.round((t.end - t.start) / DAY) : +(t.end - t.start).toPrecision(6));
246
+ const lenText = (t) => (isTime ? `${lenOf(t)} ${T(locale, lenOf(t) === 1 ? 'día' : 'días', lenOf(t) === 1 ? 'day' : 'days')}` : `${formatNumber(lenOf(t), Number.isInteger(lenOf(t)) ? 0 : 1, locale, true)}${spec.unit ? ' ' + spec.unit : ''}`);
247
+ const pct = (p) => `${formatNumber(p * 100, 0, locale)}%`;
248
+ /** Avance ponderado por duración (null si ninguna tarea lo informa). */
249
+ const progressOf = (ts) => {
250
+ const w = ts.filter((t) => t.progress !== null && !t.milestone);
251
+ return w.length ? w.reduce((a, t) => a + t.progress * lenOf(t), 0) / (w.reduce((a, t) => a + lenOf(t), 0) || 1) : null;
252
+ };
253
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
254
+ const dim = (t) => hl && !hl.has(t.name) && !hl.has(String(t.group));
255
+ const pos = new Map(); // tarea (o fase contraída) → geometría, para las dependencias
256
+ // Vínculos a dibujar. Una tarea de una fase contraída se dibuja en la
257
+ // barra-resumen de su fase; los vínculos dentro de una misma fase contraída
258
+ // se omiten y los repetidos van una sola vez (crítico si alguno lo es).
259
+ const taskBy = new Map(tasks.map((t) => [t.name, t]));
260
+ const keyOf = (name) => { const gname = hiddenGroup(taskBy.get(name)); return gname === null ? name : '\u0001' + gname; };
261
+ const edges = new Map();
262
+ for (const t of tasks) for (const d of t.deps) {
263
+ if (!taskBy.has(d)) continue;
264
+ const ka = keyOf(d), kb = keyOf(t.name);
265
+ if (ka === kb) continue;
266
+ const k = ka + '\u0000' + kb, crit = critLinks.has(d + '\u0000' + t.name);
267
+ if (!edges.has(k)) edges.set(k, { ka, kb, crit });
268
+ else if (crit) edges.get(k).crit = true;
269
+ }
270
+ const hasOut = new Set([...edges.values()].map((e) => e.ka));
271
+ const labels = [], marks = [], headers = [];
272
+
273
+ /**
274
+ * Barra con su avance: lo pendiente en un tono claro, lo hecho lleno, y el
275
+ * porcentaje dentro de lo hecho si cabe (si no, a la derecha). `avoid` son
276
+ * tramos [x0, x1] donde no debe ir el texto (rombos de hitos).
277
+ */
278
+ const barParts = ({ xa, xb, by, bh, rx, cy, color, progress, dashed, clipId, avoid = [], out = false }) => {
279
+ const w = xb - xa;
280
+ const parts = [];
281
+ if (dashed) parts.push(el('rect', { x: xa + 0.6, y: by + 0.6, width: Math.max(1, w - 1.2), height: bh - 1.2, rx, ...dashedAttrs(H, color) }));
282
+ else if (progress !== null) {
283
+ const light = ramp([theme.surface, color], theme.mode === 'dark' ? 0.38 : 0.26);
284
+ parts.push(el('rect', { x: xa, y: by, width: w, height: bh, rx, fill: light }));
285
+ if (progress > 0) {
286
+ const pw = Math.max(rx * 2, w * progress);
287
+ parts.push(el('clipPath', { id: clipId }, el('rect', { x: xa, y: by, width: w, height: bh, rx })));
288
+ parts.push(el('rect', { x: xa, y: by, width: Math.min(w, pw), height: bh, fill: color, 'clip-path': `url(#${clipId})` }));
289
+ }
290
+ } else parts.push(el('rect', { x: xa, y: by, width: w, height: bh, rx, fill: color }));
291
+ if (progress !== null && spec.labels !== false) {
292
+ const s = pct(progress);
293
+ const sw = measure(s, 11, 600);
294
+ const pw = w * progress;
295
+ const free = (a, b) => !avoid.some(([p, q]) => b > p - 3 && a < q + 3);
296
+ const right = Math.max(xb, ...avoid.map(([, q]) => q).filter((q) => q > xb - 1)) + (out ? 12 : 6); // tras la flecha que sale
297
+ if (!dashed && pw >= sw + 10 && free(xa + pw - 5 - sw, xa + pw - 5)) parts.push(text(xa + pw - 5, cy + 3.8, s, { fill: textOn(color, theme), fontSize: 11, fontWeight: 600, textAnchor: 'end' }));
298
+ else if (right + sw < inner.x + inner.w) parts.push(text(right, cy + 3.8, s, { fill: theme.lede, fontSize: 11, fontWeight: 600 }));
299
+ }
300
+ return parts;
301
+ };
302
+ const diamond = (px, cy, s) => `M${+px.toFixed(2)},${+(cy - s).toFixed(2)}l${s},${s}l${-s},${s}l${-s},${-s}Z`;
303
+ const fullW = inner.x + inner.w - area.x;
304
+
305
+ let y = inner.y;
306
+ for (const [ri, r] of rows.entries()) {
307
+ const h = rowHeight(r);
308
+ const cy = y + h / 2;
309
+ if (r.type === 'group') {
310
+ const ink = readable(r.color, theme, 4.5);
311
+ const ts = r.tasks;
312
+ const nWork = ts.filter((t) => !t.milestone).length, nMs = ts.length - nWork;
313
+ const prog = progressOf(ts);
314
+ const xa = x(r.start), xb = Math.max(xa + 3, x(r.end));
315
+ const isCrit = ts.some((t) => critical.has(t.name));
316
+ const parts = [];
317
+ // Fila completa clicable (nombre + barras) para expandir o contraer.
318
+ if (collapsible) parts.push(el('rect', { class: 'pa-hit', x: area.x - 4, y: +y.toFixed(2), width: +(fullW + 4).toFixed(2), height: +h.toFixed(2), fill: 'transparent' }));
319
+ if (collapsible) {
320
+ const cx = area.x + 4;
321
+ parts.push(el('path', { class: 'pa-chev', d: r.collapsed ? `M${cx - 2},${cy - 4.5}l5,4.5l-5,4.5Z` : `M${cx - 4.5},${cy - 2.5}l9,0l-4.5,5Z`, fill: theme.muted }));
322
+ }
323
+ const cw = countW(r);
324
+ const nameS = truncate(r.name, labelW - 10 - GUT - cw, fs, 600);
325
+ const lx = area.x + GUT;
326
+ parts.push(text(lx, cy + fs * 0.36, nameS, { fill: ink, fontSize: fs, fontWeight: 600 }));
327
+ parts.push(text(lx + measure(nameS, fs, 600) + 5, cy + fs * 0.36, countText(r), { fill: theme.muted, fontSize: fs }));
328
+ const sum = [];
329
+ let geo;
330
+ if (r.collapsed) {
331
+ // Fase contraída: barra del alto de una tarea con el avance de la fase,
332
+ // las puntas de corchete de la barra-resumen y sus hitos como rombos.
333
+ const bh = Math.max(6, Math.round(h * 0.6)), by = cy - bh / 2;
334
+ const rx = Math.min(2, bh / 2);
335
+ const s = Math.min(5.5, bh * 0.42);
336
+ const msX = ts.filter((t) => t.milestone).map((t) => x(t.start));
337
+ sum.push(...barParts({ xa, xb, by, bh, rx, cy, color: r.color, progress: prog, dashed: ts.every(isDashed), clipId: `${gid}-g${ri}`, avoid: msX.map((p) => [p - s, p + s]), out: hasOut.has('\u0001' + r.name) }));
338
+ sum.push(el('path', { d: `M${xa},${by + bh - 0.5}v4.5l4.5,-4.5zM${xb},${by + bh - 0.5}v4.5l-4.5,-4.5z`, fill: r.color }));
339
+ if (isCrit) sum.push(el('rect', { x: xa - 1, y: by - 1, width: xb - xa + 2, height: bh + 2, rx: rx + 1, fill: 'none', stroke: critColor, strokeWidth: 1.5 }));
340
+ for (const px of msX) sum.push(el('path', { d: diamond(px, cy, s), fill: ink, stroke: theme.surface, strokeWidth: 1.25 }));
341
+ geo = { x0: xa, x1: xb, cy, h: bh };
342
+ } else {
343
+ sum.push(el('rect', { x: xa, y: cy - 1.5, width: Math.max(2, xb - xa), height: 3, rx: 1.5, fill: r.color }),
344
+ el('path', { d: `M${xa},${cy - 1.5}v6l4,-6zM${xb},${cy - 1.5}v6l-4,-6z`, fill: r.color }));
345
+ }
346
+ const span = isTime ? Math.round((r.end - r.start) / DAY) : r.end - r.start;
347
+ const range = `${fmtDay(r.start)} – ${fmtDay(isTime && ts.every((t) => t.milestone || isDay(t.row[endF])) ? r.end - DAY : r.end)}${isTime ? ' · ' + span + ' ' + T(locale, 'días', 'days') : ''}`;
348
+ const counts = [`${nWork} ${T(locale, nWork === 1 ? 'tarea' : 'tareas', nWork === 1 ? 'task' : 'tasks')}`,
349
+ nMs ? `${nMs} ${T(locale, nMs === 1 ? 'hito' : 'hitos', nMs === 1 ? 'milestone' : 'milestones')}` : null,
350
+ prog !== null ? pct(prog) : null].filter(Boolean).join(' · ');
351
+ const hint = collapsible ? (r.collapsed ? T(locale, 'Clic para expandir', 'Click to expand') : T(locale, 'Clic para contraer', 'Click to collapse')) : null;
352
+ parts.push(g({ class: 'pa-m', opacity: hl && !hl.has(r.name) ? 0.35 : null, 'data-color': r.color }, sum));
353
+ headers.push(g({ class: 'pa-gh', 'data-group': r.name, 'data-collapsed': r.collapsed ? 'true' : 'false', 'data-tip': tip(r.name, range, counts, hint) }, parts));
354
+ if (geo) pos.set('\u0001' + r.name, geo);
355
+ y += h;
356
+ continue;
357
+ }
358
+ const t = r.t;
359
+ const color = colorOf(t);
360
+ const dashedT = isDashed(t);
361
+ const crit = critical.has(t.name);
362
+ const opacity = dim(t) ? 0.32 : null;
363
+ labels.push(text(area.x + IND, cy + fs * 0.36, truncate(t.name, labelW - IND - 12, fs), { fill: dim(t) ? theme.muted : theme.ink, fontSize: fs, opacity: dim(t) ? 0.8 : null }));
364
+ const tipLines = [t.name, t.milestone ? fmtDay(t.start) : `${fmtDay(t.start)} – ${fmtEnd(t)} · ${lenText(t)}`, t.group, t.progress !== null ? `${T(locale, 'Avance', 'Progress')}: ${pct(t.progress)}` : null,
365
+ t.deps.length ? `${T(locale, 'Después de', 'After')}: ${t.deps.join(', ')}` : null, dashedT ? (dashedLabel || null) : null, crit ? T(locale, 'Ruta crítica', 'Critical path') : null];
366
+ if (t.milestone) {
367
+ const px = x(t.start), s = Math.min(9, h * 0.36);
368
+ const fill = groups.length ? color : theme.ink;
369
+ const parts = [el('path', { d: `M${px},${cy - s}L${px + s},${cy}L${px},${cy + s}L${px - s},${cy}Z`, ...(dashedT ? dashedAttrs(H, fill, { width: 1.2 }) : { fill, stroke: crit ? critColor : theme.surface, strokeWidth: crit ? 2 : 1.5 }) })];
370
+ const lab = fmtDay(t.start, false);
371
+ // Si de este hito sale una flecha, la fecha va a la izquierda para no cruzarla
372
+ // (las que llegan lo hacen por arriba).
373
+ const hasNext = hasOut.has(t.name);
374
+ if (hasNext && px - s - 6 - measure(lab, 11) > inner.x) parts.push(text(px - s - 5, cy + 4, lab, { fill: theme.lede, fontSize: 11, textAnchor: 'end' }));
375
+ else if (px + s + 6 + measure(lab, 11) < inner.x + inner.w) parts.push(text(px + s + 5, cy + 4, lab, { fill: theme.lede, fontSize: 11 }));
376
+ else parts.push(text(px - s - 5, cy + 4, lab, { fill: theme.lede, fontSize: 11, textAnchor: 'end' }));
377
+ marks.push(g({ class: 'pa-m', opacity, 'data-task': t.name, 'data-color': color, 'data-tip': tip(...tipLines) }, parts));
378
+ pos.set(t.name, { x0: px - s, x1: px + s, cy, h: s * 2, ms: true, px });
379
+ } else {
380
+ const xa = x(t.start), xb = Math.max(xa + 3, x(t.end));
381
+ const bh = Math.max(6, Math.round(h * 0.6));
382
+ const by = cy - bh / 2, w = xb - xa, rx = Math.min(theme.marks?.barRadius ?? 4, bh / 2, w / 2);
383
+ const parts = barParts({ xa, xb, by, bh, rx, cy, color, progress: t.progress, dashed: dashedT, clipId: `${gid}-c${t.i}`, out: hasOut.has(t.name) });
384
+ if (crit) parts.push(el('rect', { x: xa - 1, y: by - 1, width: w + 2, height: bh + 2, rx: rx + 1, fill: 'none', stroke: critColor, strokeWidth: 1.5 }));
385
+ marks.push(g({ class: 'pa-m', opacity, 'data-task': t.name, 'data-color': color, 'data-tip': tip(...tipLines) }, parts));
386
+ pos.set(t.name, { x0: xa, x1: xb, cy, h: bh });
387
+ }
388
+ y += h;
389
+ }
390
+
391
+ // ── Dependencias: flechas en ángulo recto, por detrás de las barras ─────
392
+ // Salen del fin de la predecesora, bajan junto a ella y entran por la
393
+ // izquierda de la sucesora (a un hito, por arriba). Si la sucesora empieza
394
+ // antes, rodean por el borde de la fila.
395
+ const links = [], critOut = [];
396
+ const arrowId = `${gid}-arrow`, critArrowId = `${gid}-arrow-c`;
397
+ for (const { ka, kb, crit } of edges.values()) {
398
+ const a = pos.get(ka), b = pos.get(kb);
399
+ if (!a || !b) continue;
400
+ const xs = a.x1, ys = a.cy, ye = b.cy;
401
+ const dir = ye >= ys ? 1 : -1;
402
+ const f = (v) => +v.toFixed(1);
403
+ let dpath;
404
+ if (b.ms) {
405
+ const tipY = ye - dir * (b.h / 2 + 1.5);
406
+ if (b.px - xs >= 6) dpath = `M${f(xs)},${f(ys)}H${f(b.px)}V${f(tipY)}`;
407
+ else dpath = `M${f(xs)},${f(ys)}h6V${f(ys + dir * rowH / 2)}H${f(b.px)}V${f(tipY)}`;
408
+ } else {
409
+ const xe = b.x0 - 1.5;
410
+ if (xe - xs >= 14) dpath = `M${f(xs)},${f(ys)}h6V${f(ye)}H${f(xe)}`;
411
+ else dpath = `M${f(xs)},${f(ys)}h6V${f(ys + dir * rowH / 2)}H${f(xe - 8)}V${f(ye)}H${f(xe)}`;
412
+ }
413
+ (crit ? critOut : links).push(el('path', { d: dpath, fill: 'none', stroke: crit ? critColor : theme.muted, strokeWidth: crit ? 1.5 : 1, strokeLinejoin: 'round', opacity: crit ? null : 0.85, markerEnd: `url(#${crit ? critArrowId : arrowId})` }));
414
+ }
415
+
416
+ // ── Hoy y líneas de referencia ──────────────────────────────────────────
417
+ const refsOut = [];
418
+ const vline = (v, label, color) => {
419
+ const px = Math.round(x(v)) + 0.5;
420
+ refsOut.push(el('line', { x1: px, x2: px, y1: inner.y - 4, y2: inner.y + inner.h, stroke: color, strokeWidth: 1.5 }));
421
+ const lw = measure(label, 11, 600) + 10;
422
+ const lx = Math.min(Math.max(px - lw / 2, inner.x), inner.x + inner.w - lw);
423
+ refsOut.push(el('rect', { x: lx, y: inner.y - 13, width: lw, height: 17, rx: 8.5, fill: color }));
424
+ refsOut.push(text(lx + lw / 2, inner.y - 1.5, label, { fill: textOn(color, theme), fontSize: 11, fontWeight: 600, textAnchor: 'middle' }));
425
+ };
426
+ if (todayLabel) vline(todayV, todayLabel, spec.todayColor || theme.categorical[1]);
427
+ for (const r of refs) vline(r.t, r.label || fmtDay(r.t, false), r.color || theme.ink);
428
+
429
+ body.push(g({ class: 'pa-labels' }, labels));
430
+ body.push(g({ class: 'pa-links' }, [...links, ...critOut]));
431
+ body.push(g({ class: 'pa-marks' }, [...headers, ...marks]));
432
+ body.push(g({ class: 'pa-refs' }, refsOut));
433
+
434
+ // ── Descripción ─────────────────────────────────────────────────────────
435
+ const work = tasks.filter((t) => !t.milestone);
436
+ const longest = work.length ? work.reduce((a, b) => (lenOf(b) > lenOf(a) ? b : a)) : null;
437
+ const first = Math.min(...tasks.map((t) => t.start)), last = Math.max(...tasks.map((t) => t.end));
438
+ const lastT = tasks.find((t) => t.end === last);
439
+ const ms = tasks.filter((t) => t.milestone);
440
+ let desc = en
441
+ ? `Gantt chart of ${tasks.length} tasks${groups.length ? ` in ${groups.length} phases` : ''}, from ${fmtDay(first)} to ${lastT ? fmtEnd(lastT) : fmtDay(last)}.`
442
+ : `Carta Gantt de ${tasks.length} tareas${groups.length ? ` en ${groups.length} fases` : ''}, del ${fmtDay(first)} al ${lastT ? fmtEnd(lastT) : fmtDay(last)}.`;
443
+ if (collapsedSet.size) {
444
+ const list = groups.filter((gname) => collapsedSet.has(gname)).map((gname) => `${gname} (${tasks.filter((t) => t.group === gname).length})`).join(', ');
445
+ desc += collapsedSet.size === groups.length
446
+ ? T(locale, ` Todas las fases contraídas: ${list}.`, ` All phases collapsed: ${list}.`)
447
+ : T(locale, ` ${collapsedSet.size === 1 ? 'Fase contraída' : 'Fases contraídas'}: ${list}.`, ` ${collapsedSet.size === 1 ? 'Collapsed phase' : 'Collapsed phases'}: ${list}.`);
448
+ }
449
+ if (longest) desc += en ? ` Longest: ${longest.name} (${lenText(longest)}).` : ` Más larga: ${longest.name} (${lenText(longest)}).`;
450
+ if (ms.length) desc += en ? ` Milestones: ${ms.slice(0, 4).map((t) => `${t.name} (${fmtDay(t.start, false)})`).join(', ')}.` : ` Hitos: ${ms.slice(0, 4).map((t) => `${t.name} (${fmtDay(t.start, false)})`).join(', ')}.`;
451
+ if (critical.size) {
452
+ const chain = [...tasks].filter((t) => critical.has(t.name)).sort((a, b) => a.start - b.start || a.end - b.end).map((t) => t.name);
453
+ desc += T(locale, ` Ruta crítica: ${chain.join(' → ')}.`, ` Critical path: ${chain.join(' → ')}.`);
454
+ }
455
+ const avg = progressOf(tasks);
456
+ if (avg !== null) desc += en ? ` Overall progress: ${pct(avg)}.` : ` Avance total: ${pct(avg)}.`;
457
+ const arrow = (id, fill) => el('marker', { id, viewBox: '0 0 8 8', refX: 7, refY: 4, markerWidth: 7, markerHeight: 7, orient: 'auto' }, el('path', { d: 'M0,0.8L7,4L0,7.2Z', fill }));
458
+ const defs = arrow(arrowId, theme.muted) + (critOut.length ? arrow(critArrowId, critColor) : '') + H.defs();
459
+ return frame.finish(body, { desc, type: 'gantt', defs });
460
+ }
@@ -70,7 +70,7 @@ export function heatmap(spec, theme) {
70
70
  let best = null;
71
71
  for (const a of xs) for (const b of ys) { const v = val(a, b); if (Number.isFinite(v) && (!best || v > best[2])) best = [a, b, v]; }
72
72
  const desc = en
73
- ? `Heatmap of ${humanize(valueF)} by ${humanize(x)} and ${humanize(y)}. Highest: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`
74
- : `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}. Máximo: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`;
73
+ ? `Heatmap of ${humanize(valueF)} by ${humanize(x)} and ${humanize(y)}.` + (best ? ` Highest: ${best[1]} · ${best[0]} (${fmt(best[2])}).` : ' No cell has a numeric value.')
74
+ : `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}.` + (best ? ` Máximo: ${best[1]} · ${best[0]} (${fmt(best[2])}).` : ' Ninguna celda tiene un valor numérico.');
75
75
  return frame.finish(body, { desc, type: 'heatmap' });
76
76
  }
package/src/charts/kpi.js CHANGED
@@ -8,7 +8,8 @@ import { createFrame } from '../frame.js';
8
8
  import { ctx } from '../context.js';
9
9
 
10
10
  export function kpi(spec, theme) {
11
- const items = (spec.items || spec.data || []).map((it) => ({ ...it }));
11
+ // Un solo indicador como objeto también vale: items: { label, value }.
12
+ const items = [].concat(spec.items || spec.data || []).filter((it) => it && typeof it === 'object').map((it) => ({ ...it }));
12
13
  const locale = spec.locale || 'es';
13
14
  const en = String(locale).startsWith('en');
14
15
  const frame = createFrame(spec, theme, { height: 200 });
@@ -28,14 +29,16 @@ export function kpi(spec, theme) {
28
29
 
29
30
  const body = [];
30
31
  // Mismo tamaño de cifra en todas las tarjetas (se ajusta a la más ancha).
31
- const shown = items.map((it) => it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value), ...(it.trend || []).map(num)])(num(it.value)));
32
+ const trendOf = (it) => (Array.isArray(it.trend) ? it.trend.map(num).filter(Number.isFinite) : []);
33
+ const show = (it, extra = trendOf(it)) => it.display ?? (Number.isFinite(num(it.value)) ? makeFormat(it.format ?? spec.format, locale, [num(it.value), ...extra])(num(it.value)) : '—');
34
+ const shown = items.map((it) => show(it));
32
35
  let vSize = 34;
33
36
  while (vSize > 20 && shown.some((s) => measure(s, vSize, 600) > tw - 36)) vSize -= 2;
34
37
  items.forEach((it, i) => {
35
38
  const cx = area.x + (i % cols) * (tw + gap);
36
39
  const cy = area.y + Math.floor(i / cols) * (th + gap);
37
40
  const value = num(it.value);
38
- const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...(it.trend || []).map(num)]);
41
+ const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...trendOf(it)].filter(Number.isFinite));
39
42
  const parts = [el('rect', { x: cx, y: cy, width: tw, height: th, rx: 12, fill: theme.tint })];
40
43
  const P = 18;
41
44
  parts.push(text(cx + P, cy + P + 12 * X, truncate(it.label ?? '', tw - P * 2, 13), { fill: theme.lede, fontSize: 13 }));
@@ -69,20 +72,20 @@ export function kpi(spec, theme) {
69
72
  if (it.target !== undefined) {
70
73
  y += 24 * X;
71
74
  const t = num(it.target);
72
- const f = Math.max(0, Math.min(1, value / (t || 1)));
75
+ const f = Number.isFinite(value) ? Math.max(0, Math.min(1, value / (t || 1))) : 0; // valor no numérico: riel vacío, nunca NaN
73
76
  const w = tw - P * 2;
74
77
  const upGood = (it.good ?? 'up') === 'up';
75
78
  const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
76
79
  const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
77
80
  parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
78
81
  parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
79
- const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
82
+ const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + (Number.isFinite(value) ? formatNumber((value / (t || 1)) * 100, 0, locale) + '%' : '—');
80
83
  parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
81
84
  }
82
85
 
83
86
  // Tendencia: línea recesiva, último punto en el acento.
84
- if (Array.isArray(it.trend) && it.trend.length > 1) {
85
- const vals = it.trend.map(num).filter(Number.isFinite);
87
+ if (trendOf(it).length > 1) {
88
+ const vals = trendOf(it);
86
89
  const mn = Math.min(...vals), mx = Math.max(...vals);
87
90
  const sx = cx + P, sw = tw - P * 2, sy = cy + th - P - 26, sh = 26;
88
91
  const pts = vals.map((v, k) => [sx + (k / (vals.length - 1)) * sw, sy + sh - ((v - mn) / (mx - mn || 1)) * sh]);
@@ -92,6 +95,6 @@ export function kpi(spec, theme) {
92
95
  }
93
96
  body.push(g({ 'data-tip': tip(it.label, valStr, it.period ? `${it.period}` : '') }, parts));
94
97
  });
95
- const desc = items.map((it) => `${it.label}: ${it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value)])(num(it.value))}`).join('. ');
98
+ const desc = items.map((it) => `${it.label ?? ''}: ${show(it, [])}`).join('. ');
96
99
  return frame.finish(body, { desc, type: 'kpi' });
97
100
  }