plotagents 0.5.0 → 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 +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- package/src/theme.js +101 -14
package/src/charts/dist.js
CHANGED
|
@@ -4,18 +4,26 @@
|
|
|
4
4
|
import { el, g, text, tip, barPath } from '../svg.js';
|
|
5
5
|
import { measure } from '../text.js';
|
|
6
6
|
import { makeFormat, formatNumber } from '../format.js';
|
|
7
|
-
import { niceDomain, niceStep, linear, log, logDomain } from '../scale.js';
|
|
7
|
+
import { niceDomain, niceStep, linear, log, logDomain, intStep } from '../scale.js';
|
|
8
8
|
import { colorMap } from '../theme.js';
|
|
9
|
-
import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize } from '../data.js';
|
|
9
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize, PlotError } from '../data.js';
|
|
10
10
|
import { yAxisMargin, yAxisTop, yGrid, yTickLabels, gridX, ticksWidth } from '../axes.js';
|
|
11
11
|
import { createFrame } from '../frame.js';
|
|
12
12
|
import { ctx } from '../context.js';
|
|
13
|
-
import { histogram as binValues, kde as kdeEst, scottBandwidth, ecdf as ecdfEst, quantile, finite, median } from '../stats.js';
|
|
13
|
+
import { histogram as binValues, kde as kdeEst, scottBandwidth, ecdf as ecdfEst, quantile, finite, median, extent } from '../stats.js';
|
|
14
14
|
|
|
15
15
|
// ── Utilidades compartidas (también las usa regplot.js) ───────────────────
|
|
16
16
|
|
|
17
17
|
const EN = (spec) => String(spec.locale || 'es').startsWith('en');
|
|
18
18
|
|
|
19
|
+
/** Error con pista cuando el campo no tiene ningún número graficable. */
|
|
20
|
+
function noNumbers(field, en, isLog) {
|
|
21
|
+
const logNote = isLog ? (en ? ' (on a log scale only values > 0 count)' : ' (en escala log solo cuentan valores > 0)') : '';
|
|
22
|
+
return en
|
|
23
|
+
? new PlotError(`No numeric values in \`${field}\`${logNote}.`, `\`${field}\` must hold numbers (empty, null and text values are skipped), or pick another field with x: "field".`)
|
|
24
|
+
: new PlotError(`No hay valores numéricos en \`${field}\`${logNote}.`, `\`${field}\` debe tener números (se omiten vacíos, nulos y textos), o elige otro campo con x: "campo".`);
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** Campo de grupo: `hue` (seaborn), `series` o `color` (si no es un hex). */
|
|
20
28
|
export function hueField(spec) {
|
|
21
29
|
const h = spec.hue ?? spec.series ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : undefined);
|
|
@@ -38,15 +46,15 @@ export function groupValues(data, field, hue) {
|
|
|
38
46
|
* Dominio de un eje numérico (lineal o log), con override `[min, max]`.
|
|
39
47
|
* Devuelve { min, max, step, isLog, ticks, scale(r0, r1) }.
|
|
40
48
|
*/
|
|
41
|
-
export function axisDomain(values, { domain, type, count = 5, zero = false, nice = true } = {}) {
|
|
49
|
+
export function axisDomain(values, { domain, type, count = 5, zero = false, nice = true, integer = false } = {}) {
|
|
42
50
|
const fin = finite(values);
|
|
43
51
|
if (type === 'log') {
|
|
44
52
|
const pos = fin.filter((v) => v > 0);
|
|
45
|
-
const d = domain ? { min: domain[0], max: domain[1] } : logDomain(
|
|
53
|
+
const d = domain ? { min: domain[0], max: domain[1] } : logDomain(...extent(pos));
|
|
46
54
|
const ticks = log(d.min, d.max, 0, 1).ticks();
|
|
47
55
|
return { min: d.min, max: d.max, step: null, isLog: true, ticks, scale: (r0, r1) => log(d.min, d.max, r0, r1) };
|
|
48
56
|
}
|
|
49
|
-
let lo = fin.length ?
|
|
57
|
+
let [lo, hi] = fin.length ? extent(fin) : [0, 1];
|
|
50
58
|
if (zero) { lo = Math.min(0, lo); hi = Math.max(0, hi); }
|
|
51
59
|
let d;
|
|
52
60
|
if (domain) {
|
|
@@ -59,16 +67,18 @@ export function axisDomain(values, { domain, type, count = 5, zero = false, nice
|
|
|
59
67
|
d = { min: a, max: hi + span * 0.04, step: niceStep(span * 1.08, count) };
|
|
60
68
|
} else if (!nice) {
|
|
61
69
|
d = { min: lo, max: hi === lo ? lo + 1 : hi, step: niceStep((hi === lo ? 1 : hi - lo), count) };
|
|
62
|
-
} else d = niceDomain(lo, hi, count);
|
|
70
|
+
} else d = niceDomain(lo, hi, count, { integer });
|
|
63
71
|
// Sin pasos de 2,5: con decimales redondeados se leen mal (2,5 → "3").
|
|
64
72
|
const mag = 10 ** Math.floor(Math.log10(d.step));
|
|
65
73
|
if (Math.abs(d.step / mag - 2.5) < 1e-9) d.step = 2 * mag;
|
|
74
|
+
// Conteos: paso entero (nunca marcas en 0,5).
|
|
75
|
+
if (integer) d.step = intStep(d.step);
|
|
66
76
|
const ticks = linear(d.min, d.max, 0, 1).ticks(d.step);
|
|
67
77
|
return { min: d.min, max: d.max, step: d.step, isLog: false, ticks, scale: (r0, r1) => linear(d.min, d.max, r0, r1) };
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/** Formateador de marcas para un eje (lineal o log). */
|
|
71
|
-
export const tickFmt = (fmt, ax) => (ax.isLog ? fmt : fmt.tick(ax.step));
|
|
81
|
+
export const tickFmt = (fmt, ax) => (ax.isLog ? fmt : fmt.tick(ax.step, ax.ticks));
|
|
72
82
|
|
|
73
83
|
/** Títulos de eje horizontales: y arriba a la izquierda, x abajo a la derecha. */
|
|
74
84
|
export function axisTitles(body, theme, area, inner, { x, y }) {
|
|
@@ -108,7 +118,7 @@ export function hist(spec, theme) {
|
|
|
108
118
|
const tr = T(isLog);
|
|
109
119
|
const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
|
|
110
120
|
const all = groups.flatMap((gr) => gr.values);
|
|
111
|
-
if (!all.length)
|
|
121
|
+
if (!all.length) throw noNumbers(field, en, isLog);
|
|
112
122
|
const N = all.length;
|
|
113
123
|
const stat = spec.stat || 'count';
|
|
114
124
|
const multiple = groups.length > 1 ? spec.multiple || 'layer' : 'layer';
|
|
@@ -174,7 +184,7 @@ export function hist(spec, theme) {
|
|
|
174
184
|
if (kdes && multiple === 'stack') {
|
|
175
185
|
for (let i = 0; i < kdes[0].length; i++) { let acc = 0; for (const k of kdes) { acc += k[i][1]; k[i][1] = acc; } }
|
|
176
186
|
}
|
|
177
|
-
const kdeMax = kdes ?
|
|
187
|
+
const kdeMax = kdes ? extent(kdes.flatMap((k) => k.map((p) => p[1])))[1] : 0;
|
|
178
188
|
|
|
179
189
|
const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
|
|
180
190
|
const single = K === 1;
|
|
@@ -185,9 +195,9 @@ export function hist(spec, theme) {
|
|
|
185
195
|
const area = frame.layout(legend);
|
|
186
196
|
const body = [];
|
|
187
197
|
const M = theme.marks || {};
|
|
188
|
-
const vMax = Math.max(
|
|
198
|
+
const vMax = Math.max(extent(tops)[1], kdeMax);
|
|
189
199
|
const valueOverride = horizontal ? spec.xDomain : spec.yDomain;
|
|
190
|
-
const vAx = axisDomain([0, vMax], { domain: multiple === 'fill' ? [0, 1] : valueOverride, count: Math.max(3, Math.round((horizontal ? area.w / 110 : area.h / 64))), zero: true });
|
|
200
|
+
const vAx = axisDomain([0, vMax], { domain: multiple === 'fill' ? [0, 1] : valueOverride, count: Math.max(3, Math.round((horizontal ? area.w / 110 : area.h / 64))), zero: true, integer: stat === 'count' && multiple !== 'fill' });
|
|
191
201
|
const binDom = dom ? [dom[0], dom[1]] : [bins[0].x0, bins[bins.length - 1].x1];
|
|
192
202
|
const clipId = 'pa-clip-h' + Math.abs(hashStr((spec.title || '') + field + area.w)).toString(36);
|
|
193
203
|
const writeDomains = (bin, val) => { if (spec._domains && typeof spec._domains === 'object') { spec._domains[horizontal ? 'y' : 'x'] = bin; spec._domains[horizontal ? 'x' : 'y'] = val; } };
|
|
@@ -314,7 +324,9 @@ export function kde(spec, theme) {
|
|
|
314
324
|
const locale = spec.locale || 'es';
|
|
315
325
|
const isLog = spec.xScale === 'log';
|
|
316
326
|
const tr = T(isLog);
|
|
317
|
-
const
|
|
327
|
+
const groups0 = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
|
|
328
|
+
if (!groups0.some((gr) => gr.values.length)) throw noNumbers(field, en, isLog);
|
|
329
|
+
const groups = groups0.filter((gr) => gr.values.length > 1);
|
|
318
330
|
if (!groups.length) return emptyChart(spec, theme, en);
|
|
319
331
|
const all = groups.flatMap((gr) => gr.values);
|
|
320
332
|
const N = all.length;
|
|
@@ -326,8 +338,9 @@ export function kde(spec, theme) {
|
|
|
326
338
|
const adjust = spec.bwAdjust ?? spec.bw_adjust ?? 1;
|
|
327
339
|
// Grilla común
|
|
328
340
|
const bws = groups.map((gr) => scottBandwidth(gr.values.map(tr.f)) * adjust);
|
|
329
|
-
|
|
330
|
-
let
|
|
341
|
+
const ext = groups.map((gr) => extent(gr.values.map(tr.f)));
|
|
342
|
+
let lo = Math.min(...ext.map((e, i) => e[0] - cut * bws[i]));
|
|
343
|
+
let hi = Math.max(...ext.map((e, i) => e[1] + cut * bws[i]));
|
|
331
344
|
if (spec.clip) { lo = Math.max(lo, tr.f(spec.clip[0])); hi = Math.min(hi, tr.f(spec.clip[1])); }
|
|
332
345
|
if (spec.xDomain) { lo = tr.f(spec.xDomain[0]); hi = tr.f(spec.xDomain[1]); }
|
|
333
346
|
const G = 200;
|
|
@@ -349,7 +362,7 @@ export function kde(spec, theme) {
|
|
|
349
362
|
}
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
|
-
const yMax =
|
|
365
|
+
const yMax = extent(curves.flatMap((c) => c.map((p) => p[1])))[1];
|
|
353
366
|
const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
|
|
354
367
|
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
355
368
|
const colorOf = (k) => (hl && !hl.has(groups[k].key) ? theme.dim : K === 1 ? (spec.colors && spec.colors[groups[0].key]) || theme.categorical[0] : cmap.get(groups[k].key));
|
|
@@ -445,7 +458,7 @@ export function ecdf(spec, theme) {
|
|
|
445
458
|
const locale = spec.locale || 'es';
|
|
446
459
|
const isLog = spec.xScale === 'log';
|
|
447
460
|
const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values })).filter((gr) => gr.values.length);
|
|
448
|
-
if (!groups.length)
|
|
461
|
+
if (!groups.length) throw noNumbers(field, en, isLog);
|
|
449
462
|
const all = groups.flatMap((gr) => gr.values);
|
|
450
463
|
const K = groups.length;
|
|
451
464
|
const cmap = colorMap(groups.map((gr) => gr.key), theme, spec.colors);
|
|
@@ -476,12 +489,16 @@ export function ecdf(spec, theme) {
|
|
|
476
489
|
const comp = !!spec.complementary;
|
|
477
490
|
const Y = (p) => (comp ? 1 - p : p);
|
|
478
491
|
let d = `M${xS(Math.max(xAx.min, pts[0][0])).toFixed(2)},${yS(Y(0)).toFixed(2)}`;
|
|
479
|
-
|
|
492
|
+
// Escalones a menos de medio píxel se funden en uno (50 mil valores → ~1 por columna).
|
|
493
|
+
let prev = 0, colX = NaN, colP0 = 0, colP1 = 0;
|
|
494
|
+
const flush = () => { if (Number.isFinite(colX)) { const px = colX.toFixed(2); d += `L${px},${yS(Y(colP0)).toFixed(2)}L${px},${yS(Y(colP1)).toFixed(2)}`; } };
|
|
480
495
|
for (const [x, p] of pts) {
|
|
481
|
-
const
|
|
482
|
-
|
|
496
|
+
const fx0 = xS(Math.min(xAx.max, Math.max(xAx.min, x)));
|
|
497
|
+
if (Number.isFinite(colX) && Math.abs(fx0 - colX) < 0.5) colP1 = p;
|
|
498
|
+
else { flush(); colX = fx0; colP0 = prev; colP1 = p; }
|
|
483
499
|
prev = p;
|
|
484
500
|
}
|
|
501
|
+
flush();
|
|
485
502
|
d += `L${xS(xAx.max).toFixed(2)},${yS(Y(1)).toFixed(2)}`;
|
|
486
503
|
const vs = [...groups[k].values].sort((a, b) => a - b);
|
|
487
504
|
body.push(g({ class: 'pa-m', 'data-tip': tip(groups[k].key || humanize(field), `P25: ${fx(quantile(vs, 0.25))}`, `${en ? 'Median' : 'Mediana'}: ${fx(quantile(vs, 0.5))}`, `P75: ${fx(quantile(vs, 0.75))}`, `P90: ${fx(quantile(vs, 0.9))}`) }, [
|
package/src/charts/donut.js
CHANGED
|
@@ -35,7 +35,7 @@ export function donut(spec, theme) {
|
|
|
35
35
|
const sums = new Map();
|
|
36
36
|
for (const r of data) {
|
|
37
37
|
const v = num(r[valueF]);
|
|
38
|
-
if (!(v > 0)) continue;
|
|
38
|
+
if (!(v > 0) || !Number.isFinite(v)) continue; // ≤ 0, vacíos e Infinity no tienen ángulo
|
|
39
39
|
const k = String(r[labelF]);
|
|
40
40
|
sums.set(k, (sums.get(k) || 0) + v);
|
|
41
41
|
}
|
|
@@ -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
|
+
}
|