plotagents 0.5.0 → 0.8.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 +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -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,26 @@ 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
+ // Escala log: un valor ≤ 0 no tiene posición; es un hueco, no razón para volver a lineal.
122
+ if (spec.yScale === 'log' && !stack && m.lines.some((l) => l.points.some((p) => p.y > 0))) {
123
+ for (const l of m.lines) for (const p of l.points) {
124
+ if (!(p.y > 0)) p.y = NaN;
125
+ if (!(p.lo > 0)) p.lo = NaN;
126
+ if (!(p.hi > 0)) p.hi = NaN;
127
+ }
128
+ }
129
+ const allY = [], rawY = [];
130
+ for (const l of m.lines) for (const p of l.points) {
131
+ if (Number.isFinite(p.y)) allY.push(p.y);
132
+ if (Number.isFinite(p.lo)) allY.push(p.lo);
133
+ if (Number.isFinite(p.hi)) allY.push(p.hi);
134
+ const r = p.v ?? p.y;
135
+ if (Number.isFinite(r)) rawY.push(r);
136
+ }
114
137
  const fmt = pct
115
138
  ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale)
116
139
  : 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);
140
+ let [ymin, ymax] = extent(allY);
118
141
  const refs = spec.refLines || [];
119
142
  for (const r of refs) if (r.y !== undefined) { ymin = Math.min(ymin, r.y); ymax = Math.max(ymax, r.y); }
120
143
  const yLog = spec.yScale === 'log' && ymin > 0;
@@ -149,23 +172,40 @@ export function line(spec, theme, variant = 'line') {
149
172
  const frame = createFrame(spec, theme, { height: 400 });
150
173
  const want = labelMode !== false;
151
174
  // Leyenda solo si alguna serie queda sin etiqueta final.
175
+ const lh = labelSize * ctx.textScale + 3;
152
176
  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;
177
+ const maxLabels = Math.max(1, Math.floor(provisionalH / lh));
178
+ // Series sin ningún dato válido no llevan etiqueta (ni rompen el cálculo).
179
+ const labelable = m.lines.filter((l) => focus(l.key) && lastOf(l));
180
+ let labeled = want ? [...labelable].sort((a, b) => lastOf(b).y - lastOf(a).y).slice(0, maxLabels) : [];
181
+ const allLabeled = labeled.length === labelable.length;
182
+ const needLegend = multi && (!want || !allLabeled || spec.legend === true || (spec.legend && typeof spec.legend !== 'boolean')) && spec.legend !== false;
157
183
  const legend = needLegend ? m.seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: isArea ? 'square' : 'line', dashed: dashedSeries(s) })) : [];
158
184
  const area = frame.layout(legend);
185
+ const inside = stack ? false : spec.axisInside;
186
+ // Alto vertical fijo alrededor del área de trazado (marca superior, eje x, títulos).
187
+ const yChrome = yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0) + 24 * ctx.textScale + (spec.xLabel ? 16 * ctx.textScale : 0);
188
+ // Una leyenda larga en un gráfico bajo no aplasta el trazado: el SVG crece lo justo.
189
+ const MIN_INNER_H = 90 * ctx.textScale;
190
+ if (area.h - yChrome < MIN_INNER_H) {
191
+ const grow = Math.ceil(MIN_INNER_H - (area.h - yChrome));
192
+ frame.setHeight(frame.height + grow);
193
+ area.h += grow;
194
+ }
195
+ // Con el alto real, rotular solo las que caben (la leyenda cubre al resto).
196
+ const capacity = Math.max(1, Math.floor((area.h - yChrome + 8 - lh / 2) / lh) + 1);
197
+ if (labeled.length > capacity) labeled = labeled.slice(0, capacity);
159
198
 
160
199
  const endW = labeled.length
161
200
  ? 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
201
  : 10;
163
202
 
164
203
  // 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);
204
+ // Valores enteros (conteos): nunca marcas en 0,5.
205
+ const intY = !pct && !spec.index && rawY.length > 0 && rawY.every(Number.isInteger);
206
+ 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
207
  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;
208
+ const tickF = yLog ? (v) => fmt.tick(v, v)(v) : fmt.tick(nd.step, yTicksArr);
169
209
  const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
170
210
  const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
171
211
  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 +214,56 @@ export function line(spec, theme, variant = 'line') {
174
214
  // Escala x
175
215
  let x0 = xsAll[0], x1 = xsAll[xsAll.length - 1];
176
216
  if (spec.xDomain) [x0, x1] = spec.xDomain.map((v) => (typeof v === 'number' ? v : m.kind === 'date' ? parseDate(v) : num(v)));
217
+ // Un solo x: centrado, con media unidad a cada lado (media unidad de tiempo en fechas).
218
+ const du = m.kind === 'date' ? dateUnit(xsAll) : null;
219
+ const singleX = x0 === x1;
220
+ if (singleX) { const hw = m.kind === 'date' ? halfUnit(du) : 0.5; x0 -= hw; x1 += hw; }
177
221
  if (spec._domains) { spec._domains.y = [nd.min, nd.max]; spec._domains.x = [x0, x1]; }
178
222
  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);
223
+ // Con el eje y por dentro, si el primer punto choca con una etiqueta de marca,
224
+ // el trazado arranca pasado la etiqueta más ancha.
225
+ let xPad = 0;
226
+ if (isInside(theme, inside) && !singleX && !spec.xDomain) {
227
+ const axisSize = theme.type?.axis ?? 12;
228
+ const k = 5 * Math.max(1, ctx.textScale * 0.8);
229
+ // Caja de cada etiqueta: su ancho real, de la altura de las mayúsculas a la línea base.
230
+ 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 }; });
231
+ const xS0 = (xLog ? log : linear)(x0, x1, inner.x, inner.x + inner.w);
232
+ const dots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
233
+ const r = dots ? (M.pointRadius ?? 4) : (M.lineWidth ?? 2.25) / 2 + 0.5;
234
+ for (const l of m.lines) {
235
+ const p = l.points.find((q) => Number.isFinite(q.y));
236
+ if (!p) continue;
237
+ const px = xS0(p.x), py = yS(p.y);
238
+ 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));
239
+ }
240
+ }
241
+ const xS = (xLog ? log : linear)(x0, x1, inner.x + xPad, inner.x + inner.w);
242
+ // Formateadores creados una vez (no por punto).
243
+ const xNumFmt = m.kind === 'number' ? makeFormat(spec.xFormat, locale, xsAll) : null;
180
244
  const xFmt = (xv) => {
181
- if (m.kind === 'date') return formatDate(xv, dateUnit(xsAll), locale);
245
+ if (m.kind === 'date') return formatDate(xv, du, locale);
182
246
  if (m.kind === 'category') return m.cats[xv];
183
247
  if (m.kind === 'year') return String(xv);
184
- return makeFormat(spec.xFormat, locale, xsAll)(xv);
248
+ return xNumFmt(xv);
185
249
  };
186
250
  let xItems;
187
- if (m.kind === 'date') {
251
+ if (singleX) {
252
+ xItems = [{ pos: xS(xsAll[0]), label: xFmt(xsAll[0]) }];
253
+ } else if (m.kind === 'date') {
188
254
  xItems = timeTicks(x0, x1, Math.max(2, Math.floor(inner.w / 86)), locale).ticks.map((t) => ({ pos: xS(t.value), label: t.label }));
189
255
  } else if (m.kind === 'category') {
190
256
  xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
191
257
  } else if (xLog) {
192
- xItems = xS.ticks().map((t) => ({ pos: xS(t), label: makeFormat(spec.xFormat, locale, xsAll).tick(t)(t) }));
258
+ const lf = makeFormat(spec.xFormat, locale, xsAll);
259
+ xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t, t)(t) }));
193
260
  } else {
194
261
  let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
195
262
  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);
263
+ // Pasos 1/2/5/10 (2,5 → 2, no 3: sin años impares 2001, 2004…).
264
+ if (m.kind === 'year' || ints) st = intStep(st);
265
+ const xMaxAbs = Math.max(Math.abs(xsAll[0]), Math.abs(xsAll[xsAll.length - 1]));
266
+ const tf = m.kind === 'year' ? (v) => String(v) : makeFormat(spec.xFormat ?? (ints && xMaxAbs < 1e4 ? 'integer' : undefined), locale, xsAll).tick(st);
198
267
  xItems = xS.ticks(st).map((t) => ({ pos: xS(t), label: tf(t) }));
199
268
  }
200
269
 
@@ -205,7 +274,14 @@ export function line(spec, theme, variant = 'line') {
205
274
  body.push(xLabels(xItems, inner, theme, { y: inner.y + inner.h + 18, size: theme.type?.axis }));
206
275
  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
276
 
208
- // Anotaciones verticales (eventos)
277
+ // Anotaciones verticales (eventos). Los rótulos que chocarían bajan a la
278
+ // fila siguiente (14 px) y no tapan la etiqueta de la marca superior.
279
+ const placedAnn = [];
280
+ if (isInside(theme, inside) && yTicks.length) {
281
+ const tTop = yTicks.reduce((a, b) => (yS(b) < yS(a) ? b : a));
282
+ const ty = Math.round(yS(tTop)) + 0.5 - 5 * Math.max(1, ctx.textScale * 0.8);
283
+ placedAnn.push([inner.x, ty - (theme.type?.axis ?? 12) * ctx.textScale, inner.x + measure(tickF(tTop), theme.type?.axis ?? 12) + 2, ty + 2]);
284
+ }
209
285
  for (const a of spec.annotations || []) {
210
286
  const xv = m.kind === 'category' ? m.cats.indexOf(String(a.x)) : xValue(a.x, m.kind);
211
287
  if (!Number.isFinite(xv) || xv < x0 || xv > x1) continue;
@@ -216,7 +292,14 @@ export function line(spec, theme, variant = 'line') {
216
292
  const right = px + 5 + w > inner.x + inner.w;
217
293
  // A la izquierda de la línea si no cabe a la derecha, sin salirse del área.
218
294
  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' }));
295
+ const bx0 = right ? ax - w : ax, bx1 = right ? ax : ax + w;
296
+ let ay = inner.y + 4;
297
+ for (let row = 0; row < 6; row++) {
298
+ ay = inner.y + 4 + row * 14;
299
+ const box = [bx0 - 2, ay - 10, bx1 + 2, ay + 3];
300
+ if (!placedAnn.some((b) => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])) { placedAnn.push(box); break; }
301
+ }
302
+ 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
303
  }
221
304
  }
222
305
 
@@ -232,28 +315,38 @@ export function line(spec, theme, variant = 'line') {
232
315
  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
316
  }
234
317
  }
318
+ // Con xDomain/yDomain los datos fuera del rango no se salen del área de trazado.
319
+ const clipD = spec.xDomain || spec.yDomain ? 'pa-clip-' + Math.abs(hash(String(spec.title) + m.y + inner.w + inner.h)).toString(36) : null;
235
320
  const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
236
321
  const H = hatcher(theme, gidD);
237
322
  // Recortes para pintar áreas: firme a la izquierda de cutX, rayado a la derecha.
238
323
  const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
239
324
 
240
325
  // Trazos
241
- const ordered = [...m.lines].sort((a, b) => (focus(a.key) ? 1 : 0) - (focus(b.key) ? 1 : 0));
326
+ // Orden de dibujo: atenuadas, luego las grises de desborde (9.ª en adelante), luego las de color.
327
+ const rank = (l) => (!focus(l.key) ? 0 : multi && cmap.get(l.key) === theme.other && !spec.colors?.[l.key] ? 1 : 2);
328
+ const ordered = [...m.lines].sort((a, b) => rank(a) - rank(b));
242
329
  const showDots = spec.points ?? spec.markers ?? (m.lines.every((l) => l.points.length <= 12) && m.lines.length <= 3 && !isArea);
243
330
  const curve = spec.curve === 'smooth' ? monotonePath : spec.curve === 'step' ? stepPath : linearPath;
244
331
  const drawOrder = stack ? [...m.lines].reverse() : ordered;
245
332
  const lw = M.lineWidth ?? 2.25;
333
+ const maxPts = Math.max(64, Math.round(2 * inner.w));
246
334
  const gid = 'pa-grad-' + Math.abs(hash(String(spec.title) + m.y + inner.w)).toString(36);
247
335
  let defs = '';
248
336
  const washSingle = !multi && !isArea && spec.fill !== false && !m.errorbar && zero;
249
337
  for (const l of drawOrder) {
250
338
  const c = colorOf(l.key);
251
- const segs = splitGaps(l.points, xS, yS);
339
+ // Más de 2 puntos por píxel: LTTB (mantiene la forma, picos incluidos).
340
+ const segs = splitGaps(l.points, xS, yS).map((sg) => (sg.length > maxPts ? lttb(sg, maxPts) : sg));
252
341
  if (!segs.length) continue;
342
+ // Un solo x en un área: una columna centrada (si no, el área no tendría ancho).
343
+ const aw = Math.min(inner.w * 0.2, 40);
344
+ 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
345
  const parts = [];
254
346
  // Banda de confianza (seaborn): antes de la línea, mismo tono.
255
347
  if (m.errorbar && !stack) {
256
- const bpts = l.points.filter((p) => Number.isFinite(p.lo) && Number.isFinite(p.hi));
348
+ let bpts = l.points.filter((p) => Number.isFinite(p.lo) && Number.isFinite(p.hi));
349
+ if (bpts.length > maxPts) bpts = lttb(bpts.map((p) => [xS(p.x), yS(p.y), p]), maxPts).map((q) => q[2]);
257
350
  if (bpts.length > 1) {
258
351
  const up = bpts.map((p) => [xS(p.x), yS(p.hi)]);
259
352
  const dn = bpts.map((p) => [xS(p.x), yS(p.lo)]).reverse();
@@ -264,7 +357,7 @@ export function line(spec, theme, variant = 'line') {
264
357
  if (washSingle) {
265
358
  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
359
  }
267
- for (const sgm of segs) {
360
+ for (const sgm of segsA) {
268
361
  const top = curve(sgm.map(([a, b]) => [a, b]));
269
362
  const base0 = yS(Math.max(nd.min, 0));
270
363
  const bottom = stack ? sgm.map(([a, , p]) => [a, yS(p.y0)]).reverse() : [[sgm[sgm.length - 1][0], base0], [sgm[0][0], base0]];
@@ -297,9 +390,13 @@ export function line(spec, theme, variant = 'line') {
297
390
  // Puntos proyectados huecos: se ven como "todavía no medidos".
298
391
  const hollow = (p) => dashedSeries(l.key) || (fromT && fromT(p.x));
299
392
  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));
393
+ if (focus(l.key) && !stack) {
394
+ if (showDots) for (const q of pts) parts.push(dot(q, (M.pointRadius ?? 4) - 0.5));
395
+ // Punto aislado entre vacíos: sin marcador sería un trazo de largo cero (invisible).
396
+ 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));
397
+ }
301
398
  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));
399
+ body.push(g({ class: 'pa-m', 'data-series': l.key, ...(clipD ? { 'clip-path': `url(#${clipD})` } : {}), ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
303
400
  }
304
401
 
305
402
  body.push(refLinesY(refs, yS, inner, theme, fmt));
@@ -307,19 +404,26 @@ export function line(spec, theme, variant = 'line') {
307
404
 
308
405
  // Etiquetas finales con separación vertical y líneas guía.
309
406
  if (labeled.length) {
310
- const lh = labelSize * ctx.textScale + 3;
311
- const labs = labeled.map((l) => {
407
+ let labs = labeled.map((l) => {
312
408
  const last = lastOf(l);
313
409
  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) };
410
+ const yy0 = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
411
+ const yy = Math.min(inner.y + inner.h, Math.max(inner.y, yy0)); // fuera del dominio y: pegada al borde
412
+ // Un solo x en área: la guía parte del borde derecho de la columna, no la cruza.
413
+ const px = xS(last.x) + (singleX && isArea ? Math.min(inner.w * 0.2, 40) : 0);
414
+ return { ...t, key: l.key, y: yy, y0: yy, px, color: colorOf(l.key) };
316
415
  }).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);
416
+ const lbBottom = inner.y + inner.h + 4;
417
+ dodge(labs, lh, inner.y + lh / 2 - 4, lbBottom);
418
+ // Si aun así no caben, se omiten antes que salirse del SVG.
419
+ labs = labs.filter((lb) => lb.y <= lbBottom + 0.5);
420
+ // Guía también si la serie termina antes del borde derecho (si no, la etiqueta queda suelta).
421
+ const early = (lb) => lb.px < inner.x + inner.w - 2;
422
+ const anyMoved = labs.some((lb) => Math.abs(lb.y - lb.y0) > 1.5 || early(lb));
319
423
  const lx = inner.x + inner.w + (anyMoved || stack ? 16 : 9);
320
424
  for (const lb of labs) {
321
425
  const tparts = [];
322
- if (Math.abs(lb.y - lb.y0) > 1.5 || stack) {
426
+ if (Math.abs(lb.y - lb.y0) > 1.5 || stack || early(lb)) {
323
427
  const sx = lb.px + (stack ? 2 : 7);
324
428
  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
429
  }
@@ -334,11 +438,19 @@ export function line(spec, theme, variant = 'line') {
334
438
  }
335
439
 
336
440
  // Capa de interacción: crosshair con todos los valores en ese x.
337
- const cross = xsAll.map((xv) => ({
441
+ // Con más x que píxeles, una entrada por columna de píxel (la última).
442
+ let crossXs = xsAll.filter((xv) => xv >= x0 && xv <= x1);
443
+ if (crossXs.length > inner.w) {
444
+ const byCol = new Map();
445
+ for (const xv of crossXs) byCol.set(Math.round(xS(xv)), xv);
446
+ crossXs = [...byCol.values()];
447
+ }
448
+ const focusLines = m.lines.filter((l) => focus(l.key)).map((l) => ({ l, at: new Map(l.points.map((q) => [q.x, q])) }));
449
+ const cross = crossXs.map((xv) => ({
338
450
  x: +xS(xv).toFixed(1),
339
451
  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);
452
+ v: focusLines.map(({ l, at }) => {
453
+ const p = at.get(xv);
342
454
  if (!p || !Number.isFinite(p.y)) return null;
343
455
  const extra = Number.isFinite(p.lo) ? ` (${fmt(p.lo)}–${fmt(p.hi)})` : '';
344
456
  return [multi ? l.key : m.seriesKeys[0], colorOf(l.key), +yS(p.y).toFixed(1), fmt(stack ? p.v : p.y) + extra];
@@ -347,6 +459,7 @@ export function line(spec, theme, variant = 'line') {
347
459
  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
460
 
349
461
  const desc = describeLine(m, fmt, xFmt, locale);
462
+ if (clipD) defs += el('clipPath', { id: clipD }, el('rect', { x: inner.x - 6, y: inner.y - 6, width: inner.w + 12, height: inner.h + 12 }));
350
463
  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
464
  return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
352
465
  }
@@ -361,6 +474,36 @@ function dodge(labs, h, top, bottom) {
361
474
  for (let i = 0; i < labs.length; i++) if (labs[i].y < top + i * h) labs[i].y = top + i * h;
362
475
  }
363
476
 
477
+ /**
478
+ * Largest-Triangle-Three-Buckets: reduce [[px, py, p], ...] a `n` puntos
479
+ * conservando la forma visual (primero y último siempre incluidos).
480
+ */
481
+ export function lttb(pts, n) {
482
+ const len = pts.length;
483
+ if (n >= len || n < 3) return pts;
484
+ const out = [pts[0]];
485
+ const every = (len - 2) / (n - 2);
486
+ let a = 0;
487
+ for (let i = 0; i < n - 2; i++) {
488
+ const s0 = Math.floor((i + 1) * every) + 1, s1 = Math.min(len, Math.floor((i + 2) * every) + 1);
489
+ let ax = 0, ay = 0;
490
+ for (let j = s0; j < s1; j++) { ax += pts[j][0]; ay += pts[j][1]; }
491
+ const cnt = Math.max(1, s1 - s0);
492
+ ax /= cnt; ay /= cnt;
493
+ const r0 = Math.floor(i * every) + 1, r1 = Math.floor((i + 1) * every) + 1;
494
+ const [px, py] = pts[a];
495
+ let best = -1, bi = r0;
496
+ for (let j = r0; j < r1; j++) {
497
+ const ar = Math.abs((px - ax) * (pts[j][1] - py) - (px - pts[j][0]) * (ay - py));
498
+ if (ar > best) { best = ar; bi = j; }
499
+ }
500
+ out.push(pts[bi]);
501
+ a = bi;
502
+ }
503
+ out.push(pts[len - 1]);
504
+ return out;
505
+ }
506
+
364
507
  function splitGaps(points, xS, yS) {
365
508
  const segs = [];
366
509
  let cur = [];
@@ -372,7 +515,8 @@ function splitGaps(points, xS, yS) {
372
515
  return segs;
373
516
  }
374
517
 
375
- const f2 = (n) => n.toFixed(2);
518
+ // Un decimal basta en trazados (0,05 px): SVG más liviano con muchos puntos.
519
+ const f2 = (n) => n.toFixed(1);
376
520
  function linearPath(pts) {
377
521
  return pts.map(([x, y], i) => (i ? 'L' : 'M') + f2(x) + ',' + f2(y)).join('');
378
522
  }