plotagents 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -2
  4. package/bin/plotagents.js +49 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +7954 -0
  7. package/docs/api.md +154 -0
  8. package/docs/charts.md +218 -0
  9. package/docs/getting-started.md +138 -0
  10. package/docs/rendering.md +47 -0
  11. package/docs/themes.md +86 -0
  12. package/index.d.ts +213 -0
  13. package/package.json +74 -4
  14. package/schema.json +895 -0
  15. package/src/axes.js +144 -0
  16. package/src/charts/bar.js +339 -0
  17. package/src/charts/categorical.js +471 -0
  18. package/src/charts/chord.js +218 -0
  19. package/src/charts/choropleth.js +559 -0
  20. package/src/charts/dist.js +513 -0
  21. package/src/charts/donut.js +232 -0
  22. package/src/charts/flowchart.js +407 -0
  23. package/src/charts/heatmap.js +76 -0
  24. package/src/charts/kpi.js +97 -0
  25. package/src/charts/line.js +419 -0
  26. package/src/charts/network.js +344 -0
  27. package/src/charts/pyramid.js +177 -0
  28. package/src/charts/radar.js +152 -0
  29. package/src/charts/regplot.js +364 -0
  30. package/src/charts/sankey.js +133 -0
  31. package/src/charts/scatter.js +136 -0
  32. package/src/charts/sunburst.js +135 -0
  33. package/src/charts/treemap.js +194 -0
  34. package/src/context.js +10 -0
  35. package/src/dashed.js +90 -0
  36. package/src/data.js +137 -0
  37. package/src/facet.js +84 -0
  38. package/src/format.js +171 -0
  39. package/src/frame.js +157 -0
  40. package/src/geo/chile.js +7 -0
  41. package/src/geo/data/chile-regiones.js +12 -0
  42. package/src/geo/data/usa-estados.js +12 -0
  43. package/src/geo/data/world.js +12 -0
  44. package/src/geo/projection.js +152 -0
  45. package/src/geo/registry.js +31 -0
  46. package/src/geo/usa.js +8 -0
  47. package/src/geo/world.js +7 -0
  48. package/src/hierarchy.js +139 -0
  49. package/src/index.js +271 -0
  50. package/src/infer.js +49 -0
  51. package/src/interact.js +362 -0
  52. package/src/lint.js +137 -0
  53. package/src/palette.js +59 -0
  54. package/src/runtime.js +304 -0
  55. package/src/scale.js +171 -0
  56. package/src/schema.js +181 -0
  57. package/src/stats.js +246 -0
  58. package/src/svg.js +85 -0
  59. package/src/text.js +61 -0
  60. package/src/theme.js +224 -0
@@ -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)}. Highest: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`
74
+ : `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}. Máximo: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`;
75
+ return frame.finish(body, { desc, type: 'heatmap' });
76
+ }
@@ -0,0 +1,97 @@
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
+ const items = (spec.items || spec.data || []).map((it) => ({ ...it }));
12
+ const locale = spec.locale || 'es';
13
+ const en = String(locale).startsWith('en');
14
+ const frame = createFrame(spec, theme, { height: 200 });
15
+ const area = frame.layout([]);
16
+ const n = Math.max(1, items.length);
17
+ const fit = Math.max(1, Math.floor((area.w + 12) / 180));
18
+ // Filas parejas: 4 tarjetas donde caben 3 → 2×2, no 3+1.
19
+ const cols = spec.columns ?? (n <= fit ? n : Math.ceil(n / Math.ceil(n / fit)));
20
+ const gap = 12;
21
+ const tw = (area.w - gap * (cols - 1)) / cols;
22
+ const hasTrend = items.some((it) => Array.isArray(it.trend) && it.trend.length > 1);
23
+ const hasTarget = items.some((it) => it.target !== undefined);
24
+ const X = ctx.textScale;
25
+ const th = Math.round(118 * (0.35 + 0.65 * X) + (hasTrend ? 40 : 0) + (hasTarget ? 30 * X : 0));
26
+ const rows = Math.ceil(n / cols);
27
+ if (spec.height === undefined) frame.setHeight(area.y + rows * th + (rows - 1) * gap + frame.chrome);
28
+
29
+ const body = [];
30
+ // Mismo tamaño de cifra en todas las tarjetas (se ajusta a la más ancha).
31
+ const shown = items.map((it) => it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value), ...(it.trend || []).map(num)])(num(it.value)));
32
+ let vSize = 34;
33
+ while (vSize > 20 && shown.some((s) => measure(s, vSize, 600) > tw - 36)) vSize -= 2;
34
+ items.forEach((it, i) => {
35
+ const cx = area.x + (i % cols) * (tw + gap);
36
+ const cy = area.y + Math.floor(i / cols) * (th + gap);
37
+ const value = num(it.value);
38
+ const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...(it.trend || []).map(num)]);
39
+ const parts = [el('rect', { x: cx, y: cy, width: tw, height: th, rx: 12, fill: theme.tint })];
40
+ const P = 18;
41
+ parts.push(text(cx + P, cy + P + 12 * X, truncate(it.label ?? '', tw - P * 2, 13), { fill: theme.lede, fontSize: 13 }));
42
+ const valStr = shown[i];
43
+ 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' }));
44
+
45
+ // Variación: signo × si subir es bueno. Flecha + texto, nunca solo color.
46
+ let delta = it.delta !== undefined ? num(it.delta) : it.previous !== undefined && num(it.previous) ? (value - num(it.previous)) / Math.abs(num(it.previous)) : NaN;
47
+ let y = cy + P + 12 * X + 10 + vSize * X + 20 * X;
48
+ if (Number.isFinite(delta)) {
49
+ const isPct = (it.deltaFormat ?? 'percent') === 'percent';
50
+ if (isPct && Math.abs(delta) > 1.5 && it.delta !== undefined) delta = delta / 100; // 12 → 12%
51
+ const up = delta > 0, flat = delta === 0;
52
+ const goodUp = (it.good ?? spec.good ?? 'up') === 'up';
53
+ const good = flat ? null : up === goodUp;
54
+ const color = good === null ? theme.lede : good ? theme.status.good : theme.status.critical;
55
+ const arrow = flat ? '■' : up ? '▲' : '▼';
56
+ const dstr = isPct
57
+ ? (up ? '+' : flat ? '' : '−') + formatNumber(Math.abs(delta) * 100, Math.abs(delta) < 0.1 ? 1 : 0, locale, true) + '%'
58
+ : (up ? '+' : flat ? '' : '−') + fmt(Math.abs(delta));
59
+ parts.push(text(cx + P, y, `${arrow} ${dstr}`, { fill: color, fontSize: 13, fontWeight: 600 }));
60
+ const period = it.period ?? it.deltaLabel;
61
+ const dw = measure(`${arrow} ${dstr}`, 13, 600) + 8;
62
+ if (period && dw + measure(period, 12.5) <= tw - P * 2) parts.push(text(cx + P + dw, y, period, { fill: theme.lede, fontSize: 12.5 }));
63
+ 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 })); }
64
+ } else if (it.note) {
65
+ parts.push(text(cx + P, y, truncate(it.note, tw - P * 2, 12.5), { fill: theme.lede, fontSize: 12.5 }));
66
+ }
67
+
68
+ // Meta: medidor con riel del mismo tono, más claro.
69
+ if (it.target !== undefined) {
70
+ y += 24 * X;
71
+ const t = num(it.target);
72
+ const f = Math.max(0, Math.min(1, value / (t || 1)));
73
+ const w = tw - P * 2;
74
+ const upGood = (it.good ?? 'up') === 'up';
75
+ const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
76
+ const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
77
+ parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
78
+ parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
79
+ const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
80
+ parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
81
+ }
82
+
83
+ // Tendencia: línea recesiva, último punto en el acento.
84
+ if (Array.isArray(it.trend) && it.trend.length > 1) {
85
+ const vals = it.trend.map(num).filter(Number.isFinite);
86
+ const mn = Math.min(...vals), mx = Math.max(...vals);
87
+ const sx = cx + P, sw = tw - P * 2, sy = cy + th - P - 26, sh = 26;
88
+ const pts = vals.map((v, k) => [sx + (k / (vals.length - 1)) * sw, sy + sh - ((v - mn) / (mx - mn || 1)) * sh]);
89
+ 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' }));
90
+ const [lx, ly] = pts[pts.length - 1];
91
+ parts.push(el('circle', { cx: lx, cy: ly, r: 3.5, fill: theme.categorical[0], stroke: theme.tint, strokeWidth: 2 }));
92
+ }
93
+ body.push(g({ 'data-tip': tip(it.label, valStr, it.period ? `${it.period}` : '') }, parts));
94
+ });
95
+ const desc = items.map((it) => `${it.label}: ${it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value)])(num(it.value))}`).join('. ');
96
+ return frame.finish(body, { desc, type: 'kpi' });
97
+ }
@@ -0,0 +1,419 @@
1
+ // Líneas y áreas: series de tiempo, índices base 100, bandas de confianza
2
+ // (seaborn lineplot), escala log y áreas apiladas.
3
+ // Etiquetas SIEMPRE al final de cada línea: si chocan se separan y una línea
4
+ // guía fina las une con su punto. El eje y va por dentro para ganar ancho.
5
+ import { el, g, text, tip } from '../svg.js';
6
+ import { measure, truncate } from '../text.js';
7
+ import { makeFormat, formatDate, parseDate } from '../format.js';
8
+ import { niceDomain, linear, log, logDomain, timeTicks, dateUnit, niceStep } from '../scale.js';
9
+ import { colorMap, readable } from '../theme.js';
10
+ import { ensureData, inferXY, requireField, melt, uniq, aggregate, num, fieldKind, xValue, humanize } from '../data.js';
11
+ import { xLabels, refLinesY, yAxisMargin, yAxisTop, yGrid, yTickLabels } from '../axes.js';
12
+ import { createFrame } from '../frame.js';
13
+ import { ctx } from '../context.js';
14
+ import { meanCI, mean, sd } from '../stats.js';
15
+ import { hash } from '../frame.js';
16
+ import { dashedMatcher, fromTest, hatcher, dashedDefaultLabel, DASH } from '../dashed.js';
17
+
18
+ export function prepareLine(spec) {
19
+ let data = ensureData(spec);
20
+ let series = spec.series ?? spec.hue ?? spec.color;
21
+ let y = spec.y;
22
+ if (Array.isArray(y)) {
23
+ if (y.length > 1) {
24
+ data = melt(data, spec.x, y, '__series', '__value', spec.labelsFor || {});
25
+ series = '__series';
26
+ y = '__value';
27
+ } else y = y[0];
28
+ }
29
+ const inf = inferXY({ ...spec, y }, data);
30
+ const x = inf.x;
31
+ y = y || inf.y;
32
+ requireField(data, x, 'x');
33
+ requireField(data, y, 'y');
34
+ if (series) requireField(data, series, 'series');
35
+ const kind = fieldKind(data, x);
36
+ const cats = kind === 'category' ? uniq(data.map((r) => String(r[x]))) : null;
37
+ const seriesKeys = series ? uniq(data.map((r) => String(r[series]))) : [humanize(y) || 'Serie'];
38
+
39
+ // Rango visible (slider temporal): filtra antes de calcular escalas.
40
+ const range = spec.xRange;
41
+ const rv = (v) => (v === undefined || v === null ? null : kind === 'date' ? parseDate(v) : kind === 'category' ? cats.indexOf(String(v)) : num(v));
42
+ const [r0, r1] = range ? [rv(range[0]), rv(range[1])] : [null, null];
43
+
44
+ const map = new Map(seriesKeys.map((s) => [s, new Map()]));
45
+ for (const r of data) {
46
+ const s = series ? String(r[series]) : seriesKeys[0];
47
+ const xv = kind === 'category' ? cats.indexOf(String(r[x])) : xValue(r[x], kind);
48
+ if (!Number.isFinite(xv)) continue;
49
+ if (r0 !== null && Number.isFinite(r0) && xv < r0) continue;
50
+ if (r1 !== null && Number.isFinite(r1) && xv > r1) continue;
51
+ const m = map.get(s);
52
+ if (!m.has(xv)) m.set(xv, []);
53
+ m.get(xv).push(num(r[y]));
54
+ }
55
+ // Estimador: suma por defecto; con `ci`/`errorbar` → media ± intervalo (seaborn).
56
+ const errorbar = spec.errorbar ?? (spec.ci === true || typeof spec.ci === 'number' ? 'ci' : null);
57
+ const how = spec.aggregate ?? spec.estimator ?? (errorbar ? 'mean' : 'sum');
58
+ const level = typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95;
59
+ let lines = seriesKeys.map((s) => ({
60
+ key: s,
61
+ points: [...map.get(s).entries()]
62
+ .map(([xv, ys]) => {
63
+ const v = ys.filter(Number.isFinite);
64
+ const p = { x: xv, y: aggregate(v, how), n: v.length };
65
+ if (errorbar && v.length > 1) {
66
+ if (errorbar === 'sd') { const m = mean(v), d = sd(v); p.lo = m - d; p.hi = m + d; }
67
+ else if (errorbar === 'se') { const m = mean(v), d = sd(v) / Math.sqrt(v.length); p.lo = m - d; p.hi = m + d; }
68
+ else [p.lo, p.hi] = meanCI(v, level);
69
+ }
70
+ return p;
71
+ })
72
+ .sort((a, b) => a.x - b.x),
73
+ }));
74
+ if (spec.index) {
75
+ lines = lines.map((l) => {
76
+ const base = l.points.find((p) => Number.isFinite(p.y) && p.y !== 0)?.y;
77
+ const k = base ? 100 / base : NaN;
78
+ return { ...l, points: l.points.map((p) => ({ ...p, raw: p.y, y: p.y * k, lo: p.lo * k, hi: p.hi * k })) };
79
+ });
80
+ }
81
+ return { data, x, y, series, seriesKeys, lines, kind, cats, errorbar };
82
+ }
83
+
84
+ export function line(spec, theme, variant = 'line') {
85
+ const m = prepareLine(spec);
86
+ const locale = spec.locale || 'es';
87
+ const M = theme.marks || {};
88
+ const isArea = variant === 'area' || spec.area === true;
89
+ const multi = m.lines.length > 1;
90
+ const stack = isArea && multi && spec.stack !== false;
91
+ const pct = stack && (spec.stack === 'percent' || spec.normalize);
92
+ const xsAll = uniq(m.lines.flatMap((l) => l.points.map((p) => p.x))).sort((a, b) => a - b);
93
+ if (!xsAll.length) return createFrame(spec, theme).finish([], { desc: '', type: variant });
94
+
95
+ // Apilado (áreas), opcionalmente al 100%.
96
+ if (stack) {
97
+ const tot = new Map(xsAll.map((xv) => [xv, 0]));
98
+ for (const l of m.lines) for (const p of l.points) if (Number.isFinite(p.y)) tot.set(p.x, tot.get(p.x) + p.y);
99
+ const base = new Map(xsAll.map((xv) => [xv, 0]));
100
+ for (const l of m.lines) {
101
+ const byX = new Map(l.points.map((p) => [p.x, p.y]));
102
+ l.points = xsAll.map((xv) => {
103
+ let v = Number.isFinite(byX.get(xv)) ? byX.get(xv) : 0;
104
+ if (pct) v = tot.get(xv) ? v / tot.get(xv) : 0;
105
+ const y0 = base.get(xv);
106
+ base.set(xv, y0 + v);
107
+ return { x: xv, y: y0 + v, y0, v };
108
+ });
109
+ }
110
+ }
111
+
112
+ const allY = m.lines.flatMap((l) => l.points.flatMap((p) => [p.y, p.lo, p.hi])).filter(Number.isFinite);
113
+ const rawY = m.lines.flatMap((l) => l.points.map((p) => p.v ?? p.y)).filter(Number.isFinite);
114
+ const fmt = pct
115
+ ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale)
116
+ : spec.index ? makeFormat({ type: 'number', decimals: spec.format?.decimals ?? 0 }, locale) : makeFormat(spec.format, locale, rawY);
117
+ let ymin = Math.min(...allY), ymax = Math.max(...allY);
118
+ const refs = spec.refLines || [];
119
+ for (const r of refs) if (r.y !== undefined) { ymin = Math.min(ymin, r.y); ymax = Math.max(ymax, r.y); }
120
+ const yLog = spec.yScale === 'log' && ymin > 0;
121
+ const zero = !yLog && (spec.zero ?? (isArea || (ymin >= 0 && ymin <= ymax * 0.45) || (ymax <= 0 && ymax >= ymin * 0.45)));
122
+ if (zero) { ymin = Math.min(0, ymin); ymax = Math.max(0, ymax); }
123
+ if (spec.yDomain) [ymin, ymax] = spec.yDomain;
124
+
125
+ // Colores y foco
126
+ const cmap = colorMap(m.seriesKeys, theme, spec.colors);
127
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
128
+ const focus = (k) => !hl || hl.has(k);
129
+ const colorOf = (k) => (focus(k) ? (!multi ? (spec.colors?.[k] || theme.categorical[0]) : cmap.get(k)) : theme.dim);
130
+
131
+ // Trazo discontinuo: series enteras (`dashed`) o el tramo desde `dashedFrom` (proyección).
132
+ const dm = dashedMatcher(spec, [], null, null);
133
+ const dashedSeries = (k) => !!(dm && dm(k));
134
+ const fromT = fromTest(spec, (v) => (m.kind === 'category' ? m.cats.indexOf(String(v)) : typeof v === 'number' && m.kind === 'date' ? v : xValue(v, m.kind)));
135
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
136
+
137
+ // Etiquetas al final: siempre, salvo `labels: false`. Si hay demasiadas para
138
+ // el alto disponible, se rotulan las de mayor valor final y la leyenda cubre el resto.
139
+ const labelMode = spec.labels ?? 'auto';
140
+ const lastOf = (l) => [...l.points].reverse().find((p) => Number.isFinite(p.y));
141
+ const labelSize = theme.type?.label ?? 12;
142
+ const endText = (l) => {
143
+ const last = lastOf(l);
144
+ if (!last) return null;
145
+ const val = fmt(stack ? last.v : last.y);
146
+ return multi ? { name: truncate(l.key, 132, labelSize), val } : { name: '', val };
147
+ };
148
+
149
+ const frame = createFrame(spec, theme, { height: 400 });
150
+ const want = labelMode !== false;
151
+ // Leyenda solo si alguna serie queda sin etiqueta final.
152
+ const provisionalH = frame.bottom - frame.top - 40;
153
+ const maxLabels = Math.max(1, Math.floor(provisionalH / (labelSize * ctx.textScale + 3)));
154
+ const labeled = want ? m.lines.filter((l) => focus(l.key)).sort((a, b) => (lastOf(b)?.y ?? -Infinity) - (lastOf(a)?.y ?? -Infinity)).slice(0, maxLabels) : [];
155
+ const allLabeled = labeled.length === m.lines.filter((l) => focus(l.key)).length;
156
+ const needLegend = multi && (!want || !allLabeled || spec.legend === true) && spec.legend !== false;
157
+ const legend = needLegend ? m.seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: isArea ? 'square' : 'line', dashed: dashedSeries(s) })) : [];
158
+ const area = frame.layout(legend);
159
+
160
+ const endW = labeled.length
161
+ ? Math.max(...labeled.map((l) => { const t = endText(l); return t ? measure(t.val, labelSize, 600) + (t.name ? measure(t.name, labelSize, 500) * 1.04 + 6 * ctx.textScale : 0) : 0; })) + 22
162
+ : 10;
163
+
164
+ // Escala y
165
+ const nd = yLog ? logDomain(ymin, ymax) : spec.yDomain ? { min: ymin, max: ymax, step: niceStep(ymax - ymin, Math.max(3, Math.round(area.h / 64))) } : niceDomain(ymin, ymax, Math.max(3, Math.round(area.h / 64)));
166
+ const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step);
167
+ const yTicksArr = (yLog ? log(nd.min, nd.max, 0, 1) : linear(nd.min, nd.max, 0, 1)).ticks(nd.step);
168
+ const inside = stack ? false : spec.axisInside;
169
+ const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
170
+ const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
171
+ const inner = { x: area.x + leftM, y: yTop, w: Math.max(80, area.w - leftM - endW), h: area.y + area.h - yTop - 24 * ctx.textScale - (spec.xLabel ? 16 * ctx.textScale : 0) };
172
+ const yS = (yLog ? log : linear)(nd.min, nd.max, inner.y + inner.h, inner.y);
173
+
174
+ // Escala x
175
+ let x0 = xsAll[0], x1 = xsAll[xsAll.length - 1];
176
+ if (spec.xDomain) [x0, x1] = spec.xDomain.map((v) => (typeof v === 'number' ? v : m.kind === 'date' ? parseDate(v) : num(v)));
177
+ if (spec._domains) { spec._domains.y = [nd.min, nd.max]; spec._domains.x = [x0, x1]; }
178
+ const xLog = spec.xScale === 'log' && x0 > 0;
179
+ const xS = (xLog ? log : linear)(x0, x1 === x0 ? x0 + 1 : x1, inner.x, inner.x + inner.w);
180
+ const xFmt = (xv) => {
181
+ if (m.kind === 'date') return formatDate(xv, dateUnit(xsAll), locale);
182
+ if (m.kind === 'category') return m.cats[xv];
183
+ if (m.kind === 'year') return String(xv);
184
+ return makeFormat(spec.xFormat, locale, xsAll)(xv);
185
+ };
186
+ let xItems;
187
+ if (m.kind === 'date') {
188
+ xItems = timeTicks(x0, x1, Math.max(2, Math.floor(inner.w / 86)), locale).ticks.map((t) => ({ pos: xS(t.value), label: t.label }));
189
+ } else if (m.kind === 'category') {
190
+ xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
191
+ } else if (xLog) {
192
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: makeFormat(spec.xFormat, locale, xsAll).tick(t)(t) }));
193
+ } else {
194
+ let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
195
+ const ints = xsAll.every(Number.isInteger);
196
+ if (m.kind === 'year' || ints) st = Math.max(1, Math.round(st));
197
+ const tf = m.kind === 'year' ? (v) => String(v) : makeFormat(spec.xFormat ?? (ints ? 'integer' : undefined), locale, xsAll).tick(st);
198
+ xItems = xS.ticks(st).map((t) => ({ pos: xS(t), label: tf(t) }));
199
+ }
200
+
201
+ const body = [];
202
+ const yTicks = yS.ticks(nd.step);
203
+ body.push(yGrid(yTicks, yS, inner, theme, { baseline: zero ? 0 : NaN }));
204
+ if (spec.yLabel) body.push(text(area.x, area.y + 10, spec.yLabel, { fill: theme.lede, fontSize: theme.type?.axis ?? 12, fontWeight: 500 }));
205
+ body.push(xLabels(xItems, inner, theme, { y: inner.y + inner.h + 18, size: theme.type?.axis }));
206
+ if (spec.xLabel) body.push(text(inner.x + inner.w, inner.y + inner.h + 36, spec.xLabel, { fill: theme.lede, fontSize: theme.type?.axis ?? 12, fontWeight: 500, textAnchor: 'end' }));
207
+
208
+ // Anotaciones verticales (eventos)
209
+ for (const a of spec.annotations || []) {
210
+ const xv = m.kind === 'category' ? m.cats.indexOf(String(a.x)) : xValue(a.x, m.kind);
211
+ if (!Number.isFinite(xv) || xv < x0 || xv > x1) continue;
212
+ const px = Math.round(xS(xv)) + 0.5;
213
+ body.push(el('line', { x1: px, x2: px, y1: inner.y, y2: inner.y + inner.h, stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
214
+ if (a.label) {
215
+ const w = measure(a.label, 11.5, 500);
216
+ const right = px + 5 + w > inner.x + inner.w;
217
+ // A la izquierda de la línea si no cabe a la derecha, sin salirse del área.
218
+ const ax = right ? Math.max(px - 5, area.x + w) : px + 5;
219
+ body.push(text(ax, inner.y + 4, a.label, { fill: theme.lede, fontSize: 11.5, fontWeight: 500, textAnchor: right ? 'end' : 'start', stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
220
+ }
221
+ }
222
+
223
+ // Tramo proyectado: banda suave de fondo con su rótulo, desde el último dato firme.
224
+ let cutX = null;
225
+ if (fromT) {
226
+ const firm = xsAll.filter((xv) => !fromT(xv));
227
+ const startX = firm.length ? firm[firm.length - 1] : xsAll.find((xv) => fromT(xv));
228
+ if (startX !== undefined && xsAll.some((xv) => fromT(xv)) && startX <= x1) {
229
+ cutX = Math.max(inner.x, xS(Math.max(startX, x0)));
230
+ body.push(el('rect', { x: cutX, y: inner.y, width: Math.max(0, inner.x + inner.w - cutX), height: inner.h, fill: theme.tint, opacity: 0.55, 'pointer-events': 'none' }));
231
+ body.push(el('line', { x1: cutX, x2: cutX, y1: inner.y, y2: inner.y + inner.h, stroke: theme.muted, strokeWidth: 1, strokeDasharray: '2 3', opacity: 0.8 }));
232
+ if (spec.dashedLabel !== false) body.push(text(cutX + 6, inner.y + 11, dashedLabel, { fill: theme.muted, fontSize: 11.5, fontWeight: 500, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
233
+ }
234
+ }
235
+ const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
236
+ const H = hatcher(theme, gidD);
237
+ // Recortes para pintar áreas: firme a la izquierda de cutX, rayado a la derecha.
238
+ const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
239
+
240
+ // Trazos
241
+ const ordered = [...m.lines].sort((a, b) => (focus(a.key) ? 1 : 0) - (focus(b.key) ? 1 : 0));
242
+ const showDots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
243
+ const curve = spec.curve === 'smooth' ? monotonePath : spec.curve === 'step' ? stepPath : linearPath;
244
+ const drawOrder = stack ? [...m.lines].reverse() : ordered;
245
+ const lw = M.lineWidth ?? 2.25;
246
+ const gid = 'pa-grad-' + Math.abs(hash(String(spec.title) + m.y + inner.w)).toString(36);
247
+ let defs = '';
248
+ const washSingle = !multi && !isArea && spec.fill !== false && !m.errorbar && zero;
249
+ for (const l of drawOrder) {
250
+ const c = colorOf(l.key);
251
+ const segs = splitGaps(l.points, xS, yS);
252
+ if (!segs.length) continue;
253
+ const parts = [];
254
+ // Banda de confianza (seaborn): antes de la línea, mismo tono.
255
+ if (m.errorbar && !stack) {
256
+ const bpts = l.points.filter((p) => Number.isFinite(p.lo) && Number.isFinite(p.hi));
257
+ if (bpts.length > 1) {
258
+ const up = bpts.map((p) => [xS(p.x), yS(p.hi)]);
259
+ const dn = bpts.map((p) => [xS(p.x), yS(p.lo)]).reverse();
260
+ parts.push(el('path', { d: curve(up) + 'L' + dn.map(([a, b]) => `${a.toFixed(2)},${b.toFixed(2)}`).join('L') + 'Z', fill: c, fillOpacity: focus(l.key) ? M.bandOpacity ?? 0.16 : 0.06, stroke: 'none' }));
261
+ }
262
+ }
263
+ if (isArea || washSingle) {
264
+ if (washSingle) {
265
+ defs += el('linearGradient', { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 }, [el('stop', { offset: '0%', stopColor: c, stopOpacity: 0.22 }), el('stop', { offset: '100%', stopColor: c, stopOpacity: 0 })]);
266
+ }
267
+ for (const sgm of segs) {
268
+ const top = curve(sgm.map(([a, b]) => [a, b]));
269
+ const base0 = yS(Math.max(nd.min, 0));
270
+ const bottom = stack ? sgm.map(([a, , p]) => [a, yS(p.y0)]).reverse() : [[sgm[sgm.length - 1][0], base0], [sgm[0][0], base0]];
271
+ const d = top + 'L' + bottom.map(([a, b]) => `${a.toFixed(2)},${b.toFixed(2)}`).join('L') + 'Z';
272
+ const areaAttrs = { d, fill: washSingle ? `url(#${gid})` : c, fillOpacity: washSingle ? 1 : stack ? 0.9 : M.areaOpacity ?? 0.12, stroke: stack ? theme.surface : 'none', strokeWidth: stack ? 1.5 : 0 };
273
+ const hatchedArea = stack || dashedSeries(l.key);
274
+ if (cutX !== null && hatchedArea) {
275
+ // Área apilada o serie punteada: el tramo proyectado va rayado en su color.
276
+ parts.push(el('path', { ...areaAttrs, 'clip-path': `url(#${clipL})` }));
277
+ parts.push(el('path', { ...areaAttrs, fill: H.fill(c), fillOpacity: stack ? 0.95 : 0.5, 'clip-path': `url(#${clipR})` }));
278
+ } else if (dashedSeries(l.key) && !washSingle) parts.push(el('path', { ...areaAttrs, fill: H.fill(c), fillOpacity: stack ? 0.95 : 0.5 }));
279
+ else parts.push(el('path', areaAttrs));
280
+ }
281
+ }
282
+ const sw = focus(l.key) ? (hl ? lw + 0.75 : lw) : 1.5;
283
+ const lineAttrs = { fill: 'none', stroke: c, strokeWidth: sw, strokeLinejoin: 'round', strokeLinecap: 'round' };
284
+ const dashAttrs = { ...lineAttrs, strokeDasharray: DASH.line };
285
+ if (!stack) for (const sgm of segs) {
286
+ if (dashedSeries(l.key)) { parts.push(el('path', { d: curve(sgm.map(([a, b]) => [a, b])), ...dashAttrs })); continue; }
287
+ // Tramo firme sólido y tramo proyectado discontinuo, unidos en el último dato firme.
288
+ const k = fromT ? sgm.findIndex(([, , p]) => fromT(p.x)) : -1;
289
+ if (k < 0) parts.push(el('path', { d: curve(sgm.map(([a, b]) => [a, b])), ...lineAttrs }));
290
+ else {
291
+ if (k > 0) parts.push(el('path', { d: curve(sgm.slice(0, k).map(([a, b]) => [a, b])), ...lineAttrs }));
292
+ const rest = sgm.slice(Math.max(0, k - 1));
293
+ if (rest.length > 1) parts.push(el('path', { d: curve(rest.map(([a, b]) => [a, b])), ...dashAttrs }));
294
+ }
295
+ }
296
+ const pts = segs.flat();
297
+ // Puntos proyectados huecos: se ven como "todavía no medidos".
298
+ const hollow = (p) => dashedSeries(l.key) || (fromT && fromT(p.x));
299
+ const dot = ([a, b, p], r) => el('circle', { cx: a, cy: b, r, ...(hollow(p) ? { fill: theme.surface, stroke: c, strokeWidth: 1.75 } : { fill: c, stroke: theme.surface, strokeWidth: M.ring ?? 2 }) });
300
+ if (showDots && focus(l.key) && !stack) for (const q of pts) parts.push(dot(q, (M.pointRadius ?? 4) - 0.5));
301
+ if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
302
+ body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
303
+ }
304
+
305
+ body.push(refLinesY(refs, yS, inner, theme, fmt));
306
+ body.push(yTickLabels(yTicks, yS, inner, theme, tickF, { inside }));
307
+
308
+ // Etiquetas finales con separación vertical y líneas guía.
309
+ if (labeled.length) {
310
+ const lh = labelSize * ctx.textScale + 3;
311
+ const labs = labeled.map((l) => {
312
+ const last = lastOf(l);
313
+ const t = endText(l);
314
+ const yy = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
315
+ return { ...t, key: l.key, y: yy, y0: yy, px: xS(last.x), color: colorOf(l.key) };
316
+ }).sort((a, b) => a.y0 - b.y0);
317
+ dodge(labs, lh, inner.y + lh / 2 - 4, inner.y + inner.h + 4);
318
+ const anyMoved = labs.some((lb) => Math.abs(lb.y - lb.y0) > 1.5);
319
+ const lx = inner.x + inner.w + (anyMoved || stack ? 16 : 9);
320
+ for (const lb of labs) {
321
+ const tparts = [];
322
+ if (Math.abs(lb.y - lb.y0) > 1.5 || stack) {
323
+ const sx = lb.px + (stack ? 2 : 7);
324
+ tparts.push(el('path', { d: `M${sx.toFixed(1)},${lb.y0.toFixed(1)}L${(lx - 9).toFixed(1)},${lb.y0.toFixed(1)}L${(lx - 4).toFixed(1)},${lb.y.toFixed(1)}`, fill: 'none', stroke: stack ? theme.muted : lb.color, strokeWidth: 1, opacity: 0.7 }));
325
+ }
326
+ if (lb.name) {
327
+ // La etiqueta lleva el color de su línea (versión legible), así se conecta sin leyenda.
328
+ const tc = readable(lb.color, theme);
329
+ tparts.push(text(lx, lb.y + 4, lb.name, { fill: tc, fontSize: labelSize, fontWeight: 500 }));
330
+ tparts.push(text(lx + measure(lb.name, labelSize, 500) * 1.04 + 6 * ctx.textScale, lb.y + 4, lb.val, { fill: tc, fontSize: labelSize, fontWeight: 700 }));
331
+ } else tparts.push(text(lx, lb.y + 4.5, lb.val, { fill: readable(lb.color, theme), fontSize: labelSize + 0.5, fontWeight: 700 }));
332
+ body.push(g({ class: 'pa-endlabel' }, tparts));
333
+ }
334
+ }
335
+
336
+ // Capa de interacción: crosshair con todos los valores en ese x.
337
+ const cross = xsAll.map((xv) => ({
338
+ x: +xS(xv).toFixed(1),
339
+ t: xFmt(xv) + (fromT && fromT(xv) ? ` · ${dashedLabel}` : ''),
340
+ v: m.lines.filter((l) => focus(l.key)).map((l) => {
341
+ const p = l.points.find((q) => q.x === xv);
342
+ if (!p || !Number.isFinite(p.y)) return null;
343
+ const extra = Number.isFinite(p.lo) ? ` (${fmt(p.lo)}–${fmt(p.hi)})` : '';
344
+ return [multi ? l.key : m.seriesKeys[0], colorOf(l.key), +yS(p.y).toFixed(1), fmt(stack ? p.v : p.y) + extra];
345
+ }).filter(Boolean),
346
+ }));
347
+ body.push(el('rect', { class: 'pa-cross', x: inner.x - 4, y: inner.y, width: inner.w + 8, height: inner.h, fill: 'transparent', 'data-cross': JSON.stringify(cross), 'data-y0': inner.y, 'data-y1': inner.y + inner.h }));
348
+
349
+ const desc = describeLine(m, fmt, xFmt, locale);
350
+ if (cutX !== null) defs += el('clipPath', { id: clipL }, el('rect', { x: 0, y: 0, width: cutX, height: frame.height })) + el('clipPath', { id: clipR }, el('rect', { x: cutX, y: 0, width: frame.width, height: frame.height }));
351
+ return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
352
+ }
353
+
354
+ /** Separa etiquetas en 1D: empuja hacia abajo y, si se pasan, hacia arriba. */
355
+ function dodge(labs, h, top, bottom) {
356
+ for (let i = 0; i < labs.length; i++) labs[i].y = Math.max(labs[i].y0, i ? labs[i - 1].y + h : top);
357
+ for (let i = labs.length - 1; i >= 0; i--) {
358
+ const lim = i === labs.length - 1 ? bottom : labs[i + 1].y - h;
359
+ if (labs[i].y > lim) labs[i].y = lim;
360
+ }
361
+ for (let i = 0; i < labs.length; i++) if (labs[i].y < top + i * h) labs[i].y = top + i * h;
362
+ }
363
+
364
+ function splitGaps(points, xS, yS) {
365
+ const segs = [];
366
+ let cur = [];
367
+ for (const p of points) {
368
+ if (Number.isFinite(p.y)) cur.push([xS(p.x), yS(p.y), p]);
369
+ else if (cur.length) { segs.push(cur); cur = []; }
370
+ }
371
+ if (cur.length) segs.push(cur);
372
+ return segs;
373
+ }
374
+
375
+ const f2 = (n) => n.toFixed(2);
376
+ function linearPath(pts) {
377
+ return pts.map(([x, y], i) => (i ? 'L' : 'M') + f2(x) + ',' + f2(y)).join('');
378
+ }
379
+ function stepPath(pts) {
380
+ return pts.map(([x, y], i) => (i ? `H${f2(x)}V${f2(y)}` : `M${f2(x)},${f2(y)}`)).join('');
381
+ }
382
+
383
+ /** Curva monótona (no inventa máximos ni mínimos entre puntos). */
384
+ function monotonePath(pts) {
385
+ const n = pts.length;
386
+ if (n < 3) return linearPath(pts);
387
+ const dx = [], dy = [], s = [];
388
+ for (let i = 0; i < n - 1; i++) {
389
+ dx.push(pts[i + 1][0] - pts[i][0]);
390
+ dy.push(pts[i + 1][1] - pts[i][1]);
391
+ s.push(dy[i] / (dx[i] || 1));
392
+ }
393
+ const t = [s[0]];
394
+ for (let i = 1; i < n - 1; i++) t.push(s[i - 1] * s[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / s[i - 1] + (dx[i] + 2 * dx[i - 1]) / s[i]));
395
+ t.push(s[n - 2]);
396
+ let d = 'M' + f2(pts[0][0]) + ',' + f2(pts[0][1]);
397
+ for (let i = 0; i < n - 1; i++) {
398
+ const h = dx[i] / 3;
399
+ d += `C${f2(pts[i][0] + h)},${f2(pts[i][1] + h * t[i])} ${f2(pts[i + 1][0] - h)},${f2(pts[i + 1][1] - h * t[i + 1])} ${f2(pts[i + 1][0])},${f2(pts[i + 1][1])}`;
400
+ }
401
+ return d;
402
+ }
403
+
404
+ export function describeLine(m, fmt, xFmt, locale = 'es') {
405
+ const en = String(locale).startsWith('en');
406
+ const parts = m.lines.slice(0, 6).map((l) => {
407
+ const pts = l.points.filter((p) => Number.isFinite(p.v ?? p.y));
408
+ if (pts.length < 2) return '';
409
+ const a = pts[0], b = pts[pts.length - 1];
410
+ const va = a.v ?? a.y, vb = b.v ?? b.y;
411
+ const ch = va !== 0 ? ((vb - va) / Math.abs(va)) * 100 : null;
412
+ const chs = ch === null || fmt.type === 'percent' ? '' : ` (${ch >= 0 ? '+' : ''}${Math.round(ch)}%)`;
413
+ const max = pts.reduce((p, q) => ((q.v ?? q.y) > (p.v ?? p.y) ? q : p));
414
+ return en
415
+ ? `${l.key}: from ${fmt(va)} (${xFmt(a.x)}) to ${fmt(vb)} (${xFmt(b.x)})${chs}, peak ${fmt(max.v ?? max.y)} in ${xFmt(max.x)}.`
416
+ : `${l.key}: de ${fmt(va)} (${xFmt(a.x)}) a ${fmt(vb)} (${xFmt(b.x)})${chs}, máximo ${fmt(max.v ?? max.y)} en ${xFmt(max.x)}.`;
417
+ });
418
+ return (en ? 'Line chart. ' : 'Gráfico de líneas. ') + parts.filter(Boolean).join(' ');
419
+ }