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.
Files changed (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
@@ -0,0 +1,551 @@
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, intStep, halfUnit } from '../scale.js';
9
+ import { colorMap, readable } from '../theme.js';
10
+ import { ensureData, inferXY, requireField, melt, uniq, aggregate, num, fieldKind, xValue, humanize, PlotError } from '../data.js';
11
+ import { xLabels, refLinesY, yAxisMargin, yAxisTop, yGrid, yTickLabels, isInside, ticksWidth } from '../axes.js';
12
+ import { createFrame } from '../frame.js';
13
+ import { ctx } from '../context.js';
14
+ import { meanCI, mean, sd, extent } 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
+ const en = String(locale).startsWith('en');
95
+ if (!m.lines.some((l) => l.points.some((p) => Number.isFinite(p.y))))
96
+ throw new PlotError(
97
+ en ? `No numeric values in \`${m.y}\` to plot.` : `No hay valores numéricos en \`${m.y}\` para graficar.`,
98
+ en ? `Check that "${m.y}" holds numbers (not empty or text), or pick another field with y: "field".` : `Revisa que "${m.y}" tenga números (no vacíos ni texto), o elige otro campo con y: "campo".`
99
+ );
100
+
101
+ // Apilado (áreas), opcionalmente al 100%. Positivos y negativos se apilan por
102
+ // separado desde cero (como `stackStrategy: 'samesign'` de ECharts).
103
+ if (stack) {
104
+ const tot = new Map(xsAll.map((xv) => [xv, 0]));
105
+ 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) + Math.abs(p.y));
106
+ const pos = new Map(xsAll.map((xv) => [xv, 0]));
107
+ const neg = new Map(xsAll.map((xv) => [xv, 0]));
108
+ for (const l of m.lines) {
109
+ const byX = new Map(l.points.map((p) => [p.x, p.y]));
110
+ l.points = xsAll.map((xv) => {
111
+ let v = Number.isFinite(byX.get(xv)) ? byX.get(xv) : 0;
112
+ if (pct) v = tot.get(xv) ? v / tot.get(xv) : 0;
113
+ const base = v < 0 ? neg : pos;
114
+ const y0 = base.get(xv);
115
+ base.set(xv, y0 + v);
116
+ return { x: xv, y: y0 + v, y0, v };
117
+ });
118
+ }
119
+ }
120
+
121
+ const allY = [], rawY = [];
122
+ for (const l of m.lines) for (const p of l.points) {
123
+ if (Number.isFinite(p.y)) allY.push(p.y);
124
+ if (Number.isFinite(p.lo)) allY.push(p.lo);
125
+ if (Number.isFinite(p.hi)) allY.push(p.hi);
126
+ const r = p.v ?? p.y;
127
+ if (Number.isFinite(r)) rawY.push(r);
128
+ }
129
+ const fmt = pct
130
+ ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale)
131
+ : spec.index ? makeFormat({ type: 'number', decimals: spec.format?.decimals ?? 0 }, locale) : makeFormat(spec.format, locale, rawY);
132
+ let [ymin, ymax] = extent(allY);
133
+ const refs = spec.refLines || [];
134
+ for (const r of refs) if (r.y !== undefined) { ymin = Math.min(ymin, r.y); ymax = Math.max(ymax, r.y); }
135
+ const yLog = spec.yScale === 'log' && ymin > 0;
136
+ const zero = !yLog && (spec.zero ?? (isArea || (ymin >= 0 && ymin <= ymax * 0.45) || (ymax <= 0 && ymax >= ymin * 0.45)));
137
+ if (zero) { ymin = Math.min(0, ymin); ymax = Math.max(0, ymax); }
138
+ if (spec.yDomain) [ymin, ymax] = spec.yDomain;
139
+
140
+ // Colores y foco
141
+ const cmap = colorMap(m.seriesKeys, theme, spec.colors);
142
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
143
+ const focus = (k) => !hl || hl.has(k);
144
+ const colorOf = (k) => (focus(k) ? (!multi ? (spec.colors?.[k] || theme.categorical[0]) : cmap.get(k)) : theme.dim);
145
+
146
+ // Trazo discontinuo: series enteras (`dashed`) o el tramo desde `dashedFrom` (proyección).
147
+ const dm = dashedMatcher(spec, [], null, null);
148
+ const dashedSeries = (k) => !!(dm && dm(k));
149
+ const fromT = fromTest(spec, (v) => (m.kind === 'category' ? m.cats.indexOf(String(v)) : typeof v === 'number' && m.kind === 'date' ? v : xValue(v, m.kind)));
150
+ const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
151
+
152
+ // Etiquetas al final: siempre, salvo `labels: false`. Si hay demasiadas para
153
+ // el alto disponible, se rotulan las de mayor valor final y la leyenda cubre el resto.
154
+ const labelMode = spec.labels ?? 'auto';
155
+ const lastOf = (l) => [...l.points].reverse().find((p) => Number.isFinite(p.y));
156
+ const labelSize = theme.type?.label ?? 12;
157
+ const endText = (l) => {
158
+ const last = lastOf(l);
159
+ if (!last) return null;
160
+ const val = fmt(stack ? last.v : last.y);
161
+ return multi ? { name: truncate(l.key, 132, labelSize), val } : { name: '', val };
162
+ };
163
+
164
+ const frame = createFrame(spec, theme, { height: 400 });
165
+ const want = labelMode !== false;
166
+ // Leyenda solo si alguna serie queda sin etiqueta final.
167
+ const lh = labelSize * ctx.textScale + 3;
168
+ const provisionalH = frame.bottom - frame.top - 40;
169
+ const maxLabels = Math.max(1, Math.floor(provisionalH / lh));
170
+ // Series sin ningún dato válido no llevan etiqueta (ni rompen el cálculo).
171
+ const labelable = m.lines.filter((l) => focus(l.key) && lastOf(l));
172
+ let labeled = want ? [...labelable].sort((a, b) => lastOf(b).y - lastOf(a).y).slice(0, maxLabels) : [];
173
+ const allLabeled = labeled.length === labelable.length;
174
+ const needLegend = multi && (!want || !allLabeled || spec.legend === true || (spec.legend && typeof spec.legend !== 'boolean')) && spec.legend !== false;
175
+ const legend = needLegend ? m.seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: isArea ? 'square' : 'line', dashed: dashedSeries(s) })) : [];
176
+ const area = frame.layout(legend);
177
+ const inside = stack ? false : spec.axisInside;
178
+ // Alto vertical fijo alrededor del área de trazado (marca superior, eje x, títulos).
179
+ const yChrome = yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0) + 24 * ctx.textScale + (spec.xLabel ? 16 * ctx.textScale : 0);
180
+ // Una leyenda larga en un gráfico bajo no aplasta el trazado: el SVG crece lo justo.
181
+ const MIN_INNER_H = 90 * ctx.textScale;
182
+ if (area.h - yChrome < MIN_INNER_H) {
183
+ const grow = Math.ceil(MIN_INNER_H - (area.h - yChrome));
184
+ frame.setHeight(frame.height + grow);
185
+ area.h += grow;
186
+ }
187
+ // Con el alto real, rotular solo las que caben (la leyenda cubre al resto).
188
+ const capacity = Math.max(1, Math.floor((area.h - yChrome + 8 - lh / 2) / lh) + 1);
189
+ if (labeled.length > capacity) labeled = labeled.slice(0, capacity);
190
+
191
+ const endW = labeled.length
192
+ ? 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
193
+ : 10;
194
+
195
+ // Escala y
196
+ // Valores enteros (conteos): nunca marcas en 0,5.
197
+ const intY = !pct && !spec.index && rawY.length > 0 && rawY.every(Number.isInteger);
198
+ 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)), { integer: intY });
199
+ const yTicksArr = (yLog ? log(nd.min, nd.max, 0, 1) : linear(nd.min, nd.max, 0, 1)).ticks(nd.step);
200
+ const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step, yTicksArr);
201
+ const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
202
+ const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
203
+ 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) };
204
+ const yS = (yLog ? log : linear)(nd.min, nd.max, inner.y + inner.h, inner.y);
205
+
206
+ // Escala x
207
+ let x0 = xsAll[0], x1 = xsAll[xsAll.length - 1];
208
+ if (spec.xDomain) [x0, x1] = spec.xDomain.map((v) => (typeof v === 'number' ? v : m.kind === 'date' ? parseDate(v) : num(v)));
209
+ // Un solo x: centrado, con media unidad a cada lado (media unidad de tiempo en fechas).
210
+ const du = m.kind === 'date' ? dateUnit(xsAll) : null;
211
+ const singleX = x0 === x1;
212
+ if (singleX) { const hw = m.kind === 'date' ? halfUnit(du) : 0.5; x0 -= hw; x1 += hw; }
213
+ if (spec._domains) { spec._domains.y = [nd.min, nd.max]; spec._domains.x = [x0, x1]; }
214
+ const xLog = spec.xScale === 'log' && x0 > 0;
215
+ // Con el eje y por dentro, si el primer punto choca con una etiqueta de marca,
216
+ // el trazado arranca pasado la etiqueta más ancha.
217
+ let xPad = 0;
218
+ if (isInside(theme, inside) && !singleX && !spec.xDomain) {
219
+ const axisSize = theme.type?.axis ?? 12;
220
+ const k = 5 * Math.max(1, ctx.textScale * 0.8);
221
+ // Caja de cada etiqueta: su ancho real, de la altura de las mayúsculas a la línea base.
222
+ const boxes = yTicksArr.map((t) => { const base = Math.round(yS(t)) + 0.5 - k; return { w: measure(tickF(t), axisSize), y0: base - axisSize * 0.75 * ctx.textScale, y1: base + 1 }; });
223
+ const xS0 = (xLog ? log : linear)(x0, x1, inner.x, inner.x + inner.w);
224
+ const dots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
225
+ const r = dots ? (M.pointRadius ?? 4) : (M.lineWidth ?? 2.25) / 2 + 0.5;
226
+ for (const l of m.lines) {
227
+ const p = l.points.find((q) => Number.isFinite(q.y));
228
+ if (!p) continue;
229
+ const px = xS0(p.x), py = yS(p.y);
230
+ if (boxes.some((b) => px - r < inner.x + b.w + 1 && py + r > b.y0 && py - r < b.y1)) xPad = Math.max(xPad, Math.min(Math.max(...boxes.map((b) => b.w)) + 8, inner.w * 0.25));
231
+ }
232
+ }
233
+ const xS = (xLog ? log : linear)(x0, x1, inner.x + xPad, inner.x + inner.w);
234
+ // Formateadores creados una vez (no por punto).
235
+ const xNumFmt = m.kind === 'number' ? makeFormat(spec.xFormat, locale, xsAll) : null;
236
+ const xFmt = (xv) => {
237
+ if (m.kind === 'date') return formatDate(xv, du, locale);
238
+ if (m.kind === 'category') return m.cats[xv];
239
+ if (m.kind === 'year') return String(xv);
240
+ return xNumFmt(xv);
241
+ };
242
+ let xItems;
243
+ if (singleX) {
244
+ xItems = [{ pos: xS(xsAll[0]), label: xFmt(xsAll[0]) }];
245
+ } else if (m.kind === 'date') {
246
+ xItems = timeTicks(x0, x1, Math.max(2, Math.floor(inner.w / 86)), locale).ticks.map((t) => ({ pos: xS(t.value), label: t.label }));
247
+ } else if (m.kind === 'category') {
248
+ xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
249
+ } else if (xLog) {
250
+ const lf = makeFormat(spec.xFormat, locale, xsAll);
251
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t)(t) }));
252
+ } else {
253
+ let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
254
+ const ints = xsAll.every(Number.isInteger);
255
+ // Pasos 1/2/5/10 (2,5 → 2, no 3: sin años impares 2001, 2004…).
256
+ if (m.kind === 'year' || ints) st = intStep(st);
257
+ const xMaxAbs = Math.max(Math.abs(xsAll[0]), Math.abs(xsAll[xsAll.length - 1]));
258
+ const tf = m.kind === 'year' ? (v) => String(v) : makeFormat(spec.xFormat ?? (ints && xMaxAbs < 1e4 ? 'integer' : undefined), locale, xsAll).tick(st);
259
+ xItems = xS.ticks(st).map((t) => ({ pos: xS(t), label: tf(t) }));
260
+ }
261
+
262
+ const body = [];
263
+ const yTicks = yS.ticks(nd.step);
264
+ body.push(yGrid(yTicks, yS, inner, theme, { baseline: zero ? 0 : NaN }));
265
+ if (spec.yLabel) body.push(text(area.x, area.y + 10, spec.yLabel, { fill: theme.lede, fontSize: theme.type?.axis ?? 12, fontWeight: 500 }));
266
+ body.push(xLabels(xItems, inner, theme, { y: inner.y + inner.h + 18, size: theme.type?.axis }));
267
+ 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' }));
268
+
269
+ // Anotaciones verticales (eventos). Los rótulos que chocarían bajan a la
270
+ // fila siguiente (14 px) y no tapan la etiqueta de la marca superior.
271
+ const placedAnn = [];
272
+ if (isInside(theme, inside) && yTicks.length) {
273
+ const tTop = yTicks.reduce((a, b) => (yS(b) < yS(a) ? b : a));
274
+ const ty = Math.round(yS(tTop)) + 0.5 - 5 * Math.max(1, ctx.textScale * 0.8);
275
+ placedAnn.push([inner.x, ty - (theme.type?.axis ?? 12) * ctx.textScale, inner.x + measure(tickF(tTop), theme.type?.axis ?? 12) + 2, ty + 2]);
276
+ }
277
+ for (const a of spec.annotations || []) {
278
+ const xv = m.kind === 'category' ? m.cats.indexOf(String(a.x)) : xValue(a.x, m.kind);
279
+ if (!Number.isFinite(xv) || xv < x0 || xv > x1) continue;
280
+ const px = Math.round(xS(xv)) + 0.5;
281
+ body.push(el('line', { x1: px, x2: px, y1: inner.y, y2: inner.y + inner.h, stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
282
+ if (a.label) {
283
+ const w = measure(a.label, 11.5, 500);
284
+ const right = px + 5 + w > inner.x + inner.w;
285
+ // A la izquierda de la línea si no cabe a la derecha, sin salirse del área.
286
+ const ax = right ? Math.max(px - 5, area.x + w) : px + 5;
287
+ const bx0 = right ? ax - w : ax, bx1 = right ? ax : ax + w;
288
+ let ay = inner.y + 4;
289
+ for (let row = 0; row < 6; row++) {
290
+ ay = inner.y + 4 + row * 14;
291
+ const box = [bx0 - 2, ay - 10, bx1 + 2, ay + 3];
292
+ if (!placedAnn.some((b) => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])) { placedAnn.push(box); break; }
293
+ }
294
+ body.push(text(ax, ay, a.label, { fill: theme.lede, fontSize: 11.5, fontWeight: 500, textAnchor: right ? 'end' : 'start', stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
295
+ }
296
+ }
297
+
298
+ // Tramo proyectado: banda suave de fondo con su rótulo, desde el último dato firme.
299
+ let cutX = null;
300
+ if (fromT) {
301
+ const firm = xsAll.filter((xv) => !fromT(xv));
302
+ const startX = firm.length ? firm[firm.length - 1] : xsAll.find((xv) => fromT(xv));
303
+ if (startX !== undefined && xsAll.some((xv) => fromT(xv)) && startX <= x1) {
304
+ cutX = Math.max(inner.x, xS(Math.max(startX, x0)));
305
+ 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' }));
306
+ 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 }));
307
+ 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' }));
308
+ }
309
+ }
310
+ const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
311
+ const H = hatcher(theme, gidD);
312
+ // Recortes para pintar áreas: firme a la izquierda de cutX, rayado a la derecha.
313
+ const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
314
+
315
+ // Trazos
316
+ // Orden de dibujo: atenuadas, luego las grises de desborde (9.ª en adelante), luego las de color.
317
+ const rank = (l) => (!focus(l.key) ? 0 : multi && cmap.get(l.key) === theme.other && !spec.colors?.[l.key] ? 1 : 2);
318
+ const ordered = [...m.lines].sort((a, b) => rank(a) - rank(b));
319
+ const showDots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
320
+ const curve = spec.curve === 'smooth' ? monotonePath : spec.curve === 'step' ? stepPath : linearPath;
321
+ const drawOrder = stack ? [...m.lines].reverse() : ordered;
322
+ const lw = M.lineWidth ?? 2.25;
323
+ const maxPts = Math.max(64, Math.round(2 * inner.w));
324
+ const gid = 'pa-grad-' + Math.abs(hash(String(spec.title) + m.y + inner.w)).toString(36);
325
+ let defs = '';
326
+ const washSingle = !multi && !isArea && spec.fill !== false && !m.errorbar && zero;
327
+ for (const l of drawOrder) {
328
+ const c = colorOf(l.key);
329
+ // Más de 2 puntos por píxel: LTTB (mantiene la forma, picos incluidos).
330
+ const segs = splitGaps(l.points, xS, yS).map((sg) => (sg.length > maxPts ? lttb(sg, maxPts) : sg));
331
+ if (!segs.length) continue;
332
+ // Un solo x en un área: una columna centrada (si no, el área no tendría ancho).
333
+ const aw = Math.min(inner.w * 0.2, 40);
334
+ const segsA = singleX ? segs.map((sg) => (sg.length === 1 ? [[sg[0][0] - aw, sg[0][1], sg[0][2]], [sg[0][0] + aw, sg[0][1], sg[0][2]]] : sg)) : segs;
335
+ const parts = [];
336
+ // Banda de confianza (seaborn): antes de la línea, mismo tono.
337
+ if (m.errorbar && !stack) {
338
+ let bpts = l.points.filter((p) => Number.isFinite(p.lo) && Number.isFinite(p.hi));
339
+ if (bpts.length > maxPts) bpts = lttb(bpts.map((p) => [xS(p.x), yS(p.y), p]), maxPts).map((q) => q[2]);
340
+ if (bpts.length > 1) {
341
+ const up = bpts.map((p) => [xS(p.x), yS(p.hi)]);
342
+ const dn = bpts.map((p) => [xS(p.x), yS(p.lo)]).reverse();
343
+ 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' }));
344
+ }
345
+ }
346
+ if (isArea || washSingle) {
347
+ if (washSingle) {
348
+ 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 })]);
349
+ }
350
+ for (const sgm of segsA) {
351
+ const top = curve(sgm.map(([a, b]) => [a, b]));
352
+ const base0 = yS(Math.max(nd.min, 0));
353
+ const bottom = stack ? sgm.map(([a, , p]) => [a, yS(p.y0)]).reverse() : [[sgm[sgm.length - 1][0], base0], [sgm[0][0], base0]];
354
+ const d = top + 'L' + bottom.map(([a, b]) => `${a.toFixed(2)},${b.toFixed(2)}`).join('L') + 'Z';
355
+ 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 };
356
+ const hatchedArea = stack || dashedSeries(l.key);
357
+ if (cutX !== null && hatchedArea) {
358
+ // Área apilada o serie punteada: el tramo proyectado va rayado en su color.
359
+ parts.push(el('path', { ...areaAttrs, 'clip-path': `url(#${clipL})` }));
360
+ parts.push(el('path', { ...areaAttrs, fill: H.fill(c), fillOpacity: stack ? 0.95 : 0.5, 'clip-path': `url(#${clipR})` }));
361
+ } else if (dashedSeries(l.key) && !washSingle) parts.push(el('path', { ...areaAttrs, fill: H.fill(c), fillOpacity: stack ? 0.95 : 0.5 }));
362
+ else parts.push(el('path', areaAttrs));
363
+ }
364
+ }
365
+ const sw = focus(l.key) ? (hl ? lw + 0.75 : lw) : 1.5;
366
+ const lineAttrs = { fill: 'none', stroke: c, strokeWidth: sw, strokeLinejoin: 'round', strokeLinecap: 'round' };
367
+ const dashAttrs = { ...lineAttrs, strokeDasharray: DASH.line };
368
+ if (!stack) for (const sgm of segs) {
369
+ if (dashedSeries(l.key)) { parts.push(el('path', { d: curve(sgm.map(([a, b]) => [a, b])), ...dashAttrs })); continue; }
370
+ // Tramo firme sólido y tramo proyectado discontinuo, unidos en el último dato firme.
371
+ const k = fromT ? sgm.findIndex(([, , p]) => fromT(p.x)) : -1;
372
+ if (k < 0) parts.push(el('path', { d: curve(sgm.map(([a, b]) => [a, b])), ...lineAttrs }));
373
+ else {
374
+ if (k > 0) parts.push(el('path', { d: curve(sgm.slice(0, k).map(([a, b]) => [a, b])), ...lineAttrs }));
375
+ const rest = sgm.slice(Math.max(0, k - 1));
376
+ if (rest.length > 1) parts.push(el('path', { d: curve(rest.map(([a, b]) => [a, b])), ...dashAttrs }));
377
+ }
378
+ }
379
+ const pts = segs.flat();
380
+ // Puntos proyectados huecos: se ven como "todavía no medidos".
381
+ const hollow = (p) => dashedSeries(l.key) || (fromT && fromT(p.x));
382
+ 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 }) });
383
+ if (focus(l.key) && !stack) {
384
+ if (showDots) for (const q of pts) parts.push(dot(q, (M.pointRadius ?? 4) - 0.5));
385
+ // Punto aislado entre vacíos: sin marcador sería un trazo de largo cero (invisible).
386
+ else for (const sg of segs) if (sg.length === 1 && sg[0] !== pts[pts.length - 1]) parts.push(dot(sg[0], (M.pointRadius ?? 4) - 0.5));
387
+ }
388
+ if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
389
+ body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
390
+ }
391
+
392
+ body.push(refLinesY(refs, yS, inner, theme, fmt));
393
+ body.push(yTickLabels(yTicks, yS, inner, theme, tickF, { inside }));
394
+
395
+ // Etiquetas finales con separación vertical y líneas guía.
396
+ if (labeled.length) {
397
+ let labs = labeled.map((l) => {
398
+ const last = lastOf(l);
399
+ const t = endText(l);
400
+ const yy = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
401
+ // Un solo x en área: la guía parte del borde derecho de la columna, no la cruza.
402
+ const px = xS(last.x) + (singleX && isArea ? Math.min(inner.w * 0.2, 40) : 0);
403
+ return { ...t, key: l.key, y: yy, y0: yy, px, color: colorOf(l.key) };
404
+ }).sort((a, b) => a.y0 - b.y0);
405
+ const lbBottom = inner.y + inner.h + 4;
406
+ dodge(labs, lh, inner.y + lh / 2 - 4, lbBottom);
407
+ // Si aun así no caben, se omiten antes que salirse del SVG.
408
+ labs = labs.filter((lb) => lb.y <= lbBottom + 0.5);
409
+ // Guía también si la serie termina antes del borde derecho (si no, la etiqueta queda suelta).
410
+ const early = (lb) => lb.px < inner.x + inner.w - 2;
411
+ const anyMoved = labs.some((lb) => Math.abs(lb.y - lb.y0) > 1.5 || early(lb));
412
+ const lx = inner.x + inner.w + (anyMoved || stack ? 16 : 9);
413
+ for (const lb of labs) {
414
+ const tparts = [];
415
+ if (Math.abs(lb.y - lb.y0) > 1.5 || stack || early(lb)) {
416
+ const sx = lb.px + (stack ? 2 : 7);
417
+ 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 }));
418
+ }
419
+ if (lb.name) {
420
+ // La etiqueta lleva el color de su línea (versión legible), así se conecta sin leyenda.
421
+ const tc = readable(lb.color, theme);
422
+ tparts.push(text(lx, lb.y + 4, lb.name, { fill: tc, fontSize: labelSize, fontWeight: 500 }));
423
+ 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 }));
424
+ } else tparts.push(text(lx, lb.y + 4.5, lb.val, { fill: readable(lb.color, theme), fontSize: labelSize + 0.5, fontWeight: 700 }));
425
+ body.push(g({ class: 'pa-endlabel' }, tparts));
426
+ }
427
+ }
428
+
429
+ // Capa de interacción: crosshair con todos los valores en ese x.
430
+ // Con más x que píxeles, una entrada por columna de píxel (la última).
431
+ let crossXs = xsAll;
432
+ if (xsAll.length > inner.w) {
433
+ const byCol = new Map();
434
+ for (const xv of xsAll) byCol.set(Math.round(xS(xv)), xv);
435
+ crossXs = [...byCol.values()];
436
+ }
437
+ const focusLines = m.lines.filter((l) => focus(l.key)).map((l) => ({ l, at: new Map(l.points.map((q) => [q.x, q])) }));
438
+ const cross = crossXs.map((xv) => ({
439
+ x: +xS(xv).toFixed(1),
440
+ t: xFmt(xv) + (fromT && fromT(xv) ? ` · ${dashedLabel}` : ''),
441
+ v: focusLines.map(({ l, at }) => {
442
+ const p = at.get(xv);
443
+ if (!p || !Number.isFinite(p.y)) return null;
444
+ const extra = Number.isFinite(p.lo) ? ` (${fmt(p.lo)}–${fmt(p.hi)})` : '';
445
+ return [multi ? l.key : m.seriesKeys[0], colorOf(l.key), +yS(p.y).toFixed(1), fmt(stack ? p.v : p.y) + extra];
446
+ }).filter(Boolean),
447
+ }));
448
+ 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 }));
449
+
450
+ const desc = describeLine(m, fmt, xFmt, locale);
451
+ 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 }));
452
+ return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
453
+ }
454
+
455
+ /** Separa etiquetas en 1D: empuja hacia abajo y, si se pasan, hacia arriba. */
456
+ function dodge(labs, h, top, bottom) {
457
+ for (let i = 0; i < labs.length; i++) labs[i].y = Math.max(labs[i].y0, i ? labs[i - 1].y + h : top);
458
+ for (let i = labs.length - 1; i >= 0; i--) {
459
+ const lim = i === labs.length - 1 ? bottom : labs[i + 1].y - h;
460
+ if (labs[i].y > lim) labs[i].y = lim;
461
+ }
462
+ for (let i = 0; i < labs.length; i++) if (labs[i].y < top + i * h) labs[i].y = top + i * h;
463
+ }
464
+
465
+ /**
466
+ * Largest-Triangle-Three-Buckets: reduce [[px, py, p], ...] a `n` puntos
467
+ * conservando la forma visual (primero y último siempre incluidos).
468
+ */
469
+ export function lttb(pts, n) {
470
+ const len = pts.length;
471
+ if (n >= len || n < 3) return pts;
472
+ const out = [pts[0]];
473
+ const every = (len - 2) / (n - 2);
474
+ let a = 0;
475
+ for (let i = 0; i < n - 2; i++) {
476
+ const s0 = Math.floor((i + 1) * every) + 1, s1 = Math.min(len, Math.floor((i + 2) * every) + 1);
477
+ let ax = 0, ay = 0;
478
+ for (let j = s0; j < s1; j++) { ax += pts[j][0]; ay += pts[j][1]; }
479
+ const cnt = Math.max(1, s1 - s0);
480
+ ax /= cnt; ay /= cnt;
481
+ const r0 = Math.floor(i * every) + 1, r1 = Math.floor((i + 1) * every) + 1;
482
+ const [px, py] = pts[a];
483
+ let best = -1, bi = r0;
484
+ for (let j = r0; j < r1; j++) {
485
+ const ar = Math.abs((px - ax) * (pts[j][1] - py) - (px - pts[j][0]) * (ay - py));
486
+ if (ar > best) { best = ar; bi = j; }
487
+ }
488
+ out.push(pts[bi]);
489
+ a = bi;
490
+ }
491
+ out.push(pts[len - 1]);
492
+ return out;
493
+ }
494
+
495
+ function splitGaps(points, xS, yS) {
496
+ const segs = [];
497
+ let cur = [];
498
+ for (const p of points) {
499
+ if (Number.isFinite(p.y)) cur.push([xS(p.x), yS(p.y), p]);
500
+ else if (cur.length) { segs.push(cur); cur = []; }
501
+ }
502
+ if (cur.length) segs.push(cur);
503
+ return segs;
504
+ }
505
+
506
+ // Un decimal basta en trazados (0,05 px): SVG más liviano con muchos puntos.
507
+ const f2 = (n) => n.toFixed(1);
508
+ function linearPath(pts) {
509
+ return pts.map(([x, y], i) => (i ? 'L' : 'M') + f2(x) + ',' + f2(y)).join('');
510
+ }
511
+ function stepPath(pts) {
512
+ return pts.map(([x, y], i) => (i ? `H${f2(x)}V${f2(y)}` : `M${f2(x)},${f2(y)}`)).join('');
513
+ }
514
+
515
+ /** Curva monótona (no inventa máximos ni mínimos entre puntos). */
516
+ function monotonePath(pts) {
517
+ const n = pts.length;
518
+ if (n < 3) return linearPath(pts);
519
+ const dx = [], dy = [], s = [];
520
+ for (let i = 0; i < n - 1; i++) {
521
+ dx.push(pts[i + 1][0] - pts[i][0]);
522
+ dy.push(pts[i + 1][1] - pts[i][1]);
523
+ s.push(dy[i] / (dx[i] || 1));
524
+ }
525
+ const t = [s[0]];
526
+ 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]));
527
+ t.push(s[n - 2]);
528
+ let d = 'M' + f2(pts[0][0]) + ',' + f2(pts[0][1]);
529
+ for (let i = 0; i < n - 1; i++) {
530
+ const h = dx[i] / 3;
531
+ 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])}`;
532
+ }
533
+ return d;
534
+ }
535
+
536
+ export function describeLine(m, fmt, xFmt, locale = 'es') {
537
+ const en = String(locale).startsWith('en');
538
+ const parts = m.lines.slice(0, 6).map((l) => {
539
+ const pts = l.points.filter((p) => Number.isFinite(p.v ?? p.y));
540
+ if (pts.length < 2) return '';
541
+ const a = pts[0], b = pts[pts.length - 1];
542
+ const va = a.v ?? a.y, vb = b.v ?? b.y;
543
+ const ch = va !== 0 ? ((vb - va) / Math.abs(va)) * 100 : null;
544
+ const chs = ch === null || fmt.type === 'percent' ? '' : ` (${ch >= 0 ? '+' : ''}${Math.round(ch)}%)`;
545
+ const max = pts.reduce((p, q) => ((q.v ?? q.y) > (p.v ?? p.y) ? q : p));
546
+ return en
547
+ ? `${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)}.`
548
+ : `${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)}.`;
549
+ });
550
+ return (en ? 'Line chart. ' : 'Gráfico de líneas. ') + parts.filter(Boolean).join(' ');
551
+ }