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.
- package/AGENTS.md +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- package/src/theme.js +101 -14
package/src/charts/heatmap.js
CHANGED
|
@@ -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)}
|
|
74
|
-
: `Mapa de calor de ${humanize(valueF).toLowerCase()} por ${humanize(x).toLowerCase()} y ${humanize(y).toLowerCase()}
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
85
|
-
const vals = it
|
|
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}: ${
|
|
98
|
+
const desc = items.map((it) => `${it.label ?? ''}: ${show(it, [])}`).join('. ');
|
|
96
99
|
return frame.finish(body, { desc, type: 'kpi' });
|
|
97
100
|
}
|
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,18 @@ export function line(spec, theme, variant = 'line') {
|
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
const allY =
|
|
113
|
-
const
|
|
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
|
|
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 /
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
const
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
240
|
+
return xNumFmt(xv);
|
|
185
241
|
};
|
|
186
242
|
let xItems;
|
|
187
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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:
|
|
341
|
-
const p =
|
|
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
|
-
|
|
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
|
}
|