plotagents 0.0.0-stage → 0.5.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 +78 -0
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/bin/plotagents.js +49 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +7954 -0
- package/docs/api.md +154 -0
- package/docs/charts.md +218 -0
- package/docs/getting-started.md +138 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +86 -0
- package/index.d.ts +213 -0
- package/package.json +74 -4
- package/schema.json +895 -0
- package/src/axes.js +144 -0
- package/src/charts/bar.js +339 -0
- package/src/charts/categorical.js +471 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +559 -0
- package/src/charts/dist.js +513 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +97 -0
- package/src/charts/line.js +419 -0
- package/src/charts/network.js +344 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +152 -0
- package/src/charts/regplot.js +364 -0
- package/src/charts/sankey.js +133 -0
- package/src/charts/scatter.js +136 -0
- package/src/charts/sunburst.js +135 -0
- package/src/charts/treemap.js +194 -0
- package/src/context.js +10 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +171 -0
- package/src/frame.js +157 -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 +152 -0
- package/src/geo/registry.js +31 -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 +271 -0
- package/src/infer.js +49 -0
- package/src/interact.js +362 -0
- package/src/lint.js +137 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +304 -0
- package/src/scale.js +171 -0
- package/src/schema.js +181 -0
- package/src/stats.js +246 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +224 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Red (grafo): nodos y conexiones con disposición por fuerzas, determinista.
|
|
2
|
+
// El tamaño del nodo es su peso (o su grado ponderado); el color, su grupo.
|
|
3
|
+
// La disposición se ajusta al área disponible para usar todo el espacio.
|
|
4
|
+
import { el, g, text, tip } from '../svg.js';
|
|
5
|
+
import { measure, truncate } from '../text.js';
|
|
6
|
+
import { makeFormat } from '../format.js';
|
|
7
|
+
import { colorMap, readable } from '../theme.js';
|
|
8
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
|
|
9
|
+
import { createFrame } from '../frame.js';
|
|
10
|
+
|
|
11
|
+
const pick = (fields, cands) => cands.find((c) => fields.includes(c));
|
|
12
|
+
|
|
13
|
+
/** Prepara nodos y aristas a partir del spec. */
|
|
14
|
+
export function prepareNetwork(spec) {
|
|
15
|
+
const data = ensureData(spec);
|
|
16
|
+
const fields = fieldsOf(data);
|
|
17
|
+
const from = spec.from ?? pick(fields, ['from', 'source', 'origen', 'desde']) ?? fields[0];
|
|
18
|
+
const to = spec.to ?? pick(fields, ['to', 'target', 'destino', 'hasta']) ?? fields[1];
|
|
19
|
+
requireField(data, from, 'from');
|
|
20
|
+
requireField(data, to, 'to');
|
|
21
|
+
const vf = spec.value ?? pick(fields, ['value', 'weight', 'peso', 'valor']) ?? fields.find((f) => f !== from && f !== to && fieldKind(data, f) === 'number');
|
|
22
|
+
if (spec.value) requireField(data, vf, 'value');
|
|
23
|
+
|
|
24
|
+
// Aristas: pares no dirigidos agregados (A–B y B–A se suman).
|
|
25
|
+
const emap = new Map();
|
|
26
|
+
for (const r of data) {
|
|
27
|
+
const a = r[from], b = r[to];
|
|
28
|
+
if (a === null || a === undefined || b === null || b === undefined) continue;
|
|
29
|
+
const s = String(a), t = String(b);
|
|
30
|
+
if (s === t) continue;
|
|
31
|
+
const w = vf ? num(r[vf]) : 1;
|
|
32
|
+
if (!(w > 0)) continue;
|
|
33
|
+
const key = s < t ? s + '\u0000' + t : t + '\u0000' + s;
|
|
34
|
+
if (!emap.has(key)) emap.set(key, { s: s < t ? s : t, t: s < t ? t : s, v: 0 });
|
|
35
|
+
emap.get(key).v += w;
|
|
36
|
+
}
|
|
37
|
+
const edges = [...emap.values()];
|
|
38
|
+
|
|
39
|
+
// Nodos: de la tabla `nodes` si viene; si no, de las aristas.
|
|
40
|
+
const nodes = new Map();
|
|
41
|
+
const add = (id) => { if (!nodes.has(id)) nodes.set(id, { id, label: id, group: null, size: null, deg: 0, wdeg: 0 }); return nodes.get(id); };
|
|
42
|
+
if (Array.isArray(spec.nodes) && spec.nodes.length) {
|
|
43
|
+
const nf = fieldsOf(spec.nodes);
|
|
44
|
+
const idF = spec.nodeId ?? pick(nf, ['id', 'name', 'nombre', 'node', 'nodo']) ?? nf[0];
|
|
45
|
+
const labF = spec.nodeLabel ?? pick(nf, ['label', 'etiqueta']) ?? idF;
|
|
46
|
+
const grF = spec.group ?? pick(nf, ['group', 'grupo', 'type', 'tipo', 'category', 'categoria']);
|
|
47
|
+
const szF = spec.size ?? pick(nf, ['size', 'value', 'valor', 'tamaño', 'weight']);
|
|
48
|
+
if (!nf.includes(idF)) throw new PlotError(`El campo "${idF}" (nodeId) no existe en nodes.`, `Campos disponibles: ${nf.join(', ')}`);
|
|
49
|
+
for (const r of spec.nodes) {
|
|
50
|
+
if (r[idF] === null || r[idF] === undefined) continue;
|
|
51
|
+
const n = add(String(r[idF]));
|
|
52
|
+
if (labF && r[labF] !== undefined) n.label = String(r[labF]);
|
|
53
|
+
if (grF && r[grF] !== undefined && r[grF] !== null) n.group = String(r[grF]);
|
|
54
|
+
if (szF && Number.isFinite(num(r[szF]))) n.size = num(r[szF]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const e of edges) {
|
|
58
|
+
const a = add(e.s), b = add(e.t);
|
|
59
|
+
a.deg++; b.deg++; a.wdeg += e.v; b.wdeg += e.v;
|
|
60
|
+
}
|
|
61
|
+
const list = [...nodes.values()];
|
|
62
|
+
for (const n of list) n.w = n.size ?? n.wdeg;
|
|
63
|
+
return { edges, nodes: list, byId: nodes, valueField: vf };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Disposición por fuerzas (velocity Verlet simplificado, al estilo d3-force). */
|
|
67
|
+
function forceLayout(nodes, edges, byId, { iterations = 320, spacing = 40, pin = null } = {}) {
|
|
68
|
+
const n = nodes.length;
|
|
69
|
+
// Posiciones iniciales en filotaxis (deterministas), agrupadas por grupo.
|
|
70
|
+
const order = [...nodes].sort((a, b) => String(a.group).localeCompare(String(b.group)) || b.w - a.w);
|
|
71
|
+
order.forEach((nd, i) => {
|
|
72
|
+
const r = spacing * 0.6 * Math.sqrt(i + 0.5), a = i * 2.399963229728653;
|
|
73
|
+
nd.x = r * Math.cos(a); nd.y = r * Math.sin(a); nd.vx = 0; nd.vy = 0;
|
|
74
|
+
});
|
|
75
|
+
// Nodo en foco: parte y se queda en el centro; el resto se acomoda a su alrededor.
|
|
76
|
+
if (pin) { const c = { x: pin.x, y: pin.y }; for (const nd of nodes) { nd.x -= c.x; nd.y -= c.y; } }
|
|
77
|
+
const links = edges.map((e) => ({ a: byId.get(e.s), b: byId.get(e.t), v: e.v }));
|
|
78
|
+
const maxV = Math.max(1, ...links.map((l) => l.v));
|
|
79
|
+
let alpha = 1;
|
|
80
|
+
const decay = 1 - Math.pow(0.001, 1 / iterations);
|
|
81
|
+
const charge = -spacing * spacing * 0.9;
|
|
82
|
+
for (let it = 0; it < iterations; it++) {
|
|
83
|
+
// Repulsión entre todos los pares (O(n²), suficiente hasta ~400 nodos).
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
85
|
+
const p = nodes[i];
|
|
86
|
+
for (let j = i + 1; j < n; j++) {
|
|
87
|
+
const q = nodes[j];
|
|
88
|
+
let dx = q.x - p.x, dy = q.y - p.y;
|
|
89
|
+
let d2 = dx * dx + dy * dy;
|
|
90
|
+
if (d2 < 1e-6) { dx = ((i * 7 + j * 13) % 11) / 11 - 0.5; dy = ((i * 5 + j * 3) % 7) / 7 - 0.5; d2 = dx * dx + dy * dy; }
|
|
91
|
+
const f = (charge * alpha) / Math.max(d2, spacing * 0.25);
|
|
92
|
+
const d = Math.sqrt(d2);
|
|
93
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
94
|
+
p.vx += fx; p.vy += fy; q.vx -= fx; q.vy -= fy;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Resortes en las conexiones (más fuertes si el peso es mayor).
|
|
98
|
+
for (const l of links) {
|
|
99
|
+
const dx = l.b.x + l.b.vx - l.a.x - l.a.vx, dy = l.b.y + l.b.vy - l.a.y - l.a.vy;
|
|
100
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1e-6;
|
|
101
|
+
const target = spacing * (1.05 - 0.35 * Math.sqrt(l.v / maxV)) + (l.a.r + l.b.r) * 0.8;
|
|
102
|
+
const k = (alpha * (0.35 + 0.65 * Math.sqrt(l.v / maxV))) / Math.max(1, Math.min(l.a.deg, l.b.deg));
|
|
103
|
+
const f = ((d - target) / d) * k;
|
|
104
|
+
const bias = l.a.deg / (l.a.deg + l.b.deg);
|
|
105
|
+
l.b.vx -= dx * f * bias; l.b.vy -= dy * f * bias;
|
|
106
|
+
l.a.vx += dx * f * (1 - bias); l.a.vy += dy * f * (1 - bias);
|
|
107
|
+
}
|
|
108
|
+
// Gravedad suave hacia el centro (mantiene juntos los componentes sueltos).
|
|
109
|
+
for (const p of nodes) { p.vx -= p.x * 0.02 * alpha; p.vy -= p.y * 0.02 * alpha; }
|
|
110
|
+
for (const p of nodes) { p.vx *= 0.6; p.vy *= 0.6; p.x += p.vx; p.y += p.vy; }
|
|
111
|
+
if (pin) { pin.x = 0; pin.y = 0; pin.vx = 0; pin.vy = 0; }
|
|
112
|
+
// Colisión por radio.
|
|
113
|
+
for (let pass = 0; pass < 2; pass++)
|
|
114
|
+
for (let i = 0; i < n; i++)
|
|
115
|
+
for (let j = i + 1; j < n; j++) {
|
|
116
|
+
const p = nodes[i], q = nodes[j];
|
|
117
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
118
|
+
const min = p.r + q.r + 3;
|
|
119
|
+
const d2 = dx * dx + dy * dy;
|
|
120
|
+
if (d2 < min * min) {
|
|
121
|
+
const d = Math.sqrt(d2) || 1e-6;
|
|
122
|
+
const s = ((min - d) / d) * 0.5;
|
|
123
|
+
p.x -= dx * s; p.y -= dy * s; q.x += dx * s; q.y += dy * s;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (pin) { pin.x = 0; pin.y = 0; }
|
|
127
|
+
alpha -= alpha * decay;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Círculo ordenado por grupo y tamaño. */
|
|
132
|
+
function circleLayout(nodes, groups) {
|
|
133
|
+
const order = [...nodes].sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group) || b.w - a.w);
|
|
134
|
+
const n = order.length;
|
|
135
|
+
order.forEach((nd, i) => {
|
|
136
|
+
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
|
|
137
|
+
nd.x = Math.cos(a); nd.y = Math.sin(a); nd.angle = a;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function network(spec, theme) {
|
|
142
|
+
const locale = spec.locale || 'es';
|
|
143
|
+
const en = String(locale).startsWith('en');
|
|
144
|
+
const { edges, nodes, byId } = prepareNetwork(spec);
|
|
145
|
+
if (!nodes.length) throw new PlotError('La red no tiene conexiones válidas.', 'Entrega filas con { from, to, value }.');
|
|
146
|
+
const layout = spec.layout === 'circle' ? 'circle' : 'force';
|
|
147
|
+
|
|
148
|
+
// Grupos en orden de peso total (color estable por entidad).
|
|
149
|
+
const gtot = new Map();
|
|
150
|
+
for (const n of nodes) { const k = n.group ?? ''; gtot.set(k, (gtot.get(k) || 0) + n.w); }
|
|
151
|
+
const groups = [...gtot.keys()].sort((a, b) => gtot.get(b) - gtot.get(a));
|
|
152
|
+
const hasGroups = groups.length > 1 || (groups.length === 1 && groups[0] !== '');
|
|
153
|
+
const cmap = colorMap(groups, theme, spec.colors);
|
|
154
|
+
const colorOf = (n) => (spec.colors && spec.colors[n.id]) || (hasGroups ? cmap.get(n.group ?? '') : theme.categorical[0]);
|
|
155
|
+
const fmt = makeFormat(spec.format, locale, nodes.map((n) => n.w));
|
|
156
|
+
const fmtE = makeFormat(spec.format, locale, edges.map((e) => e.v));
|
|
157
|
+
|
|
158
|
+
const legend = hasGroups && spec.legend !== false ? groups.map((k) => ({ label: k || (en ? 'No group' : 'Sin grupo'), color: cmap.get(k) })) : [];
|
|
159
|
+
const W = spec.width || 720;
|
|
160
|
+
const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(W * 1.05 + 150) : 520 });
|
|
161
|
+
const area = frame.layout(legend);
|
|
162
|
+
|
|
163
|
+
// Radios: área ∝ peso, entre 4 y 22 px (más chicos si hay muchos nodos).
|
|
164
|
+
const maxW = Math.max(...nodes.map((n) => n.w), 1e-9);
|
|
165
|
+
const rMax = spec.maxRadius ?? Math.max(9, Math.min(22, Math.sqrt((area.w * area.h) / nodes.length) * 0.32));
|
|
166
|
+
const rMin = Math.min(4, rMax / 2);
|
|
167
|
+
for (const n of nodes) n.r = rMin + (rMax - rMin) * Math.sqrt(Math.max(0, n.w) / maxW);
|
|
168
|
+
|
|
169
|
+
// Etiquetas: las N mayores (o una lista).
|
|
170
|
+
const labelSize = (theme.type?.label ?? 12) - 0.5;
|
|
171
|
+
let labelSet;
|
|
172
|
+
if (Array.isArray(spec.labels)) labelSet = new Set(spec.labels.map(String));
|
|
173
|
+
else if (spec.labels === false) labelSet = new Set();
|
|
174
|
+
else {
|
|
175
|
+
const k = typeof spec.labels === 'number' ? spec.labels : nodes.length <= 14 ? nodes.length : layout === 'circle' ? nodes.length : 10;
|
|
176
|
+
labelSet = new Set([...nodes].sort((a, b) => b.w - a.w).slice(0, k).map((n) => n.id));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Foco (clic en un nodo): va al centro y se destacan sus vecinos, con nombre.
|
|
180
|
+
const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) : null;
|
|
181
|
+
if (focusNode) {
|
|
182
|
+
labelSet.add(focusNode.id);
|
|
183
|
+
for (const e of edges) { if (e.s === focusNode.id) labelSet.add(e.t); if (e.t === focusNode.id) labelSet.add(e.s); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Disposición + ajuste al área.
|
|
187
|
+
const body = [];
|
|
188
|
+
if (layout === 'circle') {
|
|
189
|
+
circleLayout(nodes, groups);
|
|
190
|
+
const lwFull = Math.max(0, ...nodes.filter((n) => labelSet.has(n.id)).map((n) => measure(n.label, labelSize, 600)));
|
|
191
|
+
const lw = Math.min(area.w * (area.w < 480 ? 0.2 : 0.28), lwFull) + 10;
|
|
192
|
+
const rGuess = Math.min(rMax, 10);
|
|
193
|
+
const R = Math.max(40, Math.min(area.w / 2 - lw - rGuess, area.h / 2 - rGuess - 16));
|
|
194
|
+
// En el círculo, el radio de cada nodo no puede superar su espacio en la circunferencia.
|
|
195
|
+
const rCap = Math.max(3.5, ((Math.PI * 2 * R) / nodes.length) * 0.4);
|
|
196
|
+
if (rCap < rMax) for (const n of nodes) n.r = Math.max(3, n.r * (rCap / rMax));
|
|
197
|
+
const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
|
|
198
|
+
for (const n of nodes) { n.px = cx + n.x * R; n.py = cy + n.y * R; }
|
|
199
|
+
nodes.cx = cx; nodes.cy = cy; nodes.R = R; nodes.lw = lw;
|
|
200
|
+
} else {
|
|
201
|
+
const spacing = Math.max(26, Math.sqrt((area.w * area.h) / nodes.length) * 0.75);
|
|
202
|
+
forceLayout(nodes, edges, byId, { spacing, pin: focusNode, iterations: spec.iterations ?? (nodes.length > 200 ? 200 : 320) });
|
|
203
|
+
// Rota la red para que su eje principal siga el lado largo del área (sin foco).
|
|
204
|
+
if (!focusNode) {
|
|
205
|
+
const mx = nodes.reduce((a, n) => a + n.x, 0) / nodes.length, my = nodes.reduce((a, n) => a + n.y, 0) / nodes.length;
|
|
206
|
+
let sxx = 0, syy = 0, sxy = 0;
|
|
207
|
+
for (const n of nodes) { const dx = n.x - mx, dy = n.y - my; sxx += dx * dx; syy += dy * dy; sxy += dx * dy; }
|
|
208
|
+
let th = 0.5 * Math.atan2(2 * sxy, sxx - syy); // ángulo del eje principal
|
|
209
|
+
if (area.h > area.w) th -= Math.PI / 2;
|
|
210
|
+
const c = Math.cos(-th), si = Math.sin(-th);
|
|
211
|
+
for (const n of nodes) { const dx = n.x - mx, dy = n.y - my; n.x = dx * c - dy * si; n.y = dx * si + dy * c; }
|
|
212
|
+
}
|
|
213
|
+
const x0 = Math.min(...nodes.map((n) => n.x - n.r)), x1 = Math.max(...nodes.map((n) => n.x + n.r));
|
|
214
|
+
const y0 = Math.min(...nodes.map((n) => n.y - n.r)), y1 = Math.max(...nodes.map((n) => n.y + n.r));
|
|
215
|
+
const pad = rMax + 4;
|
|
216
|
+
const aw = Math.max(10, area.w - pad * 2), ah = Math.max(10, area.h - pad * 2);
|
|
217
|
+
let sx = aw / Math.max(1e-6, x1 - x0), sy = ah / Math.max(1e-6, y1 - y0);
|
|
218
|
+
// Se permite estirar, pero sin deformar demasiado la red.
|
|
219
|
+
const ratio = sx / sy;
|
|
220
|
+
const lim = 2.4;
|
|
221
|
+
if (ratio > lim) sx = sy * lim; else if (ratio < 1 / lim) sy = sx * lim;
|
|
222
|
+
if (focusNode) {
|
|
223
|
+
// Escala simétrica alrededor del nodo en foco, que queda en el centro exacto.
|
|
224
|
+
const ex = Math.max(...nodes.map((n) => Math.abs(n.x) + n.r), 1e-6), ey = Math.max(...nodes.map((n) => Math.abs(n.y) + n.r), 1e-6);
|
|
225
|
+
let fx = aw / 2 / ex, fy = ah / 2 / ey;
|
|
226
|
+
if (fx / fy > lim) fx = fy * lim; else if (fy / fx > lim) fy = fx * lim;
|
|
227
|
+
const ccx = area.x + area.w / 2, ccy = area.y + area.h / 2;
|
|
228
|
+
for (const n of nodes) { n.px = ccx + n.x * fx; n.py = ccy + n.y * fy; }
|
|
229
|
+
} else {
|
|
230
|
+
const ox = area.x + pad + (aw - (x1 - x0) * sx) / 2, oy = area.y + pad + (ah - (y1 - y0) * sy) / 2;
|
|
231
|
+
for (const n of nodes) { n.px = ox + (n.x - x0) * sx; n.py = oy + (n.y - y0) * sy; }
|
|
232
|
+
}
|
|
233
|
+
// Tras escalar, separa nodos que quedaron encimados.
|
|
234
|
+
for (let pass = 0; pass < 6; pass++)
|
|
235
|
+
for (let i = 0; i < nodes.length; i++)
|
|
236
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
237
|
+
const p = nodes[i], q = nodes[j];
|
|
238
|
+
const dx = q.px - p.px, dy = q.py - p.py, min = p.r + q.r + 2, d2 = dx * dx + dy * dy;
|
|
239
|
+
if (d2 < min * min) { const d = Math.sqrt(d2) || 1e-6, s = ((min - d) / d) * 0.5; p.px -= dx * s; p.py -= dy * s; q.px += dx * s; q.py += dy * s; }
|
|
240
|
+
}
|
|
241
|
+
for (const n of nodes) {
|
|
242
|
+
n.px = Math.max(area.x + n.r + 1, Math.min(area.x + area.w - n.r - 1, n.px));
|
|
243
|
+
n.py = Math.max(area.y + n.r + 1, Math.min(area.y + area.h - n.r - 1, n.py));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Foco: nodos destacados y sus vecinos.
|
|
248
|
+
const hl = focusNode ? new Set([focusNode.id]) : spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
249
|
+
const near = new Set();
|
|
250
|
+
if (hl) { for (const id of hl) near.add(id); for (const e of edges) { if (hl.has(e.s)) near.add(e.t); if (hl.has(e.t)) near.add(e.s); } }
|
|
251
|
+
const on = (n) => !hl || near.has(n.id);
|
|
252
|
+
|
|
253
|
+
// Conexiones: líneas apenas curvas, ancho ∝ √peso.
|
|
254
|
+
const maxE = Math.max(...edges.map((e) => e.v), 1e-9);
|
|
255
|
+
const manyLinks = edges.length > 150;
|
|
256
|
+
const linkG = [];
|
|
257
|
+
for (const e of [...edges].sort((a, b) => a.v - b.v)) {
|
|
258
|
+
const a = byId.get(e.s), b = byId.get(e.t);
|
|
259
|
+
const w = 0.75 + 3.25 * Math.sqrt(e.v / maxE);
|
|
260
|
+
let d;
|
|
261
|
+
if (layout === 'circle') {
|
|
262
|
+
const mx = (a.px + b.px) / 2, my = (a.py + b.py) / 2;
|
|
263
|
+
const qx = mx + (nodes.cx - mx) * 0.55, qy = my + (nodes.cy - my) * 0.55;
|
|
264
|
+
d = `M${a.px.toFixed(1)},${a.py.toFixed(1)}Q${qx.toFixed(1)},${qy.toFixed(1)} ${b.px.toFixed(1)},${b.py.toFixed(1)}`;
|
|
265
|
+
} else {
|
|
266
|
+
const dx = b.px - a.px, dy = b.py - a.py, len = Math.hypot(dx, dy) || 1;
|
|
267
|
+
const bend = Math.min(18, len * 0.08);
|
|
268
|
+
const qx = (a.px + b.px) / 2 - (dy / len) * bend, qy = (a.py + b.py) / 2 + (dx / len) * bend;
|
|
269
|
+
d = `M${a.px.toFixed(1)},${a.py.toFixed(1)}Q${qx.toFixed(1)},${qy.toFixed(1)} ${b.px.toFixed(1)},${b.py.toFixed(1)}`;
|
|
270
|
+
}
|
|
271
|
+
const active = hl ? hl.has(e.s) || hl.has(e.t) : true;
|
|
272
|
+
linkG.push(el('path', {
|
|
273
|
+
class: 'pa-m', d, fill: 'none', stroke: active && hl ? theme.ink : theme.muted,
|
|
274
|
+
strokeOpacity: hl ? (active ? 0.55 : 0.08) : manyLinks ? 0.22 : 0.38, strokeWidth: w, strokeLinecap: 'round',
|
|
275
|
+
'data-tip': tip(`${a.label} — ${b.label}`, fmtE(e.v)),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
body.push(g({ class: 'pa-links' }, linkG));
|
|
279
|
+
|
|
280
|
+
// Nodos: grandes debajo, chicos encima; anillo del color de fondo.
|
|
281
|
+
const nodeG = [];
|
|
282
|
+
for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r)) {
|
|
283
|
+
const lines = [n.label];
|
|
284
|
+
if (hasGroups && n.group) lines.push(n.group);
|
|
285
|
+
lines.push(`${en ? 'Connections' : 'Conexiones'}: ${n.deg}`);
|
|
286
|
+
lines.push(`${n.size !== null ? (en ? 'Size' : 'Tamaño') : en ? 'Weighted degree' : 'Grado ponderado'}: ${fmt(n.w)}`);
|
|
287
|
+
const isF = focusNode && n.id === focusNode.id;
|
|
288
|
+
nodeG.push(el('circle', { class: 'pa-m', cx: n.px, cy: n.py, r: n.r, fill: on(n) ? colorOf(n) : theme.dim, stroke: isF ? theme.ink : theme.surface, strokeWidth: isF ? 2.5 : theme.marks?.ring ? Math.min(2, theme.marks.ring) * 0.75 : 1.5, 'data-tip': tip(...lines), 'data-node': n.id, style: 'cursor:pointer' }));
|
|
289
|
+
}
|
|
290
|
+
body.push(g({ class: 'pa-nodes' }, nodeG));
|
|
291
|
+
|
|
292
|
+
// Etiquetas con halo, sin choques: derecha → izquierda → arriba → abajo.
|
|
293
|
+
const placed = nodes.map((n) => [n.px - n.r, n.py - n.r, n.px + n.r, n.py + n.r, n.id, n.r]);
|
|
294
|
+
const labG = [];
|
|
295
|
+
// En foco: primero el nodo central, luego sus vecinos; los nodos atenuados no bloquean etiquetas.
|
|
296
|
+
const lbls = nodes.filter((n) => labelSet.has(n.id) && (on(n) || hl?.has(n.id))).sort((a, b) => (hl ? (hl.has(b.id) - hl.has(a.id)) : 0) || b.w - a.w);
|
|
297
|
+
for (const n of lbls) {
|
|
298
|
+
const strong = hl ? hl.has(n.id) : true;
|
|
299
|
+
const weight = strong ? 600 : 500;
|
|
300
|
+
const label = truncate(n.label, layout === 'circle' ? nodes.lw - 8 : 150, labelSize, weight);
|
|
301
|
+
const w = measure(label, labelSize, weight), h = labelSize + 2;
|
|
302
|
+
let cands;
|
|
303
|
+
if (layout === 'circle') {
|
|
304
|
+
const ca = Math.cos(n.angle), sa = Math.sin(n.angle);
|
|
305
|
+
const lx = n.px + ca * (n.r + 6), ly = n.py + sa * (n.r + 6) + labelSize * 0.35;
|
|
306
|
+
const anchor = Math.abs(ca) < 0.2 ? 'middle' : ca > 0 ? 'start' : 'end';
|
|
307
|
+
const yy = Math.abs(ca) < 0.2 ? (sa < 0 ? n.py - n.r - 6 : n.py + n.r + labelSize + 2) : ly;
|
|
308
|
+
cands = [[lx, yy, anchor]];
|
|
309
|
+
} else {
|
|
310
|
+
cands = [
|
|
311
|
+
[n.px + n.r + 4, n.py + labelSize * 0.35, 'start'],
|
|
312
|
+
[n.px - n.r - 4, n.py + labelSize * 0.35, 'end'],
|
|
313
|
+
[n.px, n.py - n.r - 4, 'middle'],
|
|
314
|
+
[n.px, n.py + n.r + labelSize + 1, 'middle'],
|
|
315
|
+
[n.px + n.r * 0.75 + 3, n.py - n.r * 0.75 - 2, 'start'],
|
|
316
|
+
[n.px - n.r * 0.75 - 3, n.py - n.r * 0.75 - 2, 'end'],
|
|
317
|
+
[n.px + n.r * 0.75 + 3, n.py + n.r * 0.75 + labelSize, 'start'],
|
|
318
|
+
[n.px - n.r * 0.75 - 3, n.py + n.r * 0.75 + labelSize, 'end'],
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
for (const [lx, ly, anchor] of cands) {
|
|
322
|
+
const bx0 = anchor === 'start' ? lx : anchor === 'end' ? lx - w : lx - w / 2;
|
|
323
|
+
const box = [bx0 - 1, ly - h + 2, bx0 + w + 1, ly + 3];
|
|
324
|
+
if (box[0] < area.x - 2 || box[2] > area.x + area.w + 2 || box[1] < area.y - 4 || box[3] > area.y + area.h + 4) continue;
|
|
325
|
+
// No pisa otras etiquetas ni nodos grandes; sobre nodos chicos sí (el halo la mantiene legible).
|
|
326
|
+
const hit = placed.some((b) => b[4] !== n.id && (b[4] === null || (b[5] >= 7 && (!hl || near.has(b[4])))) && !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
|
|
327
|
+
const must = hl && hl.has(n.id) && lx === cands[cands.length - 1][0] && ly === cands[cands.length - 1][1];
|
|
328
|
+
if (hit && layout !== 'circle' && !must) continue;
|
|
329
|
+
placed.push([...box, null, 0]);
|
|
330
|
+
labG.push(el('text', {
|
|
331
|
+
x: lx, y: ly, textAnchor: anchor, fill: hasGroups ? readable(colorOf(n), theme) : theme.ink, fontSize: labelSize, fontWeight: weight,
|
|
332
|
+
stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round',
|
|
333
|
+
}, label.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c])));
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
body.push(g({ class: 'pa-labels' }, labG));
|
|
338
|
+
|
|
339
|
+
const top = [...nodes].sort((a, b) => b.deg - a.deg || b.w - a.w).slice(0, 3);
|
|
340
|
+
const desc = en
|
|
341
|
+
? `Network of ${nodes.length} nodes and ${edges.length} connections. Most connected: ${top.map((n) => `${n.label} (${n.deg})`).join(', ')}.`
|
|
342
|
+
: `Red de ${nodes.length} nodos y ${edges.length} conexiones. Más conectados: ${top.map((n) => `${n.label} (${n.deg})`).join(', ')}.`;
|
|
343
|
+
return frame.finish(body, { desc, type: 'network' });
|
|
344
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Pirámide de población: barras horizontales espalda con espalda desde un eje
|
|
2
|
+
// central, con los tramos de edad en la columna del medio (sin ejes repetidos).
|
|
3
|
+
// Opcional: comparación con otro periodo como contorno escalonado.
|
|
4
|
+
import { el, g, text, tip, barPath } from '../svg.js';
|
|
5
|
+
import { measure } from '../text.js';
|
|
6
|
+
import { makeFormat, formatNumber } from '../format.js';
|
|
7
|
+
import { niceStep, linear } from '../scale.js';
|
|
8
|
+
import { colorMap, readable } from '../theme.js';
|
|
9
|
+
import { ensureData, requireField, num, uniq, fieldsOf, fieldKind, PlotError } from '../data.js';
|
|
10
|
+
import { createFrame } from '../frame.js';
|
|
11
|
+
|
|
12
|
+
const T = (en, es, locale) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
13
|
+
const lead = (s) => { const m = /^\s*(\d+)/.exec(String(s)); return m ? +m[1] : NaN; };
|
|
14
|
+
|
|
15
|
+
export function pyramid(spec, theme) {
|
|
16
|
+
const locale = spec.locale || 'es';
|
|
17
|
+
const data = ensureData(spec);
|
|
18
|
+
const fields = fieldsOf(data);
|
|
19
|
+
const cats = fields.filter((f) => fieldKind(data, f) !== 'number' || /edad|age|grupo|tramo/i.test(f));
|
|
20
|
+
const nums = fields.filter((f) => fieldKind(data, f) === 'number');
|
|
21
|
+
const ageF = spec.age ?? spec.y ?? cats.find((f) => /edad|age|tramo|grupo/i.test(f)) ?? cats[0];
|
|
22
|
+
const sideF = spec.sex ?? spec.series ?? spec.hue ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null) ?? cats.find((f) => f !== ageF && /sexo|sex|gender|género|genero/i.test(f)) ?? cats.find((f) => f !== ageF);
|
|
23
|
+
const valF = spec.value ?? spec.x ?? nums.find((f) => f !== ageF && f !== sideF);
|
|
24
|
+
requireField(data, ageF, 'age');
|
|
25
|
+
requireField(data, sideF, 'series');
|
|
26
|
+
requireField(data, valF, 'value');
|
|
27
|
+
|
|
28
|
+
// Comparación: otra tabla (compareData) o un valor de un campo (compare + compareField).
|
|
29
|
+
// Con `time` el núcleo ya filtró los datos y entrega el periodo de comparación en _prev.
|
|
30
|
+
let rows = data, cmpRows = spec.compareData ? ensureData({ data: spec.compareData }) : spec._prev && spec.compare !== undefined && spec.compare !== false ? spec._prev.data : null;
|
|
31
|
+
const cmpField = spec.compareField ?? spec.time;
|
|
32
|
+
if (!cmpRows && spec.compare !== undefined && cmpField && fields.includes(cmpField)) {
|
|
33
|
+
cmpRows = data.filter((r) => String(r[cmpField]) === String(spec.compare));
|
|
34
|
+
rows = data.filter((r) => String(r[cmpField]) !== String(spec.compare));
|
|
35
|
+
const periods = uniq(rows.map((r) => String(r[cmpField])));
|
|
36
|
+
if (periods.length > 1) { const last = spec.current ?? periods.sort().at(-1); rows = rows.filter((r) => String(r[cmpField]) === String(last)); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sides = spec.sides ?? uniq(rows.map((r) => String(r[sideF])));
|
|
40
|
+
if (sides.length !== 2) throw new PlotError(T(`A population pyramid needs exactly 2 groups in "${sideF}" (found ${sides.length}).`, `La pirámide necesita exactamente 2 grupos en "${sideF}" (hay ${sides.length}).`, locale), T('Filter the data to two groups, e.g. men and women.', 'Filtra los datos a dos grupos, por ejemplo hombres y mujeres.', locale));
|
|
41
|
+
let ages = uniq([...rows, ...(cmpRows || [])].map((r) => String(r[ageF])));
|
|
42
|
+
if (ages.every((a) => Number.isFinite(lead(a)))) ages.sort((a, b) => lead(a) - lead(b));
|
|
43
|
+
if (spec.ageOrder) ages = spec.ageOrder.map(String);
|
|
44
|
+
const top = [...ages].reverse(); // los mayores arriba
|
|
45
|
+
|
|
46
|
+
const sumBy = (rs) => {
|
|
47
|
+
const m = new Map();
|
|
48
|
+
for (const r of rs) {
|
|
49
|
+
const k = String(r[ageF]) + '\u0000' + String(r[sideF]);
|
|
50
|
+
const v = num(r[valF]);
|
|
51
|
+
if (Number.isFinite(v)) m.set(k, (m.get(k) || 0) + Math.abs(v));
|
|
52
|
+
}
|
|
53
|
+
return m;
|
|
54
|
+
};
|
|
55
|
+
const cur = sumBy(rows), cmp = cmpRows ? sumBy(cmpRows) : null;
|
|
56
|
+
const total = [...cur.values()].reduce((a, b) => a + b, 0) || 1;
|
|
57
|
+
const cmpTotal = cmp ? [...cmp.values()].reduce((a, b) => a + b, 0) || 1 : 1;
|
|
58
|
+
const pct = spec.percent === true || spec.format === 'percent' || spec.format?.type === 'percent';
|
|
59
|
+
const val = (m, a, s, tot) => { const v = m.get(a + '\u0000' + s) || 0; return pct ? v / tot : v; };
|
|
60
|
+
const V = (a, s) => val(cur, a, s, total);
|
|
61
|
+
const C = (a, s) => (cmp ? val(cmp, a, s, cmpTotal) : NaN);
|
|
62
|
+
|
|
63
|
+
const allV = ages.flatMap((a) => sides.flatMap((s) => [V(a, s), C(a, s)])).filter(Number.isFinite);
|
|
64
|
+
const vmax = Math.max(...allV, 1e-9);
|
|
65
|
+
const fmt = pct ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale) : makeFormat(spec.format, locale, allV);
|
|
66
|
+
const share = (v) => formatNumber((v / total) * 100, (v / total) * 100 < 10 ? 1 : 0, locale, true) + '%';
|
|
67
|
+
|
|
68
|
+
const cmap = colorMap(sides, theme, spec.colors);
|
|
69
|
+
const colorOf = (s) => cmap.get(s);
|
|
70
|
+
const cmpLabel = spec.compareLabel ?? spec._prev?.label ?? (spec.compare !== undefined ? String(spec.compare) : T('Comparison', 'Comparación', locale));
|
|
71
|
+
const legend = cmp && spec.legend !== false ? [{ label: cmpLabel, color: theme.ink, shape: 'line' }] : [];
|
|
72
|
+
|
|
73
|
+
const frame = createFrame(spec, theme, { height: 420 });
|
|
74
|
+
let area = frame.layout(legend);
|
|
75
|
+
const n = ages.length;
|
|
76
|
+
const headerH = 24, axisH = 24;
|
|
77
|
+
const step = spec.barHeight ? spec.barHeight + 2 : Math.max(14, Math.min(26, frame.width < 480 ? 17 : 21));
|
|
78
|
+
if (spec.height === undefined) {
|
|
79
|
+
frame.setHeight(area.y + headerH + n * step + axisH + frame.chrome);
|
|
80
|
+
area = { ...area, h: frame.bottom - area.y };
|
|
81
|
+
}
|
|
82
|
+
const ageSize = theme.type?.axis ?? 11.5;
|
|
83
|
+
const centerW = Math.max(...ages.map((a) => measure(a, ageSize, 500))) + 18;
|
|
84
|
+
const labelsOn = spec.labels === true;
|
|
85
|
+
const sample = fmt(vmax);
|
|
86
|
+
const endPad = labelsOn ? measure(sample, 11, 600) + 8 : 4;
|
|
87
|
+
const halfW = (area.w - centerW) / 2;
|
|
88
|
+
const cx0 = area.x + halfW, cx1 = cx0 + centerW; // bordes de la columna central
|
|
89
|
+
const plotTop = area.y + headerH;
|
|
90
|
+
const plotH = area.h - headerH - axisH;
|
|
91
|
+
const st = plotH / n;
|
|
92
|
+
const bh = Math.max(4, Math.min(spec.barHeight ?? 24, st - 2));
|
|
93
|
+
// Escala ajustada al dato (sin aire vacío), marcas "bonitas" dentro de ella.
|
|
94
|
+
const nd = { step: niceStep(vmax, Math.max(2, Math.round((halfW - endPad) / 85))) };
|
|
95
|
+
const dmax = spec.xDomain ? spec.xDomain[1] : vmax * 1.02;
|
|
96
|
+
const len = linear(0, dmax, 0, halfW - endPad);
|
|
97
|
+
const yOf = (i) => plotTop + i * st + (st - bh) / 2;
|
|
98
|
+
|
|
99
|
+
const body = [];
|
|
100
|
+
// Grilla vertical simétrica y marcas absolutas abajo.
|
|
101
|
+
const tf = fmt.tick(nd.step);
|
|
102
|
+
const tsize = theme.type?.axis ?? 11.5;
|
|
103
|
+
const ticks = len.ticks(nd.step).filter((t) => t <= dmax + 1e-9 && len(t) + measure(tf(t), tsize) / 2 <= halfW + 2);
|
|
104
|
+
const gridParts = [];
|
|
105
|
+
for (const t of ticks) {
|
|
106
|
+
for (const [x, anchor] of [[cx0 - len(t), 'middle'], [cx1 + len(t), 'middle']]) {
|
|
107
|
+
const px = Math.round(x) + 0.5;
|
|
108
|
+
gridParts.push(el('line', { x1: px, x2: px, y1: plotTop - 4, y2: plotTop + plotH, stroke: t === 0 ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
|
|
109
|
+
if (t === 0 && anchor) continue;
|
|
110
|
+
gridParts.push(text(px, plotTop + plotH + 16, tf(t), { fill: theme.muted, fontSize: theme.type?.axis ?? 11.5, textAnchor: anchor }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
body.push(g({ class: 'pa-grid' }, gridParts));
|
|
114
|
+
|
|
115
|
+
// Encabezados: nombre de cada lado en su color (legible) y su participación.
|
|
116
|
+
const sideTot = (s) => ages.reduce((a, ag) => a + (cur.get(ag + '\u0000' + s) || 0), 0);
|
|
117
|
+
sides.forEach((s, k) => {
|
|
118
|
+
const lab = s;
|
|
119
|
+
const sh = share(sideTot(s)) ;
|
|
120
|
+
const hx = k === 0 ? cx0 - 2 : cx1 + 2;
|
|
121
|
+
const anchor = k === 0 ? 'end' : 'start';
|
|
122
|
+
const tw = measure(lab, 13, 600);
|
|
123
|
+
body.push(text(hx, area.y + 14, lab, { fill: readable(colorOf(s), theme), fontSize: 13, fontWeight: 600, textAnchor: anchor }));
|
|
124
|
+
body.push(text(k === 0 ? hx - tw - 6 : hx + tw + 6, area.y + 14, sh, { fill: theme.muted, fontSize: 12, textAnchor: anchor }));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Barras
|
|
128
|
+
top.forEach((a, i) => {
|
|
129
|
+
const y = yOf(i);
|
|
130
|
+
// Etiqueta de edad en la columna central.
|
|
131
|
+
body.push(text((cx0 + cx1) / 2, y + bh / 2 + ageSize * 0.36, a, { fill: theme.lede, fontSize: ageSize, fontWeight: 500, textAnchor: 'middle' }));
|
|
132
|
+
sides.forEach((s, k) => {
|
|
133
|
+
const v = V(a, s);
|
|
134
|
+
const w = len(Math.min(v, dmax));
|
|
135
|
+
const c = colorOf(s);
|
|
136
|
+
const x = k === 0 ? cx0 - w : cx1;
|
|
137
|
+
const d = barPath(x, y, w, bh, k === 0 ? 'left' : 'right', Math.min(3.5, bh / 2));
|
|
138
|
+
const cv = C(a, s);
|
|
139
|
+
const lines = [`${a} · ${s}`, fmt(v) + (pct ? '' : ` · ${share(v)}`)];
|
|
140
|
+
if (Number.isFinite(cv)) lines.push(`${cmpLabel}: ${fmt(cv)}`);
|
|
141
|
+
const parts = [el('rect', { x: k === 0 ? area.x : cx1, y: plotTop + i * st, width: halfW, height: st, fill: 'transparent' })];
|
|
142
|
+
if (d) parts.push(el('path', { d, fill: c }));
|
|
143
|
+
if (labelsOn && v > 0) {
|
|
144
|
+
const lab = fmt(v);
|
|
145
|
+
parts.push(text(k === 0 ? x - 5 : x + w + 5, y + bh / 2 + 4, lab, { fill: theme.lede, fontSize: 11, fontWeight: 500, textAnchor: k === 0 ? 'end' : 'start' }));
|
|
146
|
+
}
|
|
147
|
+
body.push(g({ class: 'pa-m', 'data-tip': tip(...lines) }, parts));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Comparación: contorno escalonado por lado.
|
|
152
|
+
if (cmp) {
|
|
153
|
+
sides.forEach((s, k) => {
|
|
154
|
+
let d = '';
|
|
155
|
+
top.forEach((a, i) => {
|
|
156
|
+
const cv = C(a, s);
|
|
157
|
+
if (!Number.isFinite(cv)) return;
|
|
158
|
+
const x = k === 0 ? cx0 - len(Math.min(cv, dmax)) : cx1 + len(Math.min(cv, dmax));
|
|
159
|
+
const y0 = plotTop + i * st, y1 = y0 + st;
|
|
160
|
+
d += (d ? `L${x.toFixed(1)},${y0.toFixed(1)}` : `M${(k === 0 ? cx0 : cx1).toFixed(1)},${y0.toFixed(1)}L${x.toFixed(1)},${y0.toFixed(1)}`) + `L${x.toFixed(1)},${y1.toFixed(1)}`;
|
|
161
|
+
});
|
|
162
|
+
if (d) {
|
|
163
|
+
d += `L${(k === 0 ? cx0 : cx1).toFixed(1)},${(plotTop + n * st).toFixed(1)}`;
|
|
164
|
+
body.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', opacity: 0.85, class: 'pa-compare' }));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Descripción
|
|
170
|
+
const en = String(locale).startsWith('en');
|
|
171
|
+
let big = null;
|
|
172
|
+
for (const a of ages) for (const s of sides) { const v = cur.get(a + '\u0000' + s) || 0; if (!big || v > big[2]) big = [a, s, v]; }
|
|
173
|
+
const desc = (en ? 'Population pyramid. ' : 'Pirámide de población. ')
|
|
174
|
+
+ sides.map((s) => `${s} ${share(sideTot(s))}`).join(', ') + '. '
|
|
175
|
+
+ (big ? (en ? `Largest group: ${big[0]} (${big[1]}), ${fmt(pct ? big[2] / total : big[2])}.` : `Grupo más numeroso: ${big[0]} (${big[1]}), ${fmt(pct ? big[2] / total : big[2])}.`) : '');
|
|
176
|
+
return frame.finish(body, { desc, type: 'pyramid' });
|
|
177
|
+
}
|