plotagents 0.0.0-stage → 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 +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Dispersión y burbujas: relación entre dos variables numéricas, con tamaño
|
|
2
|
+
// opcional (área proporcional), escalas log y línea de tendencia.
|
|
3
|
+
import { el, g, text, tip, dotLayer } from '../svg.js';
|
|
4
|
+
import { measure } from '../text.js';
|
|
5
|
+
import { makeFormat } from '../format.js';
|
|
6
|
+
import { niceDomain, niceStep, linear, log, logDomain } from '../scale.js';
|
|
7
|
+
import { colorMap } from '../theme.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))]));
|
|
16
|
+
|
|
17
|
+
export function scatter(spec, theme) {
|
|
18
|
+
const data = ensureData(spec);
|
|
19
|
+
const fields = fieldsOf(data);
|
|
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');
|
|
24
|
+
const x = spec.x || numeric[0];
|
|
25
|
+
const y = spec.y || numeric.find((f) => f !== x);
|
|
26
|
+
const size = spec.size;
|
|
27
|
+
const series = spec.series ?? spec.hue ?? spec.color;
|
|
28
|
+
const labelF = spec.label ?? fields.find((f) => kindOf(f) === 'category' && f !== series);
|
|
29
|
+
requireField(data, x, 'x');
|
|
30
|
+
requireField(data, y, 'y');
|
|
31
|
+
if (size) requireField(data, size, 'size');
|
|
32
|
+
if (series) requireField(data, series, 'series');
|
|
33
|
+
const locale = spec.locale || 'es';
|
|
34
|
+
const en = String(locale).startsWith('en');
|
|
35
|
+
const xLog = spec.xScale === 'log', yLog = spec.yScale === 'log';
|
|
36
|
+
|
|
37
|
+
// En escala log, los valores ≤ 0 no tienen posición: se omiten.
|
|
38
|
+
const pts = data
|
|
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] ?? '') : '' }))
|
|
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
|
+
}
|
|
49
|
+
const keys = series ? uniq(pts.map((p) => p.k)) : [''];
|
|
50
|
+
const cmap = colorMap(keys, theme, spec.colors);
|
|
51
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
52
|
+
const focus = (p) => !hl || hl.has(p.name) || hl.has(p.k);
|
|
53
|
+
const colorOf = (p) => (!focus(p) ? theme.dim : series ? cmap.get(p.k) : theme.categorical[0]);
|
|
54
|
+
|
|
55
|
+
const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
|
|
56
|
+
const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
|
|
57
|
+
const fs = size ? makeFormat(spec.sizeFormat, locale, pts.map((p) => p.s)) : null;
|
|
58
|
+
|
|
59
|
+
const legend = series && keys.length > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: cmap.get(k) })) : [];
|
|
60
|
+
const frame = createFrame(spec, theme, { height: 460 });
|
|
61
|
+
const area = frame.layout(legend);
|
|
62
|
+
|
|
63
|
+
const ex = extent(pts.map((p) => p.x));
|
|
64
|
+
const ey = extent(pts.map((p) => p.y));
|
|
65
|
+
if (spec.xDomain) ex.splice(0, 2, ...spec.xDomain);
|
|
66
|
+
if (spec.yDomain) ey.splice(0, 2, ...spec.yDomain);
|
|
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)));
|
|
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)));
|
|
69
|
+
if (spec._domains) { spec._domains.x = [dx.min, dx.max]; spec._domains.y = [dy.min, dy.max]; }
|
|
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));
|
|
73
|
+
const leftM = yAxisMargin(yTicks, ytf, theme);
|
|
74
|
+
const top = area.y + 18 + yAxisTop(theme);
|
|
75
|
+
const inner = { x: area.x + leftM, y: top, w: area.w - leftM - 8, h: area.y + area.h - top - 44 };
|
|
76
|
+
const maxS = extent(pts.map((p) => p.s))[1];
|
|
77
|
+
const rMax = spec.maxRadius ?? Math.min(30, Math.max(12, Math.sqrt((inner.w * inner.h) / Math.max(1, pts.length)) / 3));
|
|
78
|
+
// Las burbujas no se salen del área: margen interno del radio máximo.
|
|
79
|
+
const bp = size ? rMax * 0.75 : 6;
|
|
80
|
+
const yS = (yLog ? log : linear)(dy.min, dy.max, inner.y + inner.h - bp, inner.y + bp);
|
|
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);
|
|
100
|
+
|
|
101
|
+
const body = [];
|
|
102
|
+
body.push(yGrid(yS.ticks(dy.step), yS, inner, theme, { baseline: NaN }));
|
|
103
|
+
body.push(gridX(xS.ticks(dx.step), xS, inner, theme, xtf, { baseline: NaN }));
|
|
104
|
+
body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: inner.y + inner.h + 0.5, y2: inner.y + inner.h + 0.5, stroke: theme.axis }));
|
|
105
|
+
// Títulos de ejes: horizontales, nunca rotados.
|
|
106
|
+
body.push(text(area.x, area.y + 10, spec.yLabel ?? humanize(y), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500 }));
|
|
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' }));
|
|
108
|
+
|
|
109
|
+
const taken = []; // zonas ocupadas por rótulos (R²), para la clave de tamaños
|
|
110
|
+
// Tendencia lineal (mínimos cuadrados)
|
|
111
|
+
if (spec.trend) {
|
|
112
|
+
const tx = pts.map((p) => (xLog ? Math.log10(p.x) : p.x)), ty = pts.map((p) => (yLog ? Math.log10(p.y) : p.y));
|
|
113
|
+
const n = tx.length, mx = tx.reduce((a, b) => a + b, 0) / n, my = ty.reduce((a, b) => a + b, 0) / n;
|
|
114
|
+
let sxy = 0, sxx = 0, syy = 0;
|
|
115
|
+
for (let i = 0; i < n; i++) { sxy += (tx[i] - mx) * (ty[i] - my); sxx += (tx[i] - mx) ** 2; syy += (ty[i] - my) ** 2; }
|
|
116
|
+
const b = sxy / (sxx || 1), a = my - b * mx;
|
|
117
|
+
const r2v = sxx && syy ? (sxy * sxy) / (sxx * syy) : 0;
|
|
118
|
+
const lx0 = xLog ? Math.log10(dx.min) : dx.min, lx1 = xLog ? Math.log10(dx.max) : dx.max;
|
|
119
|
+
const P = (lx) => [xS(xLog ? 10 ** lx : lx), yS(yLog ? 10 ** (a + b * lx) : a + b * lx)];
|
|
120
|
+
const [ax, ay] = P(lx0), [bx, by] = P(lx1);
|
|
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})` }));
|
|
125
|
+
const r2s = `R² = ${r2v.toFixed(2).replace('.', String(locale).startsWith('en') ? '.' : ',')}`;
|
|
126
|
+
const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
|
|
127
|
+
for (let i = 0; i <= 24; i++) occ.push([ax + ((bx - ax) * i) / 24, ay + ((by - ay) * i) / 24]);
|
|
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]);
|
|
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' }));
|
|
132
|
+
}
|
|
133
|
+
body.push(refLinesY(spec.refLines || [], yS, inner, theme, fy));
|
|
134
|
+
body.push(refLinesX(spec.refLines || [], xS, inner, theme, fx));
|
|
135
|
+
|
|
136
|
+
// Burbujas grandes debajo, chicas arriba; anillo del color de fondo.
|
|
137
|
+
const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
|
|
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
|
+
const circles = sorted.map((p, i) => {
|
|
142
|
+
let t = '';
|
|
143
|
+
if (i < MAX_TIPS || sorted.length <= 1500) {
|
|
144
|
+
const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
|
|
145
|
+
if (size) lines.push(`${hs}: ${fs(p.s)}`);
|
|
146
|
+
if (series) lines.splice(1, 0, p.k);
|
|
147
|
+
t = tip(...lines);
|
|
148
|
+
}
|
|
149
|
+
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 };
|
|
150
|
+
});
|
|
151
|
+
body.push(dotLayer(circles, { maxTips: MAX_TIPS }));
|
|
152
|
+
|
|
153
|
+
// Clave de tamaños (burbujas): 2–3 círculos de referencia anidados en una esquina libre.
|
|
154
|
+
if (size && spec.sizeKey !== false && maxS > 0) {
|
|
155
|
+
const nice = (v) => { const e = 10 ** Math.floor(Math.log10(v)); const m = v / e; return (m >= 5 ? 5 : m >= 2 ? 2 : 1) * e; };
|
|
156
|
+
const v1 = nice(maxS);
|
|
157
|
+
const vals = [v1, nice(v1 / 4), nice(v1 / 16)].filter((v, i, a) => a.indexOf(v) === i && Math.sqrt(v / maxS) * rMax >= 4);
|
|
158
|
+
if (vals.length >= 2) {
|
|
159
|
+
const radii = vals.map((v) => Math.sqrt(v / maxS) * rMax);
|
|
160
|
+
const R = radii[0];
|
|
161
|
+
const labs = vals.map((v) => fs(v));
|
|
162
|
+
const lw = Math.max(...labs.map((l) => measure(l, 10.5)));
|
|
163
|
+
const kw = 2 * R + 8 + lw, kh = 2 * R + 16;
|
|
164
|
+
const occ = sorted.map((p) => [xS(p.x), yS(p.y)]);
|
|
165
|
+
// Lo ya ocupado (R²) pesa como muchos puntos: la clave nunca lo tapa.
|
|
166
|
+
for (const q of taken) for (let i = 0; i < 50; i++) occ.push(q);
|
|
167
|
+
const qc = quietCorner(occ, inner, kw, kh);
|
|
168
|
+
const left = qc.anchor === 'end' ? qc.x - kw : qc.x;
|
|
169
|
+
const y0 = qc.y - kh + 3; // borde superior de la caja elegida
|
|
170
|
+
const cx = left + R, cy0 = y0 + kh - 1;
|
|
171
|
+
const parts = [text(left, y0 + 10, hs, { fill: theme.muted, fontSize: 10.5, fontWeight: 500 })];
|
|
172
|
+
let prevY = -Infinity;
|
|
173
|
+
radii.forEach((r, i) => {
|
|
174
|
+
const top = cy0 - 2 * r;
|
|
175
|
+
const ly = Math.max(top, prevY + 11); // rótulos separados aunque los círculos se acerquen
|
|
176
|
+
prevY = ly;
|
|
177
|
+
parts.push(el('circle', { cx, cy: cy0 - r, r, fill: 'none', stroke: theme.muted, strokeWidth: 1, opacity: 0.8 }));
|
|
178
|
+
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 }));
|
|
179
|
+
parts.push(text(cx + R + 6, ly + 3.5, labs[i], { fill: theme.muted, fontSize: 10.5 }));
|
|
180
|
+
});
|
|
181
|
+
body.push(g({ class: 'pa-sizekey' }, parts));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
body.push(yTickLabels(yS.ticks(dy.step), yS, inner, theme, ytf));
|
|
186
|
+
|
|
187
|
+
// Etiquetas selectivas: las que pide el agente o las N mayores.
|
|
188
|
+
if (labelF && spec.labels !== false) {
|
|
189
|
+
let chosen;
|
|
190
|
+
if (Array.isArray(spec.labels)) chosen = pts.filter((p) => spec.labels.map(String).includes(p.name));
|
|
191
|
+
else {
|
|
192
|
+
const n = typeof spec.labels === 'number' ? spec.labels : hl ? 0 : Math.min(6, pts.length);
|
|
193
|
+
chosen = [...pts].sort((a, b) => (size ? b.s - a.s : b.y - a.y)).slice(0, n);
|
|
194
|
+
if (hl) chosen = chosen.concat(pts.filter((p) => hl.has(p.name)));
|
|
195
|
+
}
|
|
196
|
+
const placed = [];
|
|
197
|
+
for (const p of chosen) {
|
|
198
|
+
if (!p.name) continue;
|
|
199
|
+
const w = measure(p.name, 12, 500);
|
|
200
|
+
const r = rOf(p);
|
|
201
|
+
let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
|
|
202
|
+
if (lx + w > inner.x + inner.w) { lx = xS(p.x) - r - 4; anchor = 'end'; }
|
|
203
|
+
const box = anchor === 'start' ? [lx, ly - 11, lx + w, ly + 3] : [lx - w, ly - 11, lx, ly + 3];
|
|
204
|
+
if (placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
|
|
205
|
+
placed.push(box);
|
|
206
|
+
body.push(el('text', { x: lx, y: ly, fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }, p.name.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c])));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const desc = en
|
|
211
|
+
? `Scatter plot of ${humanize(y)} vs ${humanize(x)} with ${pts.length} points.`
|
|
212
|
+
: `Gráfico de dispersión de ${humanize(y)} versus ${humanize(x)} con ${pts.length} puntos.`;
|
|
213
|
+
return frame.finish(g({}, body), { desc, type: size ? 'bubble' : 'scatter' });
|
|
214
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Sunburst: la misma jerarquía que el treemap, en anillos concéntricos. El
|
|
2
|
+
// anillo interior es el primer nivel (color por categoría); los exteriores
|
|
3
|
+
// heredan el color con pasos sutiles de luminosidad. Total al centro.
|
|
4
|
+
import { el, g, text, tip } from '../svg.js';
|
|
5
|
+
import { measure } from '../text.js';
|
|
6
|
+
import { makeFormat } from '../format.js';
|
|
7
|
+
import { textOn } from '../theme.js';
|
|
8
|
+
import { humanize } from '../data.js';
|
|
9
|
+
import { createFrame } from '../frame.js';
|
|
10
|
+
import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
11
|
+
|
|
12
|
+
const TAU = Math.PI * 2;
|
|
13
|
+
const f = (n) => n.toFixed(2);
|
|
14
|
+
|
|
15
|
+
function arcPath(cx, cy, r0, r1, a0, a1) {
|
|
16
|
+
if (a1 - a0 >= TAU - 1e-6) a1 = a0 + TAU - 1e-4;
|
|
17
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
18
|
+
const p = (r, a) => `${f(cx + r * Math.cos(a))},${f(cy + r * Math.sin(a))}`;
|
|
19
|
+
if (r0 <= 0.01) return `M${f(cx)},${f(cy)}L${p(r1, a0)}A${f(r1)},${f(r1)} 0 ${large} 1 ${p(r1, a1)}Z`;
|
|
20
|
+
return `M${p(r1, a0)}A${f(r1)},${f(r1)} 0 ${large} 1 ${p(r1, a1)}L${p(r0, a1)}A${f(r0)},${f(r0)} 0 ${large} 0 ${p(r0, a0)}Z`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sunburst(spec, theme) {
|
|
24
|
+
const locale = spec.locale || 'es';
|
|
25
|
+
const en = String(locale).startsWith('en');
|
|
26
|
+
const { root, fields, valueField } = buildHierarchy(spec);
|
|
27
|
+
const focus = findNode(root, spec.focus);
|
|
28
|
+
const levels = Math.max(1, height(focus));
|
|
29
|
+
assignColors(root, theme, spec); // colores estables al navegar
|
|
30
|
+
const vals = [];
|
|
31
|
+
(function walk(n) { vals.push(n.value); n.children.forEach(walk); })(root);
|
|
32
|
+
const fmt = makeFormat(spec.format, locale, vals);
|
|
33
|
+
const share = shareFmt(locale);
|
|
34
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
35
|
+
const dim = (n) => hl && !n.path.some((p) => hl.has(p));
|
|
36
|
+
|
|
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' }] : [])]
|
|
41
|
+
: [];
|
|
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)) });
|
|
44
|
+
let area = frame.layout(legend);
|
|
45
|
+
const body = [];
|
|
46
|
+
const bc = breadcrumb(focus, area.x, area.y, area.w, theme, locale);
|
|
47
|
+
if (bc.h) { body.push(bc.svg); area = { ...area, y: area.y + bc.h, h: area.h - bc.h }; }
|
|
48
|
+
|
|
49
|
+
const R = Math.max(40, Math.min(area.w, area.h) / 2 - 2);
|
|
50
|
+
const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
|
|
51
|
+
const r0 = R * (spec.hole ?? 0.3);
|
|
52
|
+
// Anillos: el exterior más delgado cuando hay muchos niveles.
|
|
53
|
+
const weights = Array.from({ length: levels }, (_, i) => (levels >= 3 && i === levels - 1 ? 0.62 : 1));
|
|
54
|
+
const wsum = weights.reduce((a, b) => a + b, 0);
|
|
55
|
+
const radii = [r0];
|
|
56
|
+
weights.forEach((w) => radii.push(radii[radii.length - 1] + ((R - r0) * w) / wsum));
|
|
57
|
+
|
|
58
|
+
const total = focus.value, grand = root.value;
|
|
59
|
+
const sep = theme.marks?.gap ?? 2;
|
|
60
|
+
const arcs = [];
|
|
61
|
+
const labels = [];
|
|
62
|
+
(function layout(n, a0, a1, d) {
|
|
63
|
+
let a = a0;
|
|
64
|
+
for (const c of n.children) {
|
|
65
|
+
const span = ((a1 - a0) * c.value) / (n.value || 1);
|
|
66
|
+
if (d < levels) {
|
|
67
|
+
arcs.push({ n: c, a0: a, a1: a + span, r0: radii[d], r1: radii[d + 1], d: d + 1 });
|
|
68
|
+
layout(c, a, a + span, d + 1);
|
|
69
|
+
}
|
|
70
|
+
a += span;
|
|
71
|
+
}
|
|
72
|
+
})(focus, -Math.PI / 2, (3 * Math.PI) / 2, 0);
|
|
73
|
+
|
|
74
|
+
for (const a of arcs) {
|
|
75
|
+
const n = a.n;
|
|
76
|
+
const fill = dim(n) ? theme.dim : n.color;
|
|
77
|
+
const parent = n.parent && n.parent.depth > 0 ? n.parent : null;
|
|
78
|
+
const t = tip(n.path.join(SEP), `${fmt(n.value)} · ${share(n.value, grand)} ${en ? 'of total' : 'del total'}`, parent ? `${share(n.value, parent.value)} ${en ? 'of' : 'de'} ${parent.name}` : '');
|
|
79
|
+
const drill = n.children.length ? JSON.stringify(n.path) : undefined;
|
|
80
|
+
body.push(el('path', {
|
|
81
|
+
class: 'pa-m', d: arcPath(cx, cy, a.r0, a.r1, a.a0, a.a1), fill,
|
|
82
|
+
stroke: theme.surface, strokeWidth: Math.min(sep, 1.75), strokeLinejoin: 'round',
|
|
83
|
+
'data-tip': t, 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined,
|
|
84
|
+
}));
|
|
85
|
+
// Etiqueta: tangencial si cabe a lo largo del arco; si no, radial; si no, nada.
|
|
86
|
+
const mid = (a.a0 + a.a1) / 2;
|
|
87
|
+
const rm = (a.r0 + a.r1) / 2;
|
|
88
|
+
const thick = a.r1 - a.r0;
|
|
89
|
+
const ink = textOn(fill, theme);
|
|
90
|
+
for (const size of [12, 11, 10]) {
|
|
91
|
+
const w = measure(n.name, size, 600);
|
|
92
|
+
const deg = (mid * 180) / Math.PI;
|
|
93
|
+
const half = (a.a1 - a.a0) / 2;
|
|
94
|
+
// Tangencial: texto recto sobre un arco. Se busca un radio donde el
|
|
95
|
+
// borde interior no cruce r0 y las puntas no salgan por r1.
|
|
96
|
+
const rt = a.r0 + 2 + size / 2;
|
|
97
|
+
const reach = Math.sqrt((rt + size / 2) ** 2 + (w / 2 + 3) ** 2);
|
|
98
|
+
const angOk = half < Math.PI / 2 && w / 2 + 4 <= (rt - size / 2) * Math.tan(half);
|
|
99
|
+
if (reach <= a.r1 - 2 && angOk) {
|
|
100
|
+
const lo = rt, hi = Math.sqrt(Math.max(0, (a.r1 - 2) ** 2 - (w / 2 + 3) ** 2)) - size / 2;
|
|
101
|
+
const rr = Math.max(lo, Math.min(hi, (lo + hi) / 2));
|
|
102
|
+
let rot = deg + 90;
|
|
103
|
+
if (Math.sin(mid) > 0) rot -= 180;
|
|
104
|
+
labels.push(text(0, 0, n.name, { transform: `translate(${f(cx + rr * Math.cos(mid))},${f(cy + rr * Math.sin(mid))}) rotate(${f(rot)})`, textAnchor: 'middle', dy: size * 0.35, fill: ink, fontSize: size, fontWeight: 600, pointerEvents: 'none' }));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// Radial: a lo largo del radio, si el anillo es largo y el arco ancho.
|
|
108
|
+
if (w + 10 <= thick && 2 * a.r0 * Math.sin(Math.min(half, Math.PI / 2)) >= size + 3) {
|
|
109
|
+
let rot = deg;
|
|
110
|
+
if (Math.cos(mid) < 0) rot += 180;
|
|
111
|
+
labels.push(text(0, 0, n.name, { transform: `translate(${f(cx + rm * Math.cos(mid))},${f(cy + rm * Math.sin(mid))}) rotate(${f(rot)})`, textAnchor: 'middle', dy: size * 0.35, fill: ink, fontSize: size, fontWeight: 600, pointerEvents: 'none' }));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
body.push(g({ class: 'pa-labels' }, labels));
|
|
117
|
+
|
|
118
|
+
// Centro: total (o el nodo en foco). Clic en el centro sube un nivel.
|
|
119
|
+
const big = spec.centerValue ?? fmt(total);
|
|
120
|
+
let bigSize = Math.min(30, r0 * 0.46);
|
|
121
|
+
while (bigSize > 12 && measure(big, bigSize, 600) > r0 * 1.7) bigSize -= 1;
|
|
122
|
+
const small = spec.centerLabel ?? (focus.path.length ? focus.name : en ? 'Total' : 'Total');
|
|
123
|
+
const up = focus.path.length ? JSON.stringify(focus.path.slice(0, -1)) : undefined;
|
|
124
|
+
body.push(g({ class: 'pa-center', 'data-drill': up, style: up ? 'cursor:pointer' : undefined }, [
|
|
125
|
+
el('circle', { cx, cy, r: Math.max(0, r0 - 2), fill: theme.surface }),
|
|
126
|
+
text(cx, cy + bigSize * 0.3, big, { fill: theme.ink, fontSize: bigSize, fontWeight: 600, textAnchor: 'middle' }),
|
|
127
|
+
measure(small, 11.5) <= r0 * 1.7 ? text(cx, cy + bigSize * 0.3 + 16, small, { fill: theme.lede, fontSize: 11.5, textAnchor: 'middle' }) : '',
|
|
128
|
+
]));
|
|
129
|
+
|
|
130
|
+
const top3 = focus.children.slice(0, 3).map((c) => `${c.name} ${share(c.value, total)}`).join(', ');
|
|
131
|
+
const what = humanize(valueField);
|
|
132
|
+
const desc = en
|
|
133
|
+
? `Sunburst of ${what} in ${fields.length} levels, total ${fmt(total)}. Largest groups: ${top3}.`
|
|
134
|
+
: `Sunburst de ${what.toLowerCase()} en ${fields.length} niveles, total ${fmt(total)}. Mayores grupos: ${top3}.`;
|
|
135
|
+
return frame.finish(body, { desc, type: 'sunburst' });
|
|
136
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Treemap de varios niveles (estilo Atlas de Complejidad Económica / d3plus).
|
|
2
|
+
// Color por grupo de primer nivel, variación sutil de luminosidad en niveles
|
|
3
|
+
// más profundos, encabezados de grupo cuando hay espacio y etiquetas que solo
|
|
4
|
+
// aparecen si caben. Drill-down: data-drill + spec.focus.
|
|
5
|
+
import { el, g, text, tip, esc } from '../svg.js';
|
|
6
|
+
import { measure, wrap } from '../text.js';
|
|
7
|
+
import { makeFormat } from '../format.js';
|
|
8
|
+
import { textOn, ramp } from '../theme.js';
|
|
9
|
+
import { formatNumber } from '../format.js';
|
|
10
|
+
import { humanize } from '../data.js';
|
|
11
|
+
import { createFrame } from '../frame.js';
|
|
12
|
+
import { ctx } from '../context.js';
|
|
13
|
+
import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
14
|
+
|
|
15
|
+
/** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
|
|
16
|
+
export function squarify(items, x, y, w, h) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const total = items.reduce((a, b) => a + b.value, 0);
|
|
19
|
+
if (total <= 0 || w <= 0 || h <= 0) return out;
|
|
20
|
+
const scale = (w * h) / total;
|
|
21
|
+
const nodes = items.map((it) => ({ it, area: it.value * scale })).filter((n) => n.area > 0);
|
|
22
|
+
let rect = { x, y, w, h };
|
|
23
|
+
let row = [];
|
|
24
|
+
const worst = (r, side) => {
|
|
25
|
+
const s = r.reduce((a, b) => a + b.area, 0);
|
|
26
|
+
const mx = Math.max(...r.map((n) => n.area)), mn = Math.min(...r.map((n) => n.area));
|
|
27
|
+
return Math.max((side * side * mx) / (s * s), (s * s) / (side * side * mn));
|
|
28
|
+
};
|
|
29
|
+
const layoutRow = (r, last) => {
|
|
30
|
+
const s = r.reduce((a, b) => a + b.area, 0);
|
|
31
|
+
if (rect.w >= rect.h) {
|
|
32
|
+
const cw = last ? rect.w : s / rect.h;
|
|
33
|
+
let cy = rect.y;
|
|
34
|
+
r.forEach((n, i) => { const ch = i === r.length - 1 ? rect.y + rect.h - cy : n.area / cw; out.push({ ...n.it, x: rect.x, y: cy, w: cw, h: ch }); cy += ch; });
|
|
35
|
+
rect = { x: rect.x + cw, y: rect.y, w: rect.w - cw, h: rect.h };
|
|
36
|
+
} else {
|
|
37
|
+
const ch = last ? rect.h : s / rect.w;
|
|
38
|
+
let cx = rect.x;
|
|
39
|
+
r.forEach((n, i) => { const cw = i === r.length - 1 ? rect.x + rect.w - cx : n.area / ch; out.push({ ...n.it, x: cx, y: rect.y, w: cw, h: ch }); cx += cw; });
|
|
40
|
+
rect = { x: rect.x, y: rect.y + ch, w: rect.w, h: rect.h - ch };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
nodes.forEach((n, idx) => {
|
|
44
|
+
const side = Math.min(rect.w, rect.h);
|
|
45
|
+
if (!row.length || worst([...row, n], side) <= worst(row, side)) row.push(n);
|
|
46
|
+
else { layoutRow(row, false); row = [n]; }
|
|
47
|
+
if (idx === nodes.length - 1) layoutRow(row, true);
|
|
48
|
+
});
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Separación entre hermanos según la profundidad visible (1 = primer nivel).
|
|
53
|
+
// Separación por nivel: más aire entre categorías, menos dentro de ellas.
|
|
54
|
+
const GAP = (d) => (d <= 1 ? 4 : d === 2 ? 2 : 1);
|
|
55
|
+
|
|
56
|
+
export function treemap(spec, theme) {
|
|
57
|
+
const locale = spec.locale || 'es';
|
|
58
|
+
const en = String(locale).startsWith('en');
|
|
59
|
+
const { root, fields, valueField } = buildHierarchy(spec);
|
|
60
|
+
const focus = findNode(root, spec.focus);
|
|
61
|
+
const levels = height(focus);
|
|
62
|
+
// El color sigue a la entidad: se asigna sobre el árbol completo, así al
|
|
63
|
+
// entrar a un grupo sus elementos conservan la familia de color del padre.
|
|
64
|
+
const single = height(root) <= 1 && spec.colorBy !== 'label';
|
|
65
|
+
assignColors(root, theme, spec, { single });
|
|
66
|
+
const allValues = [];
|
|
67
|
+
(function walk(n) { allValues.push(n.value); n.children.forEach(walk); })(root);
|
|
68
|
+
const fmt = makeFormat(spec.format, locale, allValues);
|
|
69
|
+
const share = shareFmt(locale);
|
|
70
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
71
|
+
|
|
72
|
+
// Variación vs. el periodo anterior (con `time`) o vs. `compareData`.
|
|
73
|
+
const prevRows = spec.compareData ?? spec._prev?.data;
|
|
74
|
+
const prevLabel = spec.compareLabel ?? spec._prev?.label;
|
|
75
|
+
const prevMap = new Map();
|
|
76
|
+
if (prevRows && prevRows.length) {
|
|
77
|
+
const pr = buildHierarchy({ ...spec, data: prevRows, time: undefined }).root;
|
|
78
|
+
(function walk(n) { prevMap.set(n.path.join('\u0000'), n.value); n.children.forEach(walk); })(pr);
|
|
79
|
+
}
|
|
80
|
+
const changeOf = (n) => { const pv = prevMap.get(n.path.join('\u0000')); return pv > 0 ? n.value / pv - 1 : null; };
|
|
81
|
+
const showChange = prevMap.size > 0 && spec.change !== false;
|
|
82
|
+
const byChange = showChange && spec.colorBy === 'change';
|
|
83
|
+
const pctTxt = (c) => (c === null ? '' : `${c > 0 ? '▲' : c < 0 ? '▼' : '■'} ${formatNumber(Math.abs(c) * 100, Math.abs(c) < 0.1 ? 1 : 0, locale, true)}%`);
|
|
84
|
+
let cMax = 0;
|
|
85
|
+
if (byChange) (function walk(n) { if (!n.children.length) { const c = changeOf(n); if (c !== null) cMax = Math.max(cMax, Math.abs(c)); } n.children.forEach(walk); })(focus);
|
|
86
|
+
cMax = spec.changeDomain ?? (Math.ceil(Math.min(1, cMax || 0.1) * 20) / 20);
|
|
87
|
+
// Crecimiento en el tono frío, caída en el cálido; gris neutro sin cambio.
|
|
88
|
+
// Con una colormap propia (cmap) se respeta su orientación: bajo → alto.
|
|
89
|
+
const divRamp = theme.cmap ? [...theme.diverging] : [...theme.diverging].reverse();
|
|
90
|
+
const changeColor = (c) => (c === null ? theme.tint : ramp(divRamp, 0.5 + Math.max(-1, Math.min(1, c / cMax)) / 2));
|
|
91
|
+
|
|
92
|
+
// Leyenda del primer nivel visible, con su participación: la leyenda también informa.
|
|
93
|
+
const top = focus.children;
|
|
94
|
+
let legend = [];
|
|
95
|
+
if (byChange && spec.legend !== false) {
|
|
96
|
+
const lab = (v) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${formatNumber(Math.abs(v) * 100, 0, locale)}%`;
|
|
97
|
+
legend = [{ type: 'ramp', stops: divRamp, min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
|
|
98
|
+
} else if (height(root) > 1 && root.children.length > 1 && spec.legend !== false) {
|
|
99
|
+
// Leyenda del primer nivel, también al entrar a un grupo: clic = ver solo
|
|
100
|
+
// esa categoría en todo el plano; shift + clic = quitarla (en el navegador).
|
|
101
|
+
const tops = root.children;
|
|
102
|
+
const active = focus.path[0];
|
|
103
|
+
legend = tops.filter((c) => !c.folded).map((c) => ({ label: `${c.name} ${share(c.value, root.value)}`, value: c.name, color: c.color, shape: 'square', active: active === undefined ? undefined : active === c.name }));
|
|
104
|
+
if (tops.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// En pantallas angostas el treemap crece hacia abajo: rectángulos legibles.
|
|
108
|
+
const W = spec.width || 720;
|
|
109
|
+
const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(Math.min(780, W * 1.15 + 170)) : Math.round(Math.max(440, Math.min(620, W * 0.62))) });
|
|
110
|
+
let area = frame.layout(legend);
|
|
111
|
+
const body = [];
|
|
112
|
+
const bc = breadcrumb(focus, area.x, area.y, area.w, theme, locale);
|
|
113
|
+
if (bc.h) { body.push(bc.svg); area = { ...area, y: area.y + bc.h, h: area.h - bc.h }; }
|
|
114
|
+
|
|
115
|
+
const total = focus.value;
|
|
116
|
+
const grand = root.value;
|
|
117
|
+
const R = theme.marks?.barRadius ?? 4;
|
|
118
|
+
|
|
119
|
+
const tipFor = (n) => {
|
|
120
|
+
const parentShare = n.parent && n.parent.value ? share(n.value, n.parent.value) : '';
|
|
121
|
+
const ch = showChange ? changeOf(n) : null;
|
|
122
|
+
return tip(
|
|
123
|
+
n.path.join(SEP),
|
|
124
|
+
`${fmt(n.value)} · ${share(n.value, grand)} ${en ? 'of total' : 'del total'}`,
|
|
125
|
+
n.parent && n.parent.depth > 0 ? `${parentShare} ${en ? 'of' : 'de'} ${n.parent.name}` : '',
|
|
126
|
+
ch !== null ? `${pctTxt(ch)} ${prevLabel ? 'vs ' + prevLabel : ''}`.trim() : ''
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
const dim = (n) => hl && !n.path.some((p) => hl.has(p));
|
|
130
|
+
|
|
131
|
+
function drawLeaf(n, x, y, w, h, relDepth) {
|
|
132
|
+
if (w < 0.6 || h < 0.6) return '';
|
|
133
|
+
const ch = showChange ? changeOf(n) : null;
|
|
134
|
+
const fill = dim(n) ? theme.dim : byChange ? changeColor(ch) : n.color;
|
|
135
|
+
const rx = Math.max(0, Math.min(R - 1, w / 6, h / 6));
|
|
136
|
+
const parts = [el('rect', { x, y, width: w, height: h, rx, fill })];
|
|
137
|
+
const extra = [spec.showValues ? fmt(n.value) : null, ch !== null ? pctTxt(ch) : null].filter(Boolean).join(' · ');
|
|
138
|
+
parts.push(...tileLabel(n.name, share(n.value, total), x, y, w, h, textOn(fill, theme), extra || null, relDepth));
|
|
139
|
+
const drill = relDepth === 1 && n.children.length ? JSON.stringify(n.path) : undefined;
|
|
140
|
+
return g({ class: 'pa-m', 'data-tip': tipFor(n), 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined }, parts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function drawNode(n, x, y, w, h, relDepth) {
|
|
144
|
+
// Hoja visible (sin hijos o al fondo de la jerarquía mostrada).
|
|
145
|
+
if (!n.children.length) return drawLeaf(n, x, y, w, h, relDepth);
|
|
146
|
+
// Sin bandas de encabezado: el área de cada grupo es exactamente su valor.
|
|
147
|
+
// La categoría se reconoce por su color sólido, el espacio entre grupos y la leyenda.
|
|
148
|
+
const parts = [];
|
|
149
|
+
const iy = y, ih = h;
|
|
150
|
+
const gap = GAP(relDepth + 1);
|
|
151
|
+
const rects = squarify(n.children, x, iy, w, ih);
|
|
152
|
+
for (const c of rects) parts.push(drawNode(c, c.x + gap / 2, c.y + gap / 2, c.w - gap, c.h - gap, relDepth + 1));
|
|
153
|
+
const drill = relDepth === 1 ? JSON.stringify(n.path) : undefined;
|
|
154
|
+
return g({ class: relDepth === 1 ? 'pa-group' : undefined, 'data-tip': relDepth === 1 ? tipFor(n) : undefined, 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined }, parts);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const g1 = GAP(1);
|
|
158
|
+
const rects = squarify(focus.children, area.x - g1 / 2, area.y - g1 / 2, area.w + g1, area.h + g1);
|
|
159
|
+
for (const r of rects) body.push(drawNode(r, r.x + g1 / 2, r.y + g1 / 2, r.w - g1, r.h - g1, 1));
|
|
160
|
+
|
|
161
|
+
const leaves = [];
|
|
162
|
+
(function walk(n) { if (!n.children.length) leaves.push(n); n.children.forEach(walk); })(focus);
|
|
163
|
+
const top3 = leaves.sort((a, b) => b.value - a.value).slice(0, 3).map((l) => `${l.name} ${share(l.value, total)}`).join(', ');
|
|
164
|
+
const what = humanize(valueField);
|
|
165
|
+
const where = focus.path.length ? ` (${focus.path.join(SEP)})` : '';
|
|
166
|
+
const desc = en
|
|
167
|
+
? `Treemap of ${what}${where} across ${fields.length} level${fields.length > 1 ? 's' : ''}, total ${fmt(total)}. Largest: ${top3}.`
|
|
168
|
+
: `Treemap de ${what.toLowerCase()}${where} en ${fields.length} nivel${fields.length > 1 ? 'es' : ''}, total ${fmt(total)}. Mayores: ${top3}.`;
|
|
169
|
+
return frame.finish(body, { desc, type: 'treemap' });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Etiqueta dentro del rectángulo solo si cabe; tamaño según el área. */
|
|
173
|
+
function tileLabel(name, pct, x, y, w, h, fill, value, depth) {
|
|
174
|
+
const out = [];
|
|
175
|
+
const pad = w < 60 || h < 40 ? 5 : 8;
|
|
176
|
+
const maxSize = depth <= 1 ? 24 : depth === 2 ? 18 : 15;
|
|
177
|
+
for (let size = Math.min(maxSize, Math.max(10, Math.sqrt(w * h) / 8.5)); size >= 9.5; size -= 1) {
|
|
178
|
+
const lines = wrap(name, w - pad * 2, size, 600, 3);
|
|
179
|
+
const X = ctx.textScale;
|
|
180
|
+
const lh = size * X * 1.14;
|
|
181
|
+
const pctSize = Math.max(9.5, size * 0.78);
|
|
182
|
+
const sub = value ? `${pct} · ${value}` : pct;
|
|
183
|
+
const need = pad + lines.length * lh + 3 + pctSize * X + pad * 0.5;
|
|
184
|
+
const widest = Math.max(...lines.map((l) => measure(l, size, 600)), measure(sub, pctSize));
|
|
185
|
+
const fitsW = lines.every((l) => measure(l, size, 600) <= w - pad * 2) && !/…$/.test(lines[lines.length - 1]);
|
|
186
|
+
if (fitsW && widest <= w - pad * 2 && need <= h) {
|
|
187
|
+
lines.forEach((l, i) => out.push(text(x + pad, y + pad + size * X * 0.88 + i * lh, l, { fill, fontSize: size, fontWeight: 600 })));
|
|
188
|
+
out.push(text(x + pad, y + pad + size * X * 0.88 + (lines.length - 1) * lh + pctSize * X + 4, sub, { fill, fontSize: pctSize, opacity: 0.86 }));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (lines.length === 1 && fitsW && pad + lh <= h) {
|
|
192
|
+
out.push(text(x + pad, y + pad + size * X * 0.88, lines[0], { fill, fontSize: size, fontWeight: 600 }));
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|