plotagents 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Mapa de calor: magnitud en una grilla de dos categorías (o tiempo).
|
|
2
|
+
// Rampa secuencial de un solo tono; divergente si hay un punto medio.
|
|
3
|
+
import { el, g, text, tip } from '../svg.js';
|
|
4
|
+
import { measure, truncate } from '../text.js';
|
|
5
|
+
import { makeFormat } from '../format.js';
|
|
6
|
+
import { ramp, textOn } from '../theme.js';
|
|
7
|
+
import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, aggregate, humanize } from '../data.js';
|
|
8
|
+
import { xLabels } from '../axes.js';
|
|
9
|
+
import { createFrame } from '../frame.js';
|
|
10
|
+
|
|
11
|
+
export function heatmap(spec, theme) {
|
|
12
|
+
const data = ensureData(spec);
|
|
13
|
+
const fields = fieldsOf(data);
|
|
14
|
+
const nonNum = fields.filter((f) => fieldKind(data, f) !== 'number');
|
|
15
|
+
const x = spec.x ?? nonNum[0];
|
|
16
|
+
const y = spec.y ?? nonNum.find((f) => f !== x);
|
|
17
|
+
const valueF = spec.value ?? fields.find((f) => f !== x && f !== y && fieldKind(data, f) === 'number');
|
|
18
|
+
requireField(data, x, 'x');
|
|
19
|
+
requireField(data, y, 'y');
|
|
20
|
+
requireField(data, valueF, 'value');
|
|
21
|
+
const locale = spec.locale || 'es';
|
|
22
|
+
const xs = uniq(data.map((r) => String(r[x])));
|
|
23
|
+
const ys = uniq(data.map((r) => String(r[y])));
|
|
24
|
+
const cells = new Map();
|
|
25
|
+
for (const r of data) {
|
|
26
|
+
const k = String(r[x]) + '\u0000' + String(r[y]);
|
|
27
|
+
if (!cells.has(k)) cells.set(k, []);
|
|
28
|
+
cells.get(k).push(num(r[valueF]));
|
|
29
|
+
}
|
|
30
|
+
const val = (a, b) => { const v = cells.get(a + '\u0000' + b); return v ? aggregate(v, spec.aggregate || 'sum') : NaN; };
|
|
31
|
+
const all = [...cells.keys()].map((k) => { const [a, b] = k.split('\u0000'); return val(a, b); }).filter(Number.isFinite);
|
|
32
|
+
const vmin = Math.min(...all), vmax = Math.max(...all);
|
|
33
|
+
const fmt = makeFormat(spec.format, locale, all);
|
|
34
|
+
const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
|
|
35
|
+
const stops = mid !== undefined ? theme.diverging : theme.sequential;
|
|
36
|
+
const colorOf = (v) => {
|
|
37
|
+
if (!Number.isFinite(v)) return theme.tint;
|
|
38
|
+
if (mid !== undefined) {
|
|
39
|
+
const span = Math.max(vmax - mid, mid - vmin) || 1;
|
|
40
|
+
return ramp(stops, 0.5 + (v - mid) / span / 2);
|
|
41
|
+
}
|
|
42
|
+
return ramp(stops.slice(1), (v - vmin) / (vmax - vmin || 1));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const legend = [{ type: 'ramp', stops: mid !== undefined ? stops : stops.slice(1), min: fmt(mid !== undefined ? -(Math.max(vmax - mid, mid - vmin)) + mid : vmin), max: fmt(mid !== undefined ? Math.max(vmax - mid, mid - vmin) + mid : vmax), title: spec.legendTitle ?? humanize(valueF) }];
|
|
46
|
+
const frame = createFrame(spec, theme, { height: Math.min(640, 140 + ys.length * 34) });
|
|
47
|
+
const area = frame.layout(legend);
|
|
48
|
+
const yW = Math.min(area.w * 0.3, Math.max(...ys.map((v) => measure(v, 12)))) + 12;
|
|
49
|
+
const inner = { x: area.x + yW, y: area.y, w: area.w - yW, h: area.h - 26 };
|
|
50
|
+
const cw = inner.w / xs.length, ch = Math.min(inner.h / ys.length, spec.cellHeight ?? 56);
|
|
51
|
+
inner.h = ch * ys.length;
|
|
52
|
+
if (spec.height === undefined) frame.setHeight(inner.y + inner.h + 26 + frame.chrome);
|
|
53
|
+
const showLabels = spec.labels ?? (cw >= Math.max(...all.map((v) => measure(fmt(v), 11))) + 8 && ch >= 18);
|
|
54
|
+
|
|
55
|
+
const body = [];
|
|
56
|
+
ys.forEach((b, j) => {
|
|
57
|
+
body.push(text(inner.x - 10, inner.y + j * ch + ch / 2 + 4, truncate(b, yW - 12, 12), { fill: theme.lede, fontSize: 12, textAnchor: 'end' }));
|
|
58
|
+
xs.forEach((a, i) => {
|
|
59
|
+
const v = val(a, b);
|
|
60
|
+
const fill = colorOf(v);
|
|
61
|
+
const cx = inner.x + i * cw + 1, cy = inner.y + j * ch + 1;
|
|
62
|
+
const parts = [el('rect', { x: cx, y: cy, width: Math.max(0, cw - 2), height: Math.max(0, ch - 2), rx: 3, fill })];
|
|
63
|
+
if (showLabels && Number.isFinite(v)) parts.push(text(cx + (cw - 2) / 2, cy + (ch - 2) / 2 + 4, fmt(v), { fill: textOn(fill, theme), fontSize: 11, textAnchor: 'middle', fontWeight: 500 }));
|
|
64
|
+
body.push(g({ class: 'pa-m', 'data-tip': tip(`${b} · ${a}`, Number.isFinite(v) ? fmt(v) : '—') }, parts));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
body.push(xLabels(xs.map((a, i) => ({ pos: inner.x + i * cw + cw / 2, label: a })), inner, theme, { y: inner.y + inner.h + 18, color: theme.lede }));
|
|
68
|
+
|
|
69
|
+
const en = String(locale).startsWith('en');
|
|
70
|
+
let best = null;
|
|
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
|
+
const desc = en
|
|
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
|
+
return frame.finish(body, { desc, type: 'heatmap' });
|
|
76
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Indicadores (stat tiles): cuando la historia es un número, el número es
|
|
2
|
+
// el gráfico. Valor, variación con flecha y texto, tendencia y meta.
|
|
3
|
+
import { el, g, text, tip } from '../svg.js';
|
|
4
|
+
import { truncate, measure } from '../text.js';
|
|
5
|
+
import { makeFormat, formatNumber } from '../format.js';
|
|
6
|
+
import { num } from '../data.js';
|
|
7
|
+
import { createFrame } from '../frame.js';
|
|
8
|
+
import { ctx } from '../context.js';
|
|
9
|
+
|
|
10
|
+
export function kpi(spec, theme) {
|
|
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 }));
|
|
13
|
+
const locale = spec.locale || 'es';
|
|
14
|
+
const en = String(locale).startsWith('en');
|
|
15
|
+
const frame = createFrame(spec, theme, { height: 200 });
|
|
16
|
+
const area = frame.layout([]);
|
|
17
|
+
const n = Math.max(1, items.length);
|
|
18
|
+
const fit = Math.max(1, Math.floor((area.w + 12) / 180));
|
|
19
|
+
// Filas parejas: 4 tarjetas donde caben 3 → 2×2, no 3+1.
|
|
20
|
+
const cols = spec.columns ?? (n <= fit ? n : Math.ceil(n / Math.ceil(n / fit)));
|
|
21
|
+
const gap = 12;
|
|
22
|
+
const tw = (area.w - gap * (cols - 1)) / cols;
|
|
23
|
+
const hasTrend = items.some((it) => Array.isArray(it.trend) && it.trend.length > 1);
|
|
24
|
+
const hasTarget = items.some((it) => it.target !== undefined);
|
|
25
|
+
const X = ctx.textScale;
|
|
26
|
+
const th = Math.round(118 * (0.35 + 0.65 * X) + (hasTrend ? 40 : 0) + (hasTarget ? 30 * X : 0));
|
|
27
|
+
const rows = Math.ceil(n / cols);
|
|
28
|
+
if (spec.height === undefined) frame.setHeight(area.y + rows * th + (rows - 1) * gap + frame.chrome);
|
|
29
|
+
|
|
30
|
+
const body = [];
|
|
31
|
+
// Mismo tamaño de cifra en todas las tarjetas (se ajusta a la más ancha).
|
|
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));
|
|
35
|
+
let vSize = 34;
|
|
36
|
+
while (vSize > 20 && shown.some((s) => measure(s, vSize, 600) > tw - 36)) vSize -= 2;
|
|
37
|
+
items.forEach((it, i) => {
|
|
38
|
+
const cx = area.x + (i % cols) * (tw + gap);
|
|
39
|
+
const cy = area.y + Math.floor(i / cols) * (th + gap);
|
|
40
|
+
const value = num(it.value);
|
|
41
|
+
const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...trendOf(it)].filter(Number.isFinite));
|
|
42
|
+
const parts = [el('rect', { x: cx, y: cy, width: tw, height: th, rx: 12, fill: theme.tint })];
|
|
43
|
+
const P = 18;
|
|
44
|
+
parts.push(text(cx + P, cy + P + 12 * X, truncate(it.label ?? '', tw - P * 2, 13), { fill: theme.lede, fontSize: 13 }));
|
|
45
|
+
const valStr = shown[i];
|
|
46
|
+
parts.push(text(cx + P, cy + P + 12 * X + 10 + vSize * X * 0.92, valStr, { fill: theme.ink, fontSize: vSize, fontWeight: 600, letterSpacing: '-0.015em' }));
|
|
47
|
+
|
|
48
|
+
// Variación: signo × si subir es bueno. Flecha + texto, nunca solo color.
|
|
49
|
+
let delta = it.delta !== undefined ? num(it.delta) : it.previous !== undefined && num(it.previous) ? (value - num(it.previous)) / Math.abs(num(it.previous)) : NaN;
|
|
50
|
+
let y = cy + P + 12 * X + 10 + vSize * X + 20 * X;
|
|
51
|
+
if (Number.isFinite(delta)) {
|
|
52
|
+
const isPct = (it.deltaFormat ?? 'percent') === 'percent';
|
|
53
|
+
if (isPct && Math.abs(delta) > 1.5 && it.delta !== undefined) delta = delta / 100; // 12 → 12%
|
|
54
|
+
const up = delta > 0, flat = delta === 0;
|
|
55
|
+
const goodUp = (it.good ?? spec.good ?? 'up') === 'up';
|
|
56
|
+
const good = flat ? null : up === goodUp;
|
|
57
|
+
const color = good === null ? theme.lede : good ? theme.status.good : theme.status.critical;
|
|
58
|
+
const arrow = flat ? '■' : up ? '▲' : '▼';
|
|
59
|
+
const dstr = isPct
|
|
60
|
+
? (up ? '+' : flat ? '' : '−') + formatNumber(Math.abs(delta) * 100, Math.abs(delta) < 0.1 ? 1 : 0, locale, true) + '%'
|
|
61
|
+
: (up ? '+' : flat ? '' : '−') + fmt(Math.abs(delta));
|
|
62
|
+
parts.push(text(cx + P, y, `${arrow} ${dstr}`, { fill: color, fontSize: 13, fontWeight: 600 }));
|
|
63
|
+
const period = it.period ?? it.deltaLabel;
|
|
64
|
+
const dw = measure(`${arrow} ${dstr}`, 13, 600) + 8;
|
|
65
|
+
if (period && dw + measure(period, 12.5) <= tw - P * 2) parts.push(text(cx + P + dw, y, period, { fill: theme.lede, fontSize: 12.5 }));
|
|
66
|
+
else if (period) { y += 17 * X; parts.push(text(cx + P, y, truncate(period, tw - P * 2, 12.5), { fill: theme.lede, fontSize: 12.5 })); }
|
|
67
|
+
} else if (it.note) {
|
|
68
|
+
parts.push(text(cx + P, y, truncate(it.note, tw - P * 2, 12.5), { fill: theme.lede, fontSize: 12.5 }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Meta: medidor con riel del mismo tono, más claro.
|
|
72
|
+
if (it.target !== undefined) {
|
|
73
|
+
y += 24 * X;
|
|
74
|
+
const t = num(it.target);
|
|
75
|
+
const f = Math.max(0, Math.min(1, value / (t || 1)));
|
|
76
|
+
const w = tw - P * 2;
|
|
77
|
+
const upGood = (it.good ?? 'up') === 'up';
|
|
78
|
+
const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
|
|
79
|
+
const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
|
|
80
|
+
parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
|
|
81
|
+
parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
|
|
82
|
+
const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
|
|
83
|
+
parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Tendencia: línea recesiva, último punto en el acento.
|
|
87
|
+
if (trendOf(it).length > 1) {
|
|
88
|
+
const vals = trendOf(it);
|
|
89
|
+
const mn = Math.min(...vals), mx = Math.max(...vals);
|
|
90
|
+
const sx = cx + P, sw = tw - P * 2, sy = cy + th - P - 26, sh = 26;
|
|
91
|
+
const pts = vals.map((v, k) => [sx + (k / (vals.length - 1)) * sw, sy + sh - ((v - mn) / (mx - mn || 1)) * sh]);
|
|
92
|
+
parts.push(el('path', { d: pts.map(([a, b], k) => (k ? 'L' : 'M') + a.toFixed(1) + ',' + b.toFixed(1)).join(''), fill: 'none', stroke: theme.muted, strokeWidth: 1.75, strokeLinejoin: 'round', strokeLinecap: 'round' }));
|
|
93
|
+
const [lx, ly] = pts[pts.length - 1];
|
|
94
|
+
parts.push(el('circle', { cx: lx, cy: ly, r: 3.5, fill: theme.categorical[0], stroke: theme.tint, strokeWidth: 2 }));
|
|
95
|
+
}
|
|
96
|
+
body.push(g({ 'data-tip': tip(it.label, valStr, it.period ? `${it.period}` : '') }, parts));
|
|
97
|
+
});
|
|
98
|
+
const desc = items.map((it) => `${it.label ?? ''}: ${show(it, [])}`).join('. ');
|
|
99
|
+
return frame.finish(body, { desc, type: 'kpi' });
|
|
100
|
+
}
|