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/regplot.js
CHANGED
|
@@ -4,7 +4,7 @@ import { el, g, text, tip, esc, barPath, dotLayer } from '../svg.js';
|
|
|
4
4
|
import { measure } from '../text.js';
|
|
5
5
|
import { makeFormat, formatNumber } from '../format.js';
|
|
6
6
|
import { colorMap, readable } from '../theme.js';
|
|
7
|
-
import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize } from '../data.js';
|
|
7
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, uniq, humanize, PlotError } from '../data.js';
|
|
8
8
|
import { yAxisMargin, yAxisTop, yGrid, yTickLabels, gridX, quietCorner } from '../axes.js';
|
|
9
9
|
import { createFrame } from '../frame.js';
|
|
10
10
|
import { polyfit, lowess, histogram, kde as kdeEst } from '../stats.js';
|
|
@@ -31,6 +31,12 @@ function readPoints(spec, data, { needY = true } = {}) {
|
|
|
31
31
|
const pts = data
|
|
32
32
|
.map((r) => ({ x: num(r[x]), y: num(r[y]), k: hue ? String(r[hue]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
|
|
33
33
|
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!xLog || p.x > 0) && (!yLog || p.y > 0));
|
|
34
|
+
if (!pts.length) {
|
|
35
|
+
const log = xLog || yLog;
|
|
36
|
+
throw EN(spec)
|
|
37
|
+
? new PlotError(`No row has numeric values in both \`${x}\` and \`${y}\`${log ? ' (> 0 for a log scale)' : ''}.`, `Check that \`x\` and \`y\` are measures with numbers${log ? ', or drop the log scale' : ''}; to compare a measure across categories use \`type: "box"\`.`)
|
|
38
|
+
: new PlotError(`Ninguna fila tiene valores numéricos en \`${x}\` y \`${y}\`${log ? ' (> 0 para escala log)' : ''}.`, `Revisa que \`x\` e \`y\` sean medidas con números${log ? ', o quita la escala log' : ''}; para comparar una medida entre categorías usa \`type: "box"\`.`);
|
|
39
|
+
}
|
|
34
40
|
const keys = hue ? uniq(pts.map((p) => p.k)) : [''];
|
|
35
41
|
return { x, y, hue, labelF, pts, keys, xLog, yLog };
|
|
36
42
|
}
|
|
@@ -50,7 +56,7 @@ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
|
|
|
50
56
|
const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
|
|
51
57
|
return { line: pts, band: null, r2: null, n: gp.length };
|
|
52
58
|
}
|
|
53
|
-
const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci
|
|
59
|
+
const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95 });
|
|
54
60
|
const line = grid.map((v) => [v, fit.predict(fx(v))]);
|
|
55
61
|
const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
|
|
56
62
|
return { line, band, r2: fit.r2, n: fit.n, fit };
|
|
@@ -117,7 +123,7 @@ export function regplot(spec, theme) {
|
|
|
117
123
|
});
|
|
118
124
|
if (spec.scatter !== false) {
|
|
119
125
|
const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
|
|
120
|
-
const dots = order.map((p) => ({
|
|
126
|
+
const dots = order.filter((p) => visible(p, xS, yS, inner)).map((p) => ({
|
|
121
127
|
class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: K === 1 ? (M.dotOpacity ?? 0.85) * 0.75 : M.dotOpacity ?? 0.85,
|
|
122
128
|
stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
|
|
123
129
|
'data-tip': tip(p.name || p.k || '', `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : ''),
|
|
@@ -345,7 +351,7 @@ export function jointplot(spec, theme) {
|
|
|
345
351
|
body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
|
|
346
352
|
});
|
|
347
353
|
}
|
|
348
|
-
body.push(g({}, dotLayer(pts.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
|
|
354
|
+
body.push(g({}, dotLayer(pts.filter((p) => visible(p, xS, yS, inner)).map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
|
|
349
355
|
body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
|
|
350
356
|
body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
|
|
351
357
|
body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
|
|
@@ -357,6 +363,12 @@ export function jointplot(spec, theme) {
|
|
|
357
363
|
return frame.finish(body, { desc, type: 'jointplot' });
|
|
358
364
|
}
|
|
359
365
|
|
|
366
|
+
/** Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área. */
|
|
367
|
+
function visible(p, xS, yS, inner) {
|
|
368
|
+
const cx = xS(p.x), cy = yS(p.y);
|
|
369
|
+
return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1;
|
|
370
|
+
}
|
|
371
|
+
|
|
360
372
|
function hashStr(s) {
|
|
361
373
|
let h = 0;
|
|
362
374
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
package/src/charts/sankey.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { el, g, text, tip } from '../svg.js';
|
|
4
4
|
import { measure, truncate } from '../text.js';
|
|
5
5
|
import { makeFormat } from '../format.js';
|
|
6
|
-
import { ensureData, requireField, num, fieldsOf, fieldKind } from '../data.js';
|
|
6
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
|
|
7
7
|
import { createFrame } from '../frame.js';
|
|
8
8
|
|
|
9
9
|
export function sankey(spec, theme) {
|
|
@@ -16,24 +16,39 @@ export function sankey(spec, theme) {
|
|
|
16
16
|
requireField(data, to, 'to');
|
|
17
17
|
requireField(data, valueF, 'value');
|
|
18
18
|
const locale = spec.locale || 'es';
|
|
19
|
+
const en = String(locale).startsWith('en');
|
|
19
20
|
|
|
20
|
-
// Enlaces agregados
|
|
21
|
+
// Enlaces agregados (valores no finitos o ≤ 0 se ignoran).
|
|
21
22
|
const lmap = new Map();
|
|
22
23
|
for (const r of data) {
|
|
23
24
|
const v = num(r[valueF]);
|
|
24
|
-
if (!(v > 0)) continue;
|
|
25
|
+
if (!(v > 0) || !Number.isFinite(v)) continue;
|
|
25
26
|
const s = String(r[from]), t = String(r[to]);
|
|
26
27
|
if (s === t) continue;
|
|
27
28
|
const k = s + '\u0000' + t;
|
|
28
29
|
lmap.set(k, (lmap.get(k) || 0) + v);
|
|
29
30
|
}
|
|
30
31
|
const links = [...lmap.entries()].map(([k, v]) => { const [s, t] = k.split('\u0000'); return { s, t, v }; });
|
|
32
|
+
if (!links.length) {
|
|
33
|
+
throw en
|
|
34
|
+
? new PlotError('The sankey has no positive flows between different nodes.', `Check \`${valueF}\`: it needs values > 0, and \`${from}\` must differ from \`${to}\` (self-loops are dropped).`)
|
|
35
|
+
: new PlotError('El sankey no tiene flujos positivos entre nodos distintos.', `Revisa \`${valueF}\`: necesita valores > 0, y \`${from}\` debe ser distinto de \`${to}\` (los bucles se omiten).`);
|
|
36
|
+
}
|
|
31
37
|
const names = [...new Set(links.flatMap((l) => [l.s, l.t]))];
|
|
32
38
|
const nodes = new Map(names.map((n) => [n, { name: n, in: [], out: [], depth: 0 }]));
|
|
33
39
|
for (const l of links) { l.sn = nodes.get(l.s); l.tn = nodes.get(l.t); l.sn.out.push(l); l.tn.in.push(l); }
|
|
34
40
|
for (const n of nodes.values()) n.value = Math.max(sum(n.in), sum(n.out));
|
|
35
41
|
|
|
36
|
-
//
|
|
42
|
+
// Un sankey fluye en una dirección: un ciclo (a → b → a) no tiene etapas.
|
|
43
|
+
const cycle = findCycle(nodes);
|
|
44
|
+
if (cycle) {
|
|
45
|
+
const path = cycle.join(' → ');
|
|
46
|
+
throw en
|
|
47
|
+
? new PlotError(`The sankey has a cycle (${path}): flows must go one way, stage by stage.`, 'Name each stage apart ("Chile (origin)" → "Chile (destination)"), drop the return flows, or use `type: "chord"` for two-way exchanges.')
|
|
48
|
+
: new PlotError(`El sankey tiene un ciclo (${path}): los flujos deben ir en una sola dirección, etapa por etapa.`, 'Distingue las etapas ("Chile (origen)" → "Chile (destino)"), quita los flujos de vuelta o usa `type: "chord"` para intercambios en ambos sentidos.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Profundidad = camino más largo desde un origen.
|
|
37
52
|
for (let it = 0; it < names.length; it++) {
|
|
38
53
|
let changed = false;
|
|
39
54
|
for (const l of links) if (l.tn.depth < l.sn.depth + 1) { l.tn.depth = l.sn.depth + 1; changed = true; }
|
|
@@ -105,7 +120,7 @@ export function sankey(spec, theme) {
|
|
|
105
120
|
const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
|
|
106
121
|
const d = `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}L${x1},${l.y1 + w}C${xm},${l.y1 + w} ${xm},${l.y0 + w} ${x0},${l.y0 + w}Z`;
|
|
107
122
|
const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
|
|
108
|
-
linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% de ${l.s}` }));
|
|
123
|
+
linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% ${en ? 'of' : 'de'} ${l.s}` }));
|
|
109
124
|
}
|
|
110
125
|
body.push(g({ class: 'pa-links' }, linkG));
|
|
111
126
|
for (const [ci, c] of cols.entries()) for (const n of c) {
|
|
@@ -124,10 +139,31 @@ export function sankey(spec, theme) {
|
|
|
124
139
|
}
|
|
125
140
|
body.push(g({ 'data-tip': `${n.name}\n${fmt(n.value)}` }, parts));
|
|
126
141
|
}
|
|
127
|
-
const en = String(locale).startsWith('en');
|
|
128
142
|
const top = [...links].sort((a, b) => b.v - a.v).slice(0, 3).map((l) => `${l.s} → ${l.t} (${fmt(l.v)})`).join('; ');
|
|
129
143
|
const desc = en ? `Sankey diagram with ${nodes.size} nodes and ${links.length} flows. Largest flows: ${top}.` : `Diagrama de Sankey con ${nodes.size} nodos y ${links.length} flujos. Mayores flujos: ${top}.`;
|
|
130
144
|
return frame.finish(body, { desc, type: 'sankey' });
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
const sum = (ls) => ls.reduce((a, l) => a + l.v, 0);
|
|
148
|
+
|
|
149
|
+
/** Primer ciclo encontrado (lista de nombres, cerrada) o null. DFS iterativo. */
|
|
150
|
+
function findCycle(nodes) {
|
|
151
|
+
const state = new Map(); // 1 = en la pila, 2 = terminado
|
|
152
|
+
for (const start of nodes.values()) {
|
|
153
|
+
if (state.has(start)) continue;
|
|
154
|
+
const stack = [[start, 0]];
|
|
155
|
+
const trail = [start];
|
|
156
|
+
state.set(start, 1);
|
|
157
|
+
while (stack.length) {
|
|
158
|
+
const top = stack[stack.length - 1];
|
|
159
|
+
const [n, i] = top;
|
|
160
|
+
if (i < n.out.length) {
|
|
161
|
+
top[1]++;
|
|
162
|
+
const m = n.out[i].tn;
|
|
163
|
+
if (state.get(m) === 1) return [...trail.slice(trail.indexOf(m)), m].map((x) => x.name);
|
|
164
|
+
if (!state.has(m)) { state.set(m, 1); stack.push([m, 0]); trail.push(m); }
|
|
165
|
+
} else { state.set(n, 2); stack.pop(); trail.pop(); }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
package/src/charts/scatter.js
CHANGED
|
@@ -5,28 +5,47 @@ import { measure } from '../text.js';
|
|
|
5
5
|
import { makeFormat } from '../format.js';
|
|
6
6
|
import { niceDomain, niceStep, linear, log, logDomain } from '../scale.js';
|
|
7
7
|
import { colorMap } from '../theme.js';
|
|
8
|
-
import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize } from '../data.js';
|
|
9
|
-
import { gridY, gridX, ticksWidth, refLinesY, refLinesX, yAxisMargin, yAxisTop, yGrid, yTickLabels, quietCorner } from '../axes.js';
|
|
10
|
-
import { createFrame, SIZES } from '../frame.js';
|
|
8
|
+
import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
|
|
9
|
+
import { gridY, gridX, ticksWidth, refLinesY, refLinesX, yAxisMargin, yAxisTop, yGrid, yTickLabels, quietCorner, isInside } from '../axes.js';
|
|
10
|
+
import { createFrame, SIZES, hash } from '../frame.js';
|
|
11
|
+
import { extent } from '../stats.js';
|
|
12
|
+
import { ctx } from '../context.js';
|
|
13
|
+
|
|
14
|
+
/** Muestra pareja de ~n filas (inferir tipos de campo sin recorrer 50 mil filas). */
|
|
15
|
+
const sampleRows = (data, n = 200) => (data.length <= n ? data : Array.from({ length: n }, (_, i) => data[Math.floor((i * (data.length - 1)) / (n - 1))]));
|
|
11
16
|
|
|
12
17
|
export function scatter(spec, theme) {
|
|
13
18
|
const data = ensureData(spec);
|
|
14
19
|
const fields = fieldsOf(data);
|
|
15
|
-
const
|
|
20
|
+
const sample = sampleRows(data);
|
|
21
|
+
const kinds = new Map();
|
|
22
|
+
const kindOf = (f) => { if (!kinds.has(f)) kinds.set(f, fieldKind(sample, f)); return kinds.get(f); };
|
|
23
|
+
const numeric = spec.x && spec.y ? [] : fields.filter((f) => kindOf(f) === 'number' || kindOf(f) === 'year');
|
|
16
24
|
const x = spec.x || numeric[0];
|
|
17
25
|
const y = spec.y || numeric.find((f) => f !== x);
|
|
18
26
|
const size = spec.size;
|
|
19
27
|
const series = spec.series ?? spec.hue ?? spec.color;
|
|
20
|
-
const labelF = spec.label ?? fields.find((f) =>
|
|
28
|
+
const labelF = spec.label ?? fields.find((f) => kindOf(f) === 'category' && f !== series);
|
|
21
29
|
requireField(data, x, 'x');
|
|
22
30
|
requireField(data, y, 'y');
|
|
23
31
|
if (size) requireField(data, size, 'size');
|
|
24
32
|
if (series) requireField(data, series, 'series');
|
|
25
33
|
const locale = spec.locale || 'es';
|
|
34
|
+
const en = String(locale).startsWith('en');
|
|
35
|
+
const xLog = spec.xScale === 'log', yLog = spec.yScale === 'log';
|
|
26
36
|
|
|
37
|
+
// En escala log, los valores ≤ 0 no tienen posición: se omiten.
|
|
27
38
|
const pts = data
|
|
28
39
|
.map((r) => ({ r, x: num(r[x]), y: num(r[y]), s: size ? num(r[size]) : 1, k: series ? String(r[series]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
|
|
29
|
-
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
40
|
+
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!xLog || p.x > 0) && (!yLog || p.y > 0));
|
|
41
|
+
if (!pts.length) {
|
|
42
|
+
const which = !data.some((r) => Number.isFinite(num(r[x]))) ? x : y;
|
|
43
|
+
const logNote = xLog || yLog ? (en ? ' (on a log scale only values > 0 count)' : ' (en escala log solo cuentan valores > 0)') : '';
|
|
44
|
+
throw new PlotError(
|
|
45
|
+
en ? `No points to plot: \`${which}\` has no numeric values${logNote}.` : `No hay puntos para graficar: \`${which}\` no tiene valores numéricos${logNote}.`,
|
|
46
|
+
en ? `Use numeric fields for x and y, e.g. x: "gdp", y: "life_expectancy". Fields: ${fields.join(', ')}.` : `Usa campos numéricos en x e y, p. ej. x: "pib", y: "esperanza_vida". Campos: ${fields.join(', ')}.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
30
49
|
const keys = series ? uniq(pts.map((p) => p.k)) : [''];
|
|
31
50
|
const cmap = colorMap(keys, theme, spec.colors);
|
|
32
51
|
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
@@ -41,27 +60,43 @@ export function scatter(spec, theme) {
|
|
|
41
60
|
const frame = createFrame(spec, theme, { height: 460 });
|
|
42
61
|
const area = frame.layout(legend);
|
|
43
62
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const ey = [Math.min(...pts.map((p) => p.y)), Math.max(...pts.map((p) => p.y))];
|
|
63
|
+
const ex = extent(pts.map((p) => p.x));
|
|
64
|
+
const ey = extent(pts.map((p) => p.y));
|
|
47
65
|
if (spec.xDomain) ex.splice(0, 2, ...spec.xDomain);
|
|
48
66
|
if (spec.yDomain) ey.splice(0, 2, ...spec.yDomain);
|
|
49
67
|
const dx = xLog ? logDomain(...ex) : spec.xDomain ? { min: ex[0], max: ex[1], step: niceStep(ex[1] - ex[0], Math.max(3, Math.round(area.w / 110))) } : niceDomain(ex[0], ex[1], Math.max(3, Math.round(area.w / 110)));
|
|
50
68
|
const dy = yLog ? logDomain(...ey) : spec.yDomain ? { min: ey[0], max: ey[1], step: niceStep(ey[1] - ey[0], Math.max(3, Math.round(area.h / 70))) } : niceDomain(ey[0], ey[1], Math.max(3, Math.round(area.h / 70)));
|
|
51
69
|
if (spec._domains) { spec._domains.x = [dx.min, dx.max]; spec._domains.y = [dy.min, dy.max]; }
|
|
52
|
-
const ytf = yLog ? fy : fy.tick(dy.step);
|
|
53
|
-
const xtf = xLog ? fx : fx.tick(dx.step);
|
|
54
70
|
const yTicks = (yLog ? log(dy.min, dy.max, 0, 1) : linear(dy.min, dy.max, 0, 1)).ticks(dy.step);
|
|
71
|
+
const ytf = yLog ? fy : fy.tick(dy.step, yTicks);
|
|
72
|
+
const xtf = xLog ? fx : fx.tick(dx.step, linear(dx.min, dx.max, 0, 1).ticks(dx.step));
|
|
55
73
|
const leftM = yAxisMargin(yTicks, ytf, theme);
|
|
56
74
|
const top = area.y + 18 + yAxisTop(theme);
|
|
57
75
|
const inner = { x: area.x + leftM, y: top, w: area.w - leftM - 8, h: area.y + area.h - top - 44 };
|
|
58
|
-
const maxS =
|
|
76
|
+
const maxS = extent(pts.map((p) => p.s))[1];
|
|
59
77
|
const rMax = spec.maxRadius ?? Math.min(30, Math.max(12, Math.sqrt((inner.w * inner.h) / Math.max(1, pts.length)) / 3));
|
|
60
78
|
// Las burbujas no se salen del área: margen interno del radio máximo.
|
|
61
79
|
const bp = size ? rMax * 0.75 : 6;
|
|
62
|
-
const xS = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp, inner.x + inner.w - bp);
|
|
63
80
|
const yS = (yLog ? log : linear)(dy.min, dy.max, inner.y + inner.h - bp, inner.y + bp);
|
|
64
81
|
const rOf = (p) => (size ? Math.max(2.5, Math.sqrt(Math.max(0, p.s) / (maxS || 1)) * rMax) : spec.radius ?? (pts.length > 300 ? 3 : 5));
|
|
82
|
+
// Eje y por dentro: si un punto pegado a la izquierda choca con una etiqueta
|
|
83
|
+
// de marca, el rango x arranca pasado la etiqueta más ancha.
|
|
84
|
+
let xPad = 0;
|
|
85
|
+
if (isInside(theme, undefined) && !spec.xDomain) {
|
|
86
|
+
const axisSize = SIZES.axis;
|
|
87
|
+
const kk = 5 * Math.max(1, ctx.textScale * 0.8);
|
|
88
|
+
const yT = yS.ticks(dy.step);
|
|
89
|
+
const boxes = yT.map((t) => { const base = Math.round(yS(t)) + 0.5 - kk; return { w: ticksWidth([t], ytf, theme.type?.axis ?? axisSize), y0: base - (theme.type?.axis ?? axisSize) * 0.75, y1: base + 1 }; });
|
|
90
|
+
const xS0 = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp, inner.x + inner.w - bp);
|
|
91
|
+
const wMax = Math.max(0, ...boxes.map((b) => b.w));
|
|
92
|
+
for (const p of pts) {
|
|
93
|
+
const r = rOf(p), px = xS0(p.x);
|
|
94
|
+
if (px - r > inner.x + wMax + 1) continue;
|
|
95
|
+
const py = yS(p.y);
|
|
96
|
+
if (boxes.some((b) => px - r < inner.x + b.w + 1 && py + r > b.y0 && py - r < b.y1)) { xPad = Math.min(Math.max(0, wMax + 6 - bp + r), inner.w * 0.2); if (xPad >= inner.w * 0.2) break; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const xS = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp + xPad, inner.x + inner.w - bp);
|
|
65
100
|
|
|
66
101
|
const body = [];
|
|
67
102
|
body.push(yGrid(yS.ticks(dy.step), yS, inner, theme, { baseline: NaN }));
|
|
@@ -71,6 +106,7 @@ export function scatter(spec, theme) {
|
|
|
71
106
|
body.push(text(area.x, area.y + 10, spec.yLabel ?? humanize(y), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500 }));
|
|
72
107
|
body.push(text(inner.x + inner.w, inner.y + inner.h + 40, spec.xLabel ?? humanize(x), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500, textAnchor: 'end' }));
|
|
73
108
|
|
|
109
|
+
const taken = []; // zonas ocupadas por rótulos (R²), para la clave de tamaños
|
|
74
110
|
// Tendencia lineal (mínimos cuadrados)
|
|
75
111
|
if (spec.trend) {
|
|
76
112
|
const tx = pts.map((p) => (xLog ? Math.log10(p.x) : p.x)), ty = pts.map((p) => (yLog ? Math.log10(p.y) : p.y));
|
|
@@ -82,12 +118,16 @@ export function scatter(spec, theme) {
|
|
|
82
118
|
const lx0 = xLog ? Math.log10(dx.min) : dx.min, lx1 = xLog ? Math.log10(dx.max) : dx.max;
|
|
83
119
|
const P = (lx) => [xS(xLog ? 10 ** lx : lx), yS(yLog ? 10 ** (a + b * lx) : a + b * lx)];
|
|
84
120
|
const [ax, ay] = P(lx0), [bx, by] = P(lx1);
|
|
85
|
-
|
|
86
|
-
|
|
121
|
+
// id único por gráfico: varios SVG en una página no comparten el recorte.
|
|
122
|
+
const clipId = 'pa-clip-trend-' + Math.abs(hash(String(spec.title) + x + y + inner.w + pts.length)).toString(36);
|
|
123
|
+
body.push(el('clipPath', { id: clipId }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
|
|
124
|
+
body.push(el('line', { x1: ax, y1: ay, x2: bx, y2: by, stroke: theme.ink, strokeWidth: 1.5, opacity: 0.55, clipPath: `url(#${clipId})` }));
|
|
87
125
|
const r2s = `R² = ${r2v.toFixed(2).replace('.', String(locale).startsWith('en') ? '.' : ',')}`;
|
|
88
126
|
const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
|
|
89
127
|
for (let i = 0; i <= 24; i++) occ.push([ax + ((bx - ax) * i) / 24, ay + ((by - ay) * i) / 24]);
|
|
90
128
|
const qc = quietCorner(occ, inner, measure(r2s, 11.5) + 4, 16);
|
|
129
|
+
const r2w = measure(r2s, 11.5) + 4, r2x0 = qc.anchor === 'end' ? qc.x - r2w : qc.x;
|
|
130
|
+
for (let i = 0; i <= 8; i++) for (const dy of [-12, -6, 0]) taken.push([r2x0 + (r2w * i) / 8, qc.y + dy]);
|
|
91
131
|
body.push(text(qc.x, qc.y, r2s, { fill: theme.lede, fontSize: 11.5, textAnchor: qc.anchor, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke' }));
|
|
92
132
|
}
|
|
93
133
|
body.push(refLinesY(spec.refLines || [], yS, inner, theme, fy));
|
|
@@ -95,13 +135,54 @@ export function scatter(spec, theme) {
|
|
|
95
135
|
|
|
96
136
|
// Burbujas grandes debajo, chicas arriba; anillo del color de fondo.
|
|
97
137
|
const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
// Tooltips: la capa densa guarda a lo más MAX_TIPS; no se arman los que se descartan.
|
|
139
|
+
const MAX_TIPS = 20000;
|
|
140
|
+
const hx = humanize(x), hy = humanize(y), hs = size ? humanize(size) : '';
|
|
141
|
+
// Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área.
|
|
142
|
+
const vis = (p) => { const cx = xS(p.x), cy = yS(p.y); return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1; };
|
|
143
|
+
const circles = sorted.filter(vis).map((p, i) => {
|
|
144
|
+
let t = '';
|
|
145
|
+
if (i < MAX_TIPS || sorted.length <= 1500) {
|
|
146
|
+
const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
|
|
147
|
+
if (size) lines.push(`${hs}: ${fs(p.s)}`);
|
|
148
|
+
if (series) lines.splice(1, 0, p.k);
|
|
149
|
+
t = tip(...lines);
|
|
150
|
+
}
|
|
151
|
+
return { class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r: rOf(p), fill: colorOf(p), fillOpacity: size ? 0.78 : 0.9, stroke: theme.surface, strokeWidth: size ? 1.5 : pts.length > 400 ? 1 : 2, 'data-tip': t };
|
|
103
152
|
});
|
|
104
|
-
body.push(dotLayer(circles));
|
|
153
|
+
body.push(dotLayer(circles, { maxTips: MAX_TIPS }));
|
|
154
|
+
|
|
155
|
+
// Clave de tamaños (burbujas): 2–3 círculos de referencia anidados en una esquina libre.
|
|
156
|
+
if (size && spec.sizeKey !== false && maxS > 0) {
|
|
157
|
+
const nice = (v) => { const e = 10 ** Math.floor(Math.log10(v)); const m = v / e; return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * e; };
|
|
158
|
+
const v1 = nice(maxS);
|
|
159
|
+
const vals = [v1, nice(v1 / 4), nice(v1 / 16)].filter((v, i, a) => a.indexOf(v) === i && Math.sqrt(v / maxS) * rMax >= 4);
|
|
160
|
+
if (vals.length >= 2) {
|
|
161
|
+
const radii = vals.map((v) => Math.sqrt(v / maxS) * rMax);
|
|
162
|
+
const R = radii[0];
|
|
163
|
+
const labs = vals.map((v) => fs(v));
|
|
164
|
+
const lw = Math.max(...labs.map((l) => measure(l, 10.5)));
|
|
165
|
+
const kw = 2 * R + 8 + lw, kh = 2 * R + 16;
|
|
166
|
+
const occ = sorted.map((p) => [xS(p.x), yS(p.y)]);
|
|
167
|
+
// Lo ya ocupado (R²) pesa como muchos puntos: la clave nunca lo tapa.
|
|
168
|
+
for (const q of taken) for (let i = 0; i < 50; i++) occ.push(q);
|
|
169
|
+
const qc = quietCorner(occ, inner, kw, kh);
|
|
170
|
+
const left = qc.anchor === 'end' ? qc.x - kw : qc.x;
|
|
171
|
+
const y0 = qc.y - kh + 3; // borde superior de la caja elegida
|
|
172
|
+
const cx = left + R, cy0 = y0 + kh - 1;
|
|
173
|
+
const parts = [text(left, y0 + 10, hs, { fill: theme.muted, fontSize: 10.5, fontWeight: 500 })];
|
|
174
|
+
let prevY = -Infinity;
|
|
175
|
+
radii.forEach((r, i) => {
|
|
176
|
+
const top = cy0 - 2 * r;
|
|
177
|
+
const ly = Math.max(top, prevY + 11); // rótulos separados aunque los círculos se acerquen
|
|
178
|
+
prevY = ly;
|
|
179
|
+
parts.push(el('circle', { cx, cy: cy0 - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
|
|
180
|
+
parts.push(el('path', { d: `M${cx.toFixed(1)},${top.toFixed(1)}H${(cx + R + 1).toFixed(1)}L${(cx + R + 4).toFixed(1)},${ly.toFixed(1)}`, fill: 'none', stroke: theme.muted, strokeWidth: 0.75, opacity: 0.6 }));
|
|
181
|
+
parts.push(text(cx + R + 6, ly + 3.5, labs[i], { fill: theme.muted, fontSize: 10.5 }));
|
|
182
|
+
});
|
|
183
|
+
body.push(g({ class: 'pa-sizekey' }, parts));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
105
186
|
|
|
106
187
|
body.push(yTickLabels(yS.ticks(dy.step), yS, inner, theme, ytf));
|
|
107
188
|
|
|
@@ -116,7 +197,7 @@ export function scatter(spec, theme) {
|
|
|
116
197
|
}
|
|
117
198
|
const placed = [];
|
|
118
199
|
for (const p of chosen) {
|
|
119
|
-
if (!p.name) continue;
|
|
200
|
+
if (!p.name || !vis(p)) continue;
|
|
120
201
|
const w = measure(p.name, 12, 500);
|
|
121
202
|
const r = rOf(p);
|
|
122
203
|
let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
|
|
@@ -128,7 +209,6 @@ export function scatter(spec, theme) {
|
|
|
128
209
|
}
|
|
129
210
|
}
|
|
130
211
|
|
|
131
|
-
const en = String(locale).startsWith('en');
|
|
132
212
|
const desc = en
|
|
133
213
|
? `Scatter plot of ${humanize(y)} vs ${humanize(x)} with ${pts.length} points.`
|
|
134
214
|
: `Gráfico de dispersión de ${humanize(y)} versus ${humanize(x)} con ${pts.length} puntos.`;
|
package/src/charts/sunburst.js
CHANGED
|
@@ -7,7 +7,7 @@ import { makeFormat } from '../format.js';
|
|
|
7
7
|
import { textOn } from '../theme.js';
|
|
8
8
|
import { humanize } from '../data.js';
|
|
9
9
|
import { createFrame } from '../frame.js';
|
|
10
|
-
import { buildHierarchy,
|
|
10
|
+
import { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
11
11
|
|
|
12
12
|
const TAU = Math.PI * 2;
|
|
13
13
|
const f = (n) => n.toFixed(2);
|
|
@@ -24,7 +24,7 @@ export function sunburst(spec, theme) {
|
|
|
24
24
|
const locale = spec.locale || 'es';
|
|
25
25
|
const en = String(locale).startsWith('en');
|
|
26
26
|
const { root, fields, valueField } = buildHierarchy(spec);
|
|
27
|
-
const focus =
|
|
27
|
+
const focus = focusNode(root, spec.focus);
|
|
28
28
|
const levels = Math.max(1, height(focus));
|
|
29
29
|
assignColors(root, theme, spec); // colores estables al navegar
|
|
30
30
|
const vals = [];
|
|
@@ -34,9 +34,10 @@ export function sunburst(spec, theme) {
|
|
|
34
34
|
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
35
35
|
const dim = (n) => hl && !n.path.some((p) => hl.has(p));
|
|
36
36
|
|
|
37
|
-
const top =
|
|
38
|
-
const
|
|
39
|
-
|
|
37
|
+
const top = root.children;
|
|
38
|
+
const active = focus.path[0];
|
|
39
|
+
const legend = top.length > 1 && spec.legend !== false
|
|
40
|
+
? [...top.filter((c) => !c.folded).map((c) => ({ label: c.name, value: c.name, color: c.color, shape: 'square', active: active === undefined ? undefined : active === c.name })), ...(top.some((c) => c.folded) ? [{ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' }] : [])]
|
|
40
41
|
: [];
|
|
41
42
|
|
|
42
43
|
const frame = createFrame(spec, theme, { height: (spec.width || 720) < 560 ? Math.round((spec.width || 720) + 190) : Math.min(560, Math.max(380, (spec.width || 720) * 0.7)) });
|
|
@@ -126,7 +127,7 @@ export function sunburst(spec, theme) {
|
|
|
126
127
|
measure(small, 11.5) <= r0 * 1.7 ? text(cx, cy + bigSize * 0.3 + 16, small, { fill: theme.lede, fontSize: 11.5, textAnchor: 'middle' }) : '',
|
|
127
128
|
]));
|
|
128
129
|
|
|
129
|
-
const top3 =
|
|
130
|
+
const top3 = focus.children.slice(0, 3).map((c) => `${c.name} ${share(c.value, total)}`).join(', ');
|
|
130
131
|
const what = humanize(valueField);
|
|
131
132
|
const desc = en
|
|
132
133
|
? `Sunburst of ${what} in ${fields.length} levels, total ${fmt(total)}. Largest groups: ${top3}.`
|