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.
Files changed (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
@@ -70,7 +70,7 @@ export function heatmap(spec, theme) {
70
70
  let best = null;
71
71
  for (const a of xs) for (const b of ys) { const v = val(a, b); if (Number.isFinite(v) && (!best || v > best[2])) best = [a, b, v]; }
72
72
  const desc = en
73
- ? `Heatmap of ${humanize(valueF)} by ${humanize(x)} and ${humanize(y)}. Highest: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`
74
- : `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}. Máximo: ${best?.[1]} · ${best?.[0]} (${fmt(best?.[2])}).`;
73
+ ? `Heatmap of ${humanize(valueF)} by ${humanize(x)} and ${humanize(y)}.` + (best ? ` Highest: ${best[1]} · ${best[0]} (${fmt(best[2])}).` : ' No cell has a numeric value.')
74
+ : `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}.` + (best ? ` Máximo: ${best[1]} · ${best[0]} (${fmt(best[2])}).` : ' Ninguna celda tiene un valor numérico.');
75
75
  return frame.finish(body, { desc, type: 'heatmap' });
76
76
  }
package/src/charts/kpi.js CHANGED
@@ -8,7 +8,8 @@ import { createFrame } from '../frame.js';
8
8
  import { ctx } from '../context.js';
9
9
 
10
10
  export function kpi(spec, theme) {
11
- const items = (spec.items || spec.data || []).map((it) => ({ ...it }));
11
+ // Un solo indicador como objeto también vale: items: { label, value }.
12
+ const items = [].concat(spec.items || spec.data || []).filter((it) => it && typeof it === 'object').map((it) => ({ ...it }));
12
13
  const locale = spec.locale || 'es';
13
14
  const en = String(locale).startsWith('en');
14
15
  const frame = createFrame(spec, theme, { height: 200 });
@@ -28,14 +29,16 @@ export function kpi(spec, theme) {
28
29
 
29
30
  const body = [];
30
31
  // Mismo tamaño de cifra en todas las tarjetas (se ajusta a la más ancha).
31
- const shown = items.map((it) => it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value), ...(it.trend || []).map(num)])(num(it.value)));
32
+ const trendOf = (it) => (Array.isArray(it.trend) ? it.trend.map(num).filter(Number.isFinite) : []);
33
+ const show = (it, extra = trendOf(it)) => it.display ?? (Number.isFinite(num(it.value)) ? makeFormat(it.format ?? spec.format, locale, [num(it.value), ...extra])(num(it.value)) : '—');
34
+ const shown = items.map((it) => show(it));
32
35
  let vSize = 34;
33
36
  while (vSize > 20 && shown.some((s) => measure(s, vSize, 600) > tw - 36)) vSize -= 2;
34
37
  items.forEach((it, i) => {
35
38
  const cx = area.x + (i % cols) * (tw + gap);
36
39
  const cy = area.y + Math.floor(i / cols) * (th + gap);
37
40
  const value = num(it.value);
38
- const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...(it.trend || []).map(num)]);
41
+ const fmt = makeFormat(it.format ?? spec.format, locale, [value, ...trendOf(it)].filter(Number.isFinite));
39
42
  const parts = [el('rect', { x: cx, y: cy, width: tw, height: th, rx: 12, fill: theme.tint })];
40
43
  const P = 18;
41
44
  parts.push(text(cx + P, cy + P + 12 * X, truncate(it.label ?? '', tw - P * 2, 13), { fill: theme.lede, fontSize: 13 }));
@@ -81,8 +84,8 @@ export function kpi(spec, theme) {
81
84
  }
82
85
 
83
86
  // Tendencia: línea recesiva, último punto en el acento.
84
- if (Array.isArray(it.trend) && it.trend.length > 1) {
85
- const vals = it.trend.map(num).filter(Number.isFinite);
87
+ if (trendOf(it).length > 1) {
88
+ const vals = trendOf(it);
86
89
  const mn = Math.min(...vals), mx = Math.max(...vals);
87
90
  const sx = cx + P, sw = tw - P * 2, sy = cy + th - P - 26, sh = 26;
88
91
  const pts = vals.map((v, k) => [sx + (k / (vals.length - 1)) * sw, sy + sh - ((v - mn) / (mx - mn || 1)) * sh]);
@@ -92,6 +95,6 @@ export function kpi(spec, theme) {
92
95
  }
93
96
  body.push(g({ 'data-tip': tip(it.label, valStr, it.period ? `${it.period}` : '') }, parts));
94
97
  });
95
- const desc = items.map((it) => `${it.label}: ${it.display ?? makeFormat(it.format ?? spec.format, locale, [num(it.value)])(num(it.value))}`).join('. ');
98
+ const desc = items.map((it) => `${it.label ?? ''}: ${show(it, [])}`).join('. ');
96
99
  return frame.finish(body, { desc, type: 'kpi' });
97
100
  }
@@ -5,13 +5,13 @@
5
5
  import { el, g, text, tip } from '../svg.js';
6
6
  import { measure, truncate } from '../text.js';
7
7
  import { makeFormat, formatDate, parseDate } from '../format.js';
8
- import { niceDomain, linear, log, logDomain, timeTicks, dateUnit, niceStep } from '../scale.js';
8
+ import { niceDomain, linear, log, logDomain, timeTicks, dateUnit, niceStep, intStep, halfUnit } from '../scale.js';
9
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';
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
12
  import { createFrame } from '../frame.js';
13
13
  import { ctx } from '../context.js';
14
- import { meanCI, mean, sd } from '../stats.js';
14
+ import { meanCI, mean, sd, extent } from '../stats.js';
15
15
  import { hash } from '../frame.js';
16
16
  import { dashedMatcher, fromTest, hatcher, dashedDefaultLabel, DASH } from '../dashed.js';
17
17
 
@@ -91,17 +91,26 @@ export function line(spec, theme, variant = 'line') {
91
91
  const pct = stack && (spec.stack === 'percent' || spec.normalize);
92
92
  const xsAll = uniq(m.lines.flatMap((l) => l.points.map((p) => p.x))).sort((a, b) => a - b);
93
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
+ );
94
100
 
95
- // Apilado (áreas), opcionalmente al 100%.
101
+ // Apilado (áreas), opcionalmente al 100%. Positivos y negativos se apilan por
102
+ // separado desde cero (como `stackStrategy: 'samesign'` de ECharts).
96
103
  if (stack) {
97
104
  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]));
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]));
100
108
  for (const l of m.lines) {
101
109
  const byX = new Map(l.points.map((p) => [p.x, p.y]));
102
110
  l.points = xsAll.map((xv) => {
103
111
  let v = Number.isFinite(byX.get(xv)) ? byX.get(xv) : 0;
104
112
  if (pct) v = tot.get(xv) ? v / tot.get(xv) : 0;
113
+ const base = v < 0 ? neg : pos;
105
114
  const y0 = base.get(xv);
106
115
  base.set(xv, y0 + v);
107
116
  return { x: xv, y: y0 + v, y0, v };
@@ -109,12 +118,18 @@ export function line(spec, theme, variant = 'line') {
109
118
  }
110
119
  }
111
120
 
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);
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
+ }
114
129
  const fmt = pct
115
130
  ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale)
116
131
  : 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);
132
+ let [ymin, ymax] = extent(allY);
118
133
  const refs = spec.refLines || [];
119
134
  for (const r of refs) if (r.y !== undefined) { ymin = Math.min(ymin, r.y); ymax = Math.max(ymax, r.y); }
120
135
  const yLog = spec.yScale === 'log' && ymin > 0;
@@ -149,23 +164,40 @@ export function line(spec, theme, variant = 'line') {
149
164
  const frame = createFrame(spec, theme, { height: 400 });
150
165
  const want = labelMode !== false;
151
166
  // Leyenda solo si alguna serie queda sin etiqueta final.
167
+ const lh = labelSize * ctx.textScale + 3;
152
168
  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;
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;
157
175
  const legend = needLegend ? m.seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: isArea ? 'square' : 'line', dashed: dashedSeries(s) })) : [];
158
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);
159
190
 
160
191
  const endW = labeled.length
161
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
162
193
  : 10;
163
194
 
164
195
  // 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);
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 });
167
199
  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;
200
+ const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step, yTicksArr);
169
201
  const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
170
202
  const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
171
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) };
@@ -174,27 +206,56 @@ export function line(spec, theme, variant = 'line') {
174
206
  // Escala x
175
207
  let x0 = xsAll[0], x1 = xsAll[xsAll.length - 1];
176
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; }
177
213
  if (spec._domains) { spec._domains.y = [nd.min, nd.max]; spec._domains.x = [x0, x1]; }
178
214
  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);
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;
180
236
  const xFmt = (xv) => {
181
- if (m.kind === 'date') return formatDate(xv, dateUnit(xsAll), locale);
237
+ if (m.kind === 'date') return formatDate(xv, du, locale);
182
238
  if (m.kind === 'category') return m.cats[xv];
183
239
  if (m.kind === 'year') return String(xv);
184
- return makeFormat(spec.xFormat, locale, xsAll)(xv);
240
+ return xNumFmt(xv);
185
241
  };
186
242
  let xItems;
187
- if (m.kind === 'date') {
243
+ if (singleX) {
244
+ xItems = [{ pos: xS(xsAll[0]), label: xFmt(xsAll[0]) }];
245
+ } else if (m.kind === 'date') {
188
246
  xItems = timeTicks(x0, x1, Math.max(2, Math.floor(inner.w / 86)), locale).ticks.map((t) => ({ pos: xS(t.value), label: t.label }));
189
247
  } else if (m.kind === 'category') {
190
248
  xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
191
249
  } else if (xLog) {
192
- xItems = xS.ticks().map((t) => ({ pos: xS(t), label: makeFormat(spec.xFormat, locale, xsAll).tick(t)(t) }));
250
+ const lf = makeFormat(spec.xFormat, locale, xsAll);
251
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t)(t) }));
193
252
  } else {
194
253
  let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
195
254
  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);
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);
198
259
  xItems = xS.ticks(st).map((t) => ({ pos: xS(t), label: tf(t) }));
199
260
  }
200
261
 
@@ -205,7 +266,14 @@ export function line(spec, theme, variant = 'line') {
205
266
  body.push(xLabels(xItems, inner, theme, { y: inner.y + inner.h + 18, size: theme.type?.axis }));
206
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' }));
207
268
 
208
- // Anotaciones verticales (eventos)
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
+ }
209
277
  for (const a of spec.annotations || []) {
210
278
  const xv = m.kind === 'category' ? m.cats.indexOf(String(a.x)) : xValue(a.x, m.kind);
211
279
  if (!Number.isFinite(xv) || xv < x0 || xv > x1) continue;
@@ -216,7 +284,14 @@ export function line(spec, theme, variant = 'line') {
216
284
  const right = px + 5 + w > inner.x + inner.w;
217
285
  // A la izquierda de la línea si no cabe a la derecha, sin salirse del área.
218
286
  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' }));
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' }));
220
295
  }
221
296
  }
222
297
 
@@ -238,22 +313,30 @@ export function line(spec, theme, variant = 'line') {
238
313
  const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
239
314
 
240
315
  // Trazos
241
- const ordered = [...m.lines].sort((a, b) => (focus(a.key) ? 1 : 0) - (focus(b.key) ? 1 : 0));
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));
242
319
  const showDots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
243
320
  const curve = spec.curve === 'smooth' ? monotonePath : spec.curve === 'step' ? stepPath : linearPath;
244
321
  const drawOrder = stack ? [...m.lines].reverse() : ordered;
245
322
  const lw = M.lineWidth ?? 2.25;
323
+ const maxPts = Math.max(64, Math.round(2 * inner.w));
246
324
  const gid = 'pa-grad-' + Math.abs(hash(String(spec.title) + m.y + inner.w)).toString(36);
247
325
  let defs = '';
248
326
  const washSingle = !multi && !isArea && spec.fill !== false && !m.errorbar && zero;
249
327
  for (const l of drawOrder) {
250
328
  const c = colorOf(l.key);
251
- const segs = splitGaps(l.points, xS, yS);
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));
252
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;
253
335
  const parts = [];
254
336
  // Banda de confianza (seaborn): antes de la línea, mismo tono.
255
337
  if (m.errorbar && !stack) {
256
- const bpts = l.points.filter((p) => Number.isFinite(p.lo) && Number.isFinite(p.hi));
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]);
257
340
  if (bpts.length > 1) {
258
341
  const up = bpts.map((p) => [xS(p.x), yS(p.hi)]);
259
342
  const dn = bpts.map((p) => [xS(p.x), yS(p.lo)]).reverse();
@@ -264,7 +347,7 @@ export function line(spec, theme, variant = 'line') {
264
347
  if (washSingle) {
265
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 })]);
266
349
  }
267
- for (const sgm of segs) {
350
+ for (const sgm of segsA) {
268
351
  const top = curve(sgm.map(([a, b]) => [a, b]));
269
352
  const base0 = yS(Math.max(nd.min, 0));
270
353
  const bottom = stack ? sgm.map(([a, , p]) => [a, yS(p.y0)]).reverse() : [[sgm[sgm.length - 1][0], base0], [sgm[0][0], base0]];
@@ -297,7 +380,11 @@ export function line(spec, theme, variant = 'line') {
297
380
  // Puntos proyectados huecos: se ven como "todavía no medidos".
298
381
  const hollow = (p) => dashedSeries(l.key) || (fromT && fromT(p.x));
299
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 }) });
300
- if (showDots && focus(l.key) && !stack) for (const q of pts) parts.push(dot(q, (M.pointRadius ?? 4) - 0.5));
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
+ }
301
388
  if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
302
389
  body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
303
390
  }
@@ -307,19 +394,25 @@ export function line(spec, theme, variant = 'line') {
307
394
 
308
395
  // Etiquetas finales con separación vertical y líneas guía.
309
396
  if (labeled.length) {
310
- const lh = labelSize * ctx.textScale + 3;
311
- const labs = labeled.map((l) => {
397
+ let labs = labeled.map((l) => {
312
398
  const last = lastOf(l);
313
399
  const t = endText(l);
314
400
  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) };
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) };
316
404
  }).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);
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));
319
412
  const lx = inner.x + inner.w + (anyMoved || stack ? 16 : 9);
320
413
  for (const lb of labs) {
321
414
  const tparts = [];
322
- if (Math.abs(lb.y - lb.y0) > 1.5 || stack) {
415
+ if (Math.abs(lb.y - lb.y0) > 1.5 || stack || early(lb)) {
323
416
  const sx = lb.px + (stack ? 2 : 7);
324
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 }));
325
418
  }
@@ -334,11 +427,19 @@ export function line(spec, theme, variant = 'line') {
334
427
  }
335
428
 
336
429
  // Capa de interacción: crosshair con todos los valores en ese x.
337
- const cross = xsAll.map((xv) => ({
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) => ({
338
439
  x: +xS(xv).toFixed(1),
339
440
  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);
441
+ v: focusLines.map(({ l, at }) => {
442
+ const p = at.get(xv);
342
443
  if (!p || !Number.isFinite(p.y)) return null;
343
444
  const extra = Number.isFinite(p.lo) ? ` (${fmt(p.lo)}–${fmt(p.hi)})` : '';
344
445
  return [multi ? l.key : m.seriesKeys[0], colorOf(l.key), +yS(p.y).toFixed(1), fmt(stack ? p.v : p.y) + extra];
@@ -361,6 +462,36 @@ function dodge(labs, h, top, bottom) {
361
462
  for (let i = 0; i < labs.length; i++) if (labs[i].y < top + i * h) labs[i].y = top + i * h;
362
463
  }
363
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
+
364
495
  function splitGaps(points, xS, yS) {
365
496
  const segs = [];
366
497
  let cur = [];
@@ -372,7 +503,8 @@ function splitGaps(points, xS, yS) {
372
503
  return segs;
373
504
  }
374
505
 
375
- const f2 = (n) => n.toFixed(2);
506
+ // Un decimal basta en trazados (0,05 px): SVG más liviano con muchos puntos.
507
+ const f2 = (n) => n.toFixed(1);
376
508
  function linearPath(pts) {
377
509
  return pts.map(([x, y], i) => (i ? 'L' : 'M') + f2(x) + ',' + f2(y)).join('');
378
510
  }