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.
- package/AGENTS.md +29 -5
- package/README.md +44 -15
- package/bin/plotagents.js +40 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +6161 -757
- package/docs/api.md +23 -5
- package/docs/charts.md +229 -10
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/es/agents.md +102 -0
- package/docs/es/api.md +172 -0
- package/docs/es/charts.md +436 -0
- package/docs/es/colorscales.md +46 -0
- package/docs/es/dashboards.md +79 -0
- package/docs/es/getting-started.md +142 -0
- package/docs/es/rendering.md +47 -0
- package/docs/es/themes.md +116 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +275 -20
- package/package.json +11 -4
- package/schema.json +1382 -53
- package/skills/plotagents/SKILL.md +105 -0
- package/src/axes.js +11 -3
- package/src/charts/bar.js +40 -21
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +6 -4
- package/src/charts/choropleth.js +287 -37
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +39 -21
- package/src/charts/donut.js +4 -2
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +11 -8
- package/src/charts/line.js +187 -43
- package/src/charts/network.js +557 -189
- package/src/charts/pointmap.js +602 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +16 -4
- package/src/charts/sankey.js +42 -6
- package/src/charts/scatter.js +103 -23
- package/src/charts/sunburst.js +7 -6
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +12 -9
- package/src/charts/wordcloud.js +177 -0
- package/src/colorscales.js +193 -0
- package/src/dashboard.js +451 -0
- package/src/dashed.js +1 -1
- package/src/data.js +4 -2
- package/src/facet.js +4 -3
- package/src/format.js +80 -25
- package/src/frame.js +101 -29
- package/src/geo/projection.js +76 -9
- package/src/geo/registry.js +87 -8
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +92 -14
- package/src/infer.js +18 -1
- package/src/interact.js +157 -36
- package/src/lint.js +353 -11
- package/src/runtime.js +301 -101
- package/src/scale.js +82 -11
- package/src/schema.js +235 -36
- package/src/stats.js +76 -17
- package/src/svg.js +4 -1
- package/src/text.js +5 -0
- package/src/theme.js +101 -14
package/src/charts/line.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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 /
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
const
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
248
|
+
return xNumFmt(xv);
|
|
185
249
|
};
|
|
186
250
|
let xItems;
|
|
187
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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:
|
|
341
|
-
const p =
|
|
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
|
-
|
|
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
|
}
|