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/network.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
// Red (grafo): nodos y conexiones con disposición
|
|
1
|
+
// Red (grafo): nodos y conexiones, con disposición a elección y determinista.
|
|
2
2
|
// El tamaño del nodo es su peso (o su grado ponderado); el color, su grupo.
|
|
3
|
-
//
|
|
4
|
-
|
|
3
|
+
// Cada disposición llena coordenadas propias; luego un mismo ajuste al área y
|
|
4
|
+
// una misma pasada contra encimados sirven a todas.
|
|
5
|
+
import { el, g, tip, esc } from '../svg.js';
|
|
5
6
|
import { measure, truncate } from '../text.js';
|
|
6
7
|
import { makeFormat } from '../format.js';
|
|
7
|
-
import { colorMap
|
|
8
|
+
import { colorMap } from '../theme.js';
|
|
9
|
+
import { ctx } from '../context.js';
|
|
8
10
|
import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
|
|
9
11
|
import { createFrame } from '../frame.js';
|
|
10
12
|
|
|
11
13
|
const pick = (fields, cands) => cands.find((c) => fields.includes(c));
|
|
14
|
+
const GOLDEN = 2.399963229728653;
|
|
15
|
+
const f1 = (v) => (Math.round(v * 10) / 10).toString();
|
|
16
|
+
|
|
17
|
+
/** Disposiciones disponibles (`layout`). `circle` es alias de `circular`. */
|
|
18
|
+
export const NETWORK_LAYOUTS = ['force', 'circular', 'radial', 'concentric', 'clusters', 'tree', 'grid'];
|
|
19
|
+
const ALIAS = { circle: 'circular', ring: 'circular', cluster: 'clusters', groups: 'clusters', hierarchy: 'tree', concentrico: 'concentric', arbol: 'tree', 'árbol': 'tree', grilla: 'grid', fuerzas: 'force' };
|
|
20
|
+
export function layoutName(v) {
|
|
21
|
+
const s = String(v ?? 'force').toLowerCase();
|
|
22
|
+
const k = ALIAS[s] ?? s;
|
|
23
|
+
return NETWORK_LAYOUTS.includes(k) ? k : 'force';
|
|
24
|
+
}
|
|
12
25
|
|
|
13
26
|
/** Prepara nodos y aristas a partir del spec. */
|
|
14
27
|
export function prepareNetwork(spec) {
|
|
@@ -21,7 +34,8 @@ export function prepareNetwork(spec) {
|
|
|
21
34
|
const vf = spec.value ?? pick(fields, ['value', 'weight', 'peso', 'valor']) ?? fields.find((f) => f !== from && f !== to && fieldKind(data, f) === 'number');
|
|
22
35
|
if (spec.value) requireField(data, vf, 'value');
|
|
23
36
|
|
|
24
|
-
// Aristas: pares no dirigidos agregados (A–B y B–A se suman).
|
|
37
|
+
// Aristas: pares no dirigidos agregados (A–B y B–A se suman). `src`/`dst`
|
|
38
|
+
// guardan el sentido de la primera fila (el color sale del origen).
|
|
25
39
|
const emap = new Map();
|
|
26
40
|
for (const r of data) {
|
|
27
41
|
const a = r[from], b = r[to];
|
|
@@ -31,7 +45,7 @@ export function prepareNetwork(spec) {
|
|
|
31
45
|
const w = vf ? num(r[vf]) : 1;
|
|
32
46
|
if (!(w > 0)) continue;
|
|
33
47
|
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 });
|
|
48
|
+
if (!emap.has(key)) emap.set(key, { s: s < t ? s : t, t: s < t ? t : s, src: s, dst: t, v: 0 });
|
|
35
49
|
emap.get(key).v += w;
|
|
36
50
|
}
|
|
37
51
|
const edges = [...emap.values()];
|
|
@@ -63,13 +77,104 @@ export function prepareNetwork(spec) {
|
|
|
63
77
|
return { edges, nodes: list, byId: nodes, valueField: vf };
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
// Orden determinista: grupo, peso (mayor primero), id.
|
|
81
|
+
const byGroup = (a, b) => a.gi - b.gi || b.w - a.w || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
82
|
+
const byWeight = (a, b) => b.w - a.w || a.gi - b.gi || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
83
|
+
|
|
84
|
+
// ── Disposiciones: cada una llena n.x, n.y en sus propias unidades ─────────
|
|
85
|
+
|
|
86
|
+
// ── Vecindad: grilla uniforme y árbol de cuadrantes (rendimiento) ─────────
|
|
87
|
+
|
|
88
|
+
/** Llama fn(p, q) para cada par a menos de `cell` en la grilla (3×3 celdas). */
|
|
89
|
+
function forNearPairs(nodes, X, Y, cell, fn) {
|
|
90
|
+
const n = nodes.length;
|
|
91
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
92
|
+
for (const p of nodes) { if (p[X] < x0) x0 = p[X]; if (p[X] > x1) x1 = p[X]; if (p[Y] < y0) y0 = p[Y]; if (p[Y] > y1) y1 = p[Y]; }
|
|
93
|
+
// Celdas en listas enlazadas sobre arreglos tipados: sin objetos por paso.
|
|
94
|
+
cell = Math.max(cell, Math.sqrt(((x1 - x0 + 1) * (y1 - y0 + 1)) / 4e5));
|
|
95
|
+
const W = Math.floor((x1 - x0) / cell) + 1, H = Math.floor((y1 - y0) / cell) + 1;
|
|
96
|
+
const head = new Int32Array(W * H).fill(-1), next = new Int32Array(n), gx = new Int32Array(n), gy = new Int32Array(n);
|
|
97
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
98
|
+
gx[i] = Math.floor((nodes[i][X] - x0) / cell); gy[i] = Math.floor((nodes[i][Y] - y0) / cell);
|
|
99
|
+
const c = gy[i] * W + gx[i];
|
|
100
|
+
next[i] = head[c]; head[c] = i;
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < n; i++) {
|
|
103
|
+
for (let cy = Math.max(0, gy[i] - 1); cy <= Math.min(H - 1, gy[i] + 1); cy++)
|
|
104
|
+
for (let cx = Math.max(0, gx[i] - 1); cx <= Math.min(W - 1, gx[i] + 1); cx++)
|
|
105
|
+
for (let j = head[cy * W + cx]; j !== -1; j = next[j]) if (j > i) fn(nodes[i], nodes[j]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Separa nodos encimados (radio + `extra`), en las coordenadas X, Y. */
|
|
110
|
+
function collide(nodes, X, Y, extra, cell) {
|
|
111
|
+
forNearPairs(nodes, X, Y, cell, (p, q) => {
|
|
112
|
+
const dx = q[X] - p[X], dy = q[Y] - p[Y], min = p.r + q.r + extra, d2 = dx * dx + dy * dy;
|
|
113
|
+
if (d2 < min * min) { const d = Math.sqrt(d2) || 1e-6, s = ((min - d) / d) * 0.5; p[X] -= dx * s; p[Y] -= dy * s; q[X] += dx * s; q[Y] += dy * s; }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Repulsión Barnes–Hut: cada celda lejana actúa como una sola masa en su centroide. */
|
|
118
|
+
function repelBH(nodes, strength, minD2, theta = 0.9) {
|
|
119
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
120
|
+
for (const p of nodes) { if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; }
|
|
121
|
+
const size = Math.max(x1 - x0, y1 - y0, 1e-3) * 1.0001;
|
|
122
|
+
const mk = (x, y, s) => ({ x, y, s, m: 0, cx: 0, cy: 0, kids: null, pts: null });
|
|
123
|
+
const root = mk(x0, y0, size);
|
|
124
|
+
const insert = (q, p, depth) => {
|
|
125
|
+
for (;;) {
|
|
126
|
+
q.cx = (q.cx * q.m + p.x) / (q.m + 1); q.cy = (q.cy * q.m + p.y) / (q.m + 1); q.m++;
|
|
127
|
+
if (!q.kids) {
|
|
128
|
+
if (!q.pts) { q.pts = [p]; return; }
|
|
129
|
+
if (depth > 24) { q.pts.push(p); return; } // puntos coincidentes
|
|
130
|
+
const h = q.s / 2;
|
|
131
|
+
q.kids = [mk(q.x, q.y, h), mk(q.x + h, q.y, h), mk(q.x, q.y + h, h), mk(q.x + h, q.y + h, h)];
|
|
132
|
+
for (const o of q.pts) {
|
|
133
|
+
const k = q.kids[(o.x >= q.x + h ? 1 : 0) + (o.y >= q.y + h ? 2 : 0)];
|
|
134
|
+
k.cx = (k.cx * k.m + o.x) / (k.m + 1); k.cy = (k.cy * k.m + o.y) / (k.m + 1); k.m++;
|
|
135
|
+
(k.pts ||= []).push(o);
|
|
136
|
+
}
|
|
137
|
+
q.pts = null;
|
|
138
|
+
}
|
|
139
|
+
const h = q.s / 2;
|
|
140
|
+
q = q.kids[(p.x >= q.x + h ? 1 : 0) + (p.y >= q.y + h ? 2 : 0)];
|
|
141
|
+
depth++;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
for (const p of nodes) insert(root, p, 0);
|
|
145
|
+
const th2 = theta * theta;
|
|
146
|
+
const stack = [];
|
|
147
|
+
for (const p of nodes) {
|
|
148
|
+
stack.length = 0;
|
|
149
|
+
stack.push(root);
|
|
150
|
+
while (stack.length) {
|
|
151
|
+
const q = stack.pop();
|
|
152
|
+
if (!q.m) continue;
|
|
153
|
+
if (q.pts) {
|
|
154
|
+
for (const o of q.pts) {
|
|
155
|
+
if (o === p) continue;
|
|
156
|
+
let dx = p.x - o.x, dy = p.y - o.y, d2 = dx * dx + dy * dy;
|
|
157
|
+
if (d2 < 1e-6) { dx = ((p._i * 7 + o._i * 13) % 11) / 11 - 0.5; dy = ((p._i * 5 + o._i * 3) % 7) / 7 - 0.5; if (p._i < o._i) { dx = -dx; dy = -dy; } d2 = dx * dx + dy * dy; }
|
|
158
|
+
const d = Math.sqrt(d2), f = -strength / Math.max(d2, minD2);
|
|
159
|
+
p.vx += (dx / d) * f; p.vy += (dy / d) * f;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const dx = p.x - q.cx, dy = p.y - q.cy, d2 = dx * dx + dy * dy;
|
|
164
|
+
if (q.s * q.s < th2 * d2) {
|
|
165
|
+
const d = Math.sqrt(d2) || 1e-6, f = (-strength * q.m) / Math.max(d2, minD2);
|
|
166
|
+
p.vx += (dx / d) * f; p.vy += (dy / d) * f;
|
|
167
|
+
} else stack.push(q.kids[0], q.kids[1], q.kids[2], q.kids[3]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Fuerzas (velocity Verlet simplificado, al estilo d3-force). Unidades ≈ px. */
|
|
173
|
+
function forceLayout(nodes, edges, byId, { iterations = 320, spacing = 40, pin = null, rotate = true, wide = true } = {}) {
|
|
68
174
|
const n = nodes.length;
|
|
69
175
|
// Posiciones iniciales en filotaxis (deterministas), agrupadas por grupo.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const r = spacing * 0.6 * Math.sqrt(i + 0.5), a = i * 2.399963229728653;
|
|
176
|
+
[...nodes].sort(byGroup).forEach((nd, i) => {
|
|
177
|
+
const r = spacing * 0.6 * Math.sqrt(i + 0.5), a = i * GOLDEN;
|
|
73
178
|
nd.x = r * Math.cos(a); nd.y = r * Math.sin(a); nd.vx = 0; nd.vy = 0;
|
|
74
179
|
});
|
|
75
180
|
// Nodo en foco: parte y se queda en el centro; el resto se acomoda a su alrededor.
|
|
@@ -79,21 +184,25 @@ function forceLayout(nodes, edges, byId, { iterations = 320, spacing = 40, pin =
|
|
|
79
184
|
let alpha = 1;
|
|
80
185
|
const decay = 1 - Math.pow(0.001, 1 / iterations);
|
|
81
186
|
const charge = -spacing * spacing * 0.9;
|
|
187
|
+
const cell = 2 * Math.max(...nodes.map((q) => q.r)) + 3;
|
|
188
|
+
nodes.forEach((q, i) => { q._i = i; });
|
|
82
189
|
for (let it = 0; it < iterations; it++) {
|
|
83
|
-
// Repulsión
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
for (let
|
|
87
|
-
const
|
|
88
|
-
let
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
190
|
+
// Repulsión: exacta hasta ~150 nodos; Barnes–Hut (θ = 0,9) por encima.
|
|
191
|
+
if (n > 150) repelBH(nodes, charge * alpha, spacing * 0.25);
|
|
192
|
+
else
|
|
193
|
+
for (let i = 0; i < n; i++) {
|
|
194
|
+
const p = nodes[i];
|
|
195
|
+
for (let j = i + 1; j < n; j++) {
|
|
196
|
+
const q = nodes[j];
|
|
197
|
+
let dx = q.x - p.x, dy = q.y - p.y;
|
|
198
|
+
let d2 = dx * dx + dy * dy;
|
|
199
|
+
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; }
|
|
200
|
+
const f = (charge * alpha) / Math.max(d2, spacing * 0.25);
|
|
201
|
+
const d = Math.sqrt(d2);
|
|
202
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
203
|
+
p.vx += fx; p.vy += fy; q.vx -= fx; q.vy -= fy;
|
|
204
|
+
}
|
|
95
205
|
}
|
|
96
|
-
}
|
|
97
206
|
// Resortes en las conexiones (más fuertes si el peso es mayor).
|
|
98
207
|
for (const l of links) {
|
|
99
208
|
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;
|
|
@@ -109,46 +218,271 @@ function forceLayout(nodes, edges, byId, { iterations = 320, spacing = 40, pin =
|
|
|
109
218
|
for (const p of nodes) { p.vx -= p.x * 0.02 * alpha; p.vy -= p.y * 0.02 * alpha; }
|
|
110
219
|
for (const p of nodes) { p.vx *= 0.6; p.vy *= 0.6; p.x += p.vx; p.y += p.vy; }
|
|
111
220
|
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
|
-
}
|
|
221
|
+
// Colisión por radio (grilla uniforme: solo pares vecinos).
|
|
222
|
+
for (let pass = 0; pass < 2; pass++) collide(nodes, 'x', 'y', 3, cell);
|
|
126
223
|
if (pin) { pin.x = 0; pin.y = 0; }
|
|
127
224
|
alpha -= alpha * decay;
|
|
128
225
|
}
|
|
226
|
+
// Rota la red para que su eje principal siga el lado largo del área (sin foco).
|
|
227
|
+
if (rotate && !pin) {
|
|
228
|
+
const mx = nodes.reduce((a, q) => a + q.x, 0) / n, my = nodes.reduce((a, q) => a + q.y, 0) / n;
|
|
229
|
+
let sxx = 0, syy = 0, sxy = 0;
|
|
230
|
+
for (const q of nodes) { const dx = q.x - mx, dy = q.y - my; sxx += dx * dx; syy += dy * dy; sxy += dx * dy; }
|
|
231
|
+
let th = 0.5 * Math.atan2(2 * sxy, sxx - syy);
|
|
232
|
+
if (!wide) th -= Math.PI / 2;
|
|
233
|
+
const c = Math.cos(-th), si = Math.sin(-th);
|
|
234
|
+
for (const q of nodes) { const dx = q.x - mx, dy = q.y - my; q.x = dx * c - dy * si; q.y = dx * si + dy * c; }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Círculo ordenado por grupo y peso, con un hueco entre grupos. El foco va al centro. */
|
|
239
|
+
function circularLayout(nodes, { pin }) {
|
|
240
|
+
const ring = nodes.filter((n) => n !== pin).sort(byGroup);
|
|
241
|
+
const gaps = new Set(ring.map((n) => n.gi)).size > 1;
|
|
242
|
+
const slots = [];
|
|
243
|
+
ring.forEach((n, i) => { if (gaps && i > 0 && n.gi !== ring[i - 1].gi) slots.push(null); slots.push(n); });
|
|
244
|
+
if (gaps && slots.length > 1) slots.push(null); // hueco entre el último y el primero
|
|
245
|
+
const S = slots.length;
|
|
246
|
+
slots.forEach((n, i) => {
|
|
247
|
+
if (!n) return;
|
|
248
|
+
const a = -Math.PI / 2 + (i / S) * Math.PI * 2;
|
|
249
|
+
n.x = Math.cos(a); n.y = Math.sin(a); n.ring = true;
|
|
250
|
+
});
|
|
251
|
+
if (pin) { pin.x = 0; pin.y = 0; pin.ring = false; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Árbol de expansión por BFS desde `root`: hijos en orden de peso de la
|
|
256
|
+
* conexión, luego grupo, peso e id. Devuelve hijos, profundidad y las aristas
|
|
257
|
+
* del árbol; los nodos inalcanzables quedan fuera de `depth`.
|
|
258
|
+
*/
|
|
259
|
+
function spanningTree(nodes, edges, byId, root) {
|
|
260
|
+
const adj = new Map(nodes.map((n) => [n.id, []]));
|
|
261
|
+
for (const e of edges) { adj.get(e.s).push([e.t, e.v]); adj.get(e.t).push([e.s, e.v]); }
|
|
262
|
+
for (const list of adj.values()) list.sort((a, b) => b[1] - a[1] || byGroup(byId.get(a[0]), byId.get(b[0])));
|
|
263
|
+
const depth = new Map([[root.id, 0]]), children = new Map(nodes.map((n) => [n.id, []]));
|
|
264
|
+
const treeEdges = new Set();
|
|
265
|
+
const queue = [root.id];
|
|
266
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
267
|
+
const id = queue[qi];
|
|
268
|
+
for (const [nb] of adj.get(id)) {
|
|
269
|
+
if (depth.has(nb)) continue;
|
|
270
|
+
depth.set(nb, depth.get(id) + 1);
|
|
271
|
+
children.get(id).push(nb);
|
|
272
|
+
treeEdges.add(id < nb ? id + '\u0000' + nb : nb + '\u0000' + id);
|
|
273
|
+
queue.push(nb);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Hojas numeradas en orden DFS; cada padre queda en el promedio de sus hijos.
|
|
277
|
+
const pos = new Map();
|
|
278
|
+
let leaf = 0;
|
|
279
|
+
// Iterativo: una cadena larga (miles de niveles) desbordaría la pila.
|
|
280
|
+
const stack = [[root.id, 0]];
|
|
281
|
+
while (stack.length) {
|
|
282
|
+
const top = stack[stack.length - 1];
|
|
283
|
+
const ch = children.get(top[0]);
|
|
284
|
+
if (!ch.length) { pos.set(top[0], leaf++); stack.pop(); continue; }
|
|
285
|
+
if (top[1] < ch.length) { stack.push([ch[top[1]++], 0]); continue; }
|
|
286
|
+
pos.set(top[0], ch.reduce((a, c) => a + pos.get(c), 0) / ch.length);
|
|
287
|
+
stack.pop();
|
|
288
|
+
}
|
|
289
|
+
const maxDepth = Math.max(0, ...depth.values());
|
|
290
|
+
const rest = nodes.filter((n) => !depth.has(n.id)).sort(byGroup);
|
|
291
|
+
return { depth, pos, leaves: leaf, maxDepth, rest, treeEdges, children };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function treeRoot(nodes, pin) {
|
|
295
|
+
return pin ?? [...nodes].sort((a, b) => b.wdeg - a.wdeg || b.w - a.w || (a.id < b.id ? -1 : 1))[0];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Árbol radial: raíz al centro, hojas repartidas en ángulo y radio según la
|
|
300
|
+
* profundidad. Cada anillo se abre lo justo para que sus nodos vecinos no se
|
|
301
|
+
* pisen (unidades ≈ px).
|
|
302
|
+
*/
|
|
303
|
+
function radialLayout(nodes, edges, byId, { pin, gap }) {
|
|
304
|
+
const root = treeRoot(nodes, pin);
|
|
305
|
+
const T = spanningTree(nodes, edges, byId, root);
|
|
306
|
+
const L = Math.max(1, T.leaves);
|
|
307
|
+
const levels = [];
|
|
308
|
+
for (const n of nodes) if (T.depth.has(n.id)) (levels[T.depth.get(n.id)] ||= []).push(n);
|
|
309
|
+
const R = [0];
|
|
310
|
+
const rmax = (list) => Math.max(0, ...(list || []).map((n) => n.r));
|
|
311
|
+
for (let d = 1; d < levels.length; d++) {
|
|
312
|
+
const list = levels[d].sort((a, b) => T.pos.get(a.id) - T.pos.get(b.id));
|
|
313
|
+
const cap = (L * (2 * rmax(list) + gap)) / (2 * Math.PI);
|
|
314
|
+
let need = 0;
|
|
315
|
+
for (let i = 0; i < list.length; i++) {
|
|
316
|
+
const p = list[i], q = list[(i + 1) % list.length];
|
|
317
|
+
if (p === q) break;
|
|
318
|
+
let da = ((T.pos.get(q.id) - T.pos.get(p.id)) * 2 * Math.PI) / L;
|
|
319
|
+
if (da <= 0) da += 2 * Math.PI;
|
|
320
|
+
need = Math.max(need, Math.min(cap, (p.r + q.r + gap) / Math.min(da, Math.PI)));
|
|
321
|
+
}
|
|
322
|
+
R[d] = Math.max(R[d - 1] + rmax(levels[d - 1]) + rmax(list) + gap * 3, need);
|
|
323
|
+
}
|
|
324
|
+
for (const n of nodes) {
|
|
325
|
+
if (!T.depth.has(n.id)) continue;
|
|
326
|
+
const a = -Math.PI / 2 + (2 * Math.PI * T.pos.get(n.id)) / L, rr = R[T.depth.get(n.id)];
|
|
327
|
+
n.x = rr * Math.cos(a); n.y = rr * Math.sin(a);
|
|
328
|
+
}
|
|
329
|
+
// Inalcanzables: un anillo exterior.
|
|
330
|
+
const Rout = R[R.length - 1] + rmax(levels[levels.length - 1]) + rmax(T.rest) + gap * 3;
|
|
331
|
+
T.rest.forEach((n, i) => {
|
|
332
|
+
const a = -Math.PI / 2 + (2 * Math.PI * (i + 0.5)) / T.rest.length;
|
|
333
|
+
n.x = Rout * Math.cos(a); n.y = Rout * Math.sin(a);
|
|
334
|
+
});
|
|
335
|
+
return T;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Árbol plano: profundidad en un eje, hojas en el otro. */
|
|
339
|
+
function treeLayout(nodes, edges, byId, { pin, wide }) {
|
|
340
|
+
const root = treeRoot(nodes, pin);
|
|
341
|
+
const T = spanningTree(nodes, edges, byId, root);
|
|
342
|
+
const L = Math.max(1, T.leaves);
|
|
343
|
+
const put = (n, depth, along) => { if (wide) { n.x = depth; n.y = along; } else { n.x = along; n.y = depth; } };
|
|
344
|
+
for (const n of nodes) if (T.depth.has(n.id)) put(n, T.depth.get(n.id), T.pos.get(n.id));
|
|
345
|
+
T.rest.forEach((n, i) => put(n, T.maxDepth + 1, T.rest.length > 1 ? (i * (L - 1)) / (T.rest.length - 1) : (L - 1) / 2));
|
|
346
|
+
return T;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Anillos por peso: el mayor (o el foco) al centro; dentro de cada anillo, por grupo. */
|
|
350
|
+
function concentricLayout(nodes, { pin, gap }) {
|
|
351
|
+
const order = [...nodes].sort(byWeight);
|
|
352
|
+
if (pin) { order.splice(order.indexOf(pin), 1); order.unshift(pin); }
|
|
353
|
+
const rings = [[order[0]]];
|
|
354
|
+
let R = 0, i = 1;
|
|
355
|
+
while (i < order.length) {
|
|
356
|
+
const prev = rings[rings.length - 1];
|
|
357
|
+
const rPrev = Math.max(...prev.map((n) => n.r));
|
|
358
|
+
const rHere = order[i].r; // el mayor del anillo (vienen ordenados)
|
|
359
|
+
R += rPrev + rHere + gap * 3;
|
|
360
|
+
const room = 2 * Math.PI * R;
|
|
361
|
+
const ring = [];
|
|
362
|
+
let used = 0;
|
|
363
|
+
while (i < order.length && (ring.length === 0 || used + 2 * order[i].r + gap <= room)) { used += 2 * order[i].r + gap; ring.push(order[i++]); }
|
|
364
|
+
ring.R = R;
|
|
365
|
+
rings.push(ring);
|
|
366
|
+
}
|
|
367
|
+
order[0].x = 0; order[0].y = 0;
|
|
368
|
+
rings.slice(1).forEach((ring, k) => {
|
|
369
|
+
ring.sort(byGroup);
|
|
370
|
+
const off = k % 2 ? Math.PI / ring.length : 0; // anillos alternados, para no alinear radios
|
|
371
|
+
ring.forEach((n, j) => { const a = -Math.PI / 2 + off + (2 * Math.PI * j) / ring.length; n.x = ring.R * Math.cos(a); n.y = ring.R * Math.sin(a); });
|
|
372
|
+
});
|
|
129
373
|
}
|
|
130
374
|
|
|
131
|
-
/**
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
const n
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
375
|
+
/** Un racimo por grupo (espiral de girasol), con los centros en una elipse. */
|
|
376
|
+
function clustersLayout(nodes, groupsCount, { pin, gap, aspect }) {
|
|
377
|
+
const by = new Map();
|
|
378
|
+
for (const n of [...nodes].sort(byGroup)) { if (n === pin) continue; if (!by.has(n.gi)) by.set(n.gi, []); by.get(n.gi).push(n); }
|
|
379
|
+
const cl = [...by.entries()].map(([gi, list]) => {
|
|
380
|
+
const c = 2 * Math.sqrt(list.reduce((a, n) => a + n.r * n.r, 0) / list.length) + gap;
|
|
381
|
+
const offs = list.map((n, i) => [c * Math.sqrt(i) * Math.cos(i * GOLDEN), c * Math.sqrt(i) * Math.sin(i * GOLDEN)]);
|
|
382
|
+
const rad = Math.max(...list.map((n, i) => Math.hypot(...offs[i]) + n.r));
|
|
383
|
+
return { gi, list, offs, rad };
|
|
138
384
|
});
|
|
385
|
+
const spacing = gap * 3;
|
|
386
|
+
if (cl.length === 1) {
|
|
387
|
+
cl[0].cx = 0; cl[0].cy = 0;
|
|
388
|
+
} else {
|
|
389
|
+
// Cada grupo ocupa en la circunferencia un arco ∝ su diámetro (∝ √cantidad).
|
|
390
|
+
const total = cl.reduce((a, c) => a + 2 * c.rad + spacing, 0);
|
|
391
|
+
let R = total / (2 * Math.PI);
|
|
392
|
+
// Con pocos grupos, el círculo debe al menos separar a los vecinos.
|
|
393
|
+
const need = Math.max(...cl.map((c) => c.rad)) * (cl.length === 2 ? 1.05 : 1) + spacing / 2 + (pin ? pin.r * 2 : 0);
|
|
394
|
+
R = Math.max(R, need);
|
|
395
|
+
let acc = 0;
|
|
396
|
+
for (const c of cl) {
|
|
397
|
+
const a = -Math.PI / 2 + (2 * Math.PI * (acc + c.rad + spacing / 2)) / total;
|
|
398
|
+
acc += 2 * c.rad + spacing;
|
|
399
|
+
c.cx = R * Math.cos(a) * aspect; c.cy = R * Math.sin(a);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const c of cl) c.list.forEach((n, i) => { n.x = c.cx + c.offs[i][0]; n.y = c.cy + c.offs[i][1]; });
|
|
403
|
+
if (pin) { pin.x = 0; pin.y = 0; }
|
|
404
|
+
return groupsCount;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Grilla ordenada por grupo y peso; el foco, en la celda central. */
|
|
408
|
+
function gridLayout(nodes, { pin, aw, ah }) {
|
|
409
|
+
const n = nodes.length;
|
|
410
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt((n * aw) / Math.max(1, ah))));
|
|
411
|
+
const rows = Math.ceil(n / cols);
|
|
412
|
+
const order = nodes.filter((q) => q !== pin).sort(byGroup);
|
|
413
|
+
if (pin) order.splice(Math.min(order.length, Math.floor(rows / 2) * cols + Math.floor(cols / 2)), 0, pin);
|
|
414
|
+
order.forEach((q, i) => { q.x = i % cols; q.y = Math.floor(i / cols); });
|
|
415
|
+
return { cols, rows };
|
|
139
416
|
}
|
|
140
417
|
|
|
418
|
+
// ── Ajuste al área (común a todas) ─────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function fitToArea(nodes, area, { pad, lim, center, withR }) {
|
|
421
|
+
const aw = Math.max(10, area.w - pad.l - pad.r), ah = Math.max(10, area.h - pad.t - pad.b);
|
|
422
|
+
const ox = area.x + pad.l, oy = area.y + pad.t;
|
|
423
|
+
const ext = withR ? (n) => n.r : () => 0;
|
|
424
|
+
const limit = (sx, sy) => {
|
|
425
|
+
if (!Number.isFinite(sx)) sx = Number.isFinite(sy) ? sy : 1;
|
|
426
|
+
if (!Number.isFinite(sy)) sy = sx;
|
|
427
|
+
if (sx / sy > lim) sx = sy * lim; else if (sy / sx > lim) sy = sx * lim;
|
|
428
|
+
return [sx, sy];
|
|
429
|
+
};
|
|
430
|
+
if (center) {
|
|
431
|
+
// Escala simétrica alrededor del origen (el foco queda en el centro exacto).
|
|
432
|
+
const ex = Math.max(...nodes.map((n) => Math.abs(n.x) + ext(n))), ey = Math.max(...nodes.map((n) => Math.abs(n.y) + ext(n)));
|
|
433
|
+
const [fx, fy] = limit(ex > 1e-9 ? aw / 2 / ex : Infinity, ey > 1e-9 ? ah / 2 / ey : Infinity);
|
|
434
|
+
for (const n of nodes) { n.px = ox + aw / 2 + n.x * fx; n.py = oy + ah / 2 + n.y * fy; }
|
|
435
|
+
return [fx, fy];
|
|
436
|
+
}
|
|
437
|
+
const x0 = Math.min(...nodes.map((n) => n.x - ext(n))), x1 = Math.max(...nodes.map((n) => n.x + ext(n)));
|
|
438
|
+
const y0 = Math.min(...nodes.map((n) => n.y - ext(n))), y1 = Math.max(...nodes.map((n) => n.y + ext(n)));
|
|
439
|
+
const [sx, sy] = limit(x1 - x0 > 1e-9 ? aw / (x1 - x0) : Infinity, y1 - y0 > 1e-9 ? ah / (y1 - y0) : Infinity);
|
|
440
|
+
const bx = ox + (aw - (x1 - x0) * sx) / 2, by = oy + (ah - (y1 - y0) * sy) / 2;
|
|
441
|
+
for (const n of nodes) { n.px = bx + (n.x - x0) * sx; n.py = by + (n.y - y0) * sy; }
|
|
442
|
+
return [sx, sy];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Achica los radios si la disposición geométrica deja nodos encimados. */
|
|
446
|
+
function shrinkToFit(nodes) {
|
|
447
|
+
let k = 1;
|
|
448
|
+
const cell = 2 * Math.max(...nodes.map((q) => q.r)) + 2;
|
|
449
|
+
forNearPairs(nodes, 'px', 'py', cell, (p, q) => {
|
|
450
|
+
const d = Math.hypot(q.px - p.px, q.py - p.py);
|
|
451
|
+
if (d < (p.r + q.r) * k + 1.5) k = Math.max(0.35, (d - 1.5) / (p.r + q.r));
|
|
452
|
+
});
|
|
453
|
+
if (k < 1) for (const n of nodes) n.r = Math.max(1.5, n.r * k);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function separate(nodes, area, passes = 6) {
|
|
457
|
+
const cell = 2 * Math.max(...nodes.map((q) => q.r)) + 2;
|
|
458
|
+
for (let pass = 0; pass < passes; pass++) collide(nodes, 'px', 'py', 1.5, cell);
|
|
459
|
+
for (const n of nodes) {
|
|
460
|
+
n.px = Math.max(area.x + n.r + 1, Math.min(area.x + area.w - n.r - 1, n.px));
|
|
461
|
+
n.py = Math.max(area.y + n.r + 1, Math.min(area.y + area.h - n.r - 1, n.py));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Etiquetas ──────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
const hitsCircle = (b, c) => {
|
|
468
|
+
const x = Math.max(b[0], Math.min(c.px, b[2])), y = Math.max(b[1], Math.min(c.py, b[3]));
|
|
469
|
+
const dx = c.px - x, dy = c.py - y;
|
|
470
|
+
return dx * dx + dy * dy < (c.r + 0.5) * (c.r + 0.5);
|
|
471
|
+
};
|
|
472
|
+
const hitsBox = (a, b) => !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
|
|
473
|
+
|
|
141
474
|
export function network(spec, theme) {
|
|
142
475
|
const locale = spec.locale || 'es';
|
|
143
476
|
const en = String(locale).startsWith('en');
|
|
144
|
-
const { edges, nodes, byId } = prepareNetwork(spec);
|
|
477
|
+
const { edges, nodes, byId, valueField } = prepareNetwork(spec);
|
|
145
478
|
if (!nodes.length) throw new PlotError('La red no tiene conexiones válidas.', 'Entrega filas con { from, to, value }.');
|
|
146
|
-
const layout = spec.layout
|
|
479
|
+
const layout = layoutName(spec.layout);
|
|
147
480
|
|
|
148
481
|
// Grupos en orden de peso total (color estable por entidad).
|
|
149
482
|
const gtot = new Map();
|
|
150
483
|
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));
|
|
484
|
+
const groups = [...gtot.keys()].sort((a, b) => gtot.get(b) - gtot.get(a) || (a < b ? -1 : a > b ? 1 : 0));
|
|
485
|
+
for (const n of nodes) n.gi = groups.indexOf(n.group ?? '');
|
|
152
486
|
const hasGroups = groups.length > 1 || (groups.length === 1 && groups[0] !== '');
|
|
153
487
|
const cmap = colorMap(groups, theme, spec.colors);
|
|
154
488
|
const colorOf = (n) => (spec.colors && spec.colors[n.id]) || (hasGroups ? cmap.get(n.group ?? '') : theme.categorical[0]);
|
|
@@ -157,184 +491,218 @@ export function network(spec, theme) {
|
|
|
157
491
|
|
|
158
492
|
const legend = hasGroups && spec.legend !== false ? groups.map((k) => ({ label: k || (en ? 'No group' : 'Sin grupo'), color: cmap.get(k) })) : [];
|
|
159
493
|
const W = spec.width || 720;
|
|
160
|
-
|
|
494
|
+
// El círculo rotula hacia afuera en todas direcciones: con muchos nodos pide más alto.
|
|
495
|
+
const tall = layout === 'circular' && nodes.length > 30;
|
|
496
|
+
const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(W * 1.05 + 150) : tall ? 600 : 520 });
|
|
161
497
|
const area = frame.layout(legend);
|
|
498
|
+
const wide = area.w >= area.h;
|
|
162
499
|
|
|
163
|
-
// Radios: área ∝ peso,
|
|
500
|
+
// Radios: área ∝ peso, de 2,5 px hasta un máximo que depende del espacio por nodo (≤ 30).
|
|
164
501
|
const maxW = Math.max(...nodes.map((n) => n.w), 1e-9);
|
|
165
|
-
const rMax = spec.maxRadius ?? Math.max(
|
|
166
|
-
const rMin = Math.min(
|
|
167
|
-
|
|
502
|
+
const rMax = spec.maxRadius ?? Math.max(8, Math.min(30, Math.sqrt((area.w * area.h) / nodes.length) * 0.32));
|
|
503
|
+
const rMin = Math.min(2.5, rMax / 2);
|
|
504
|
+
// En anillos concéntricos los mayores van juntos al centro: algo más chicos, para que se lean los anillos.
|
|
505
|
+
const rTop = layout === 'concentric' && spec.maxRadius === undefined ? rMax * 0.75 : rMax;
|
|
506
|
+
for (const n of nodes) { n.r = Math.max(rMin, rTop * Math.sqrt(Math.max(0, n.w) / maxW)); n.r0 = n.r; n.ring = false; }
|
|
507
|
+
|
|
508
|
+
// Foco (clic en un nodo): va al centro y se destacan sus vecinos, con nombre.
|
|
509
|
+
const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) ?? null : null;
|
|
510
|
+
// Un `highlight` que no existe en la red se ignora (si no, todo quedaría atenuado).
|
|
511
|
+
const hlIds = spec.highlight === undefined || spec.highlight === null ? [] : [].concat(spec.highlight).map(String).filter((id) => byId.has(id));
|
|
512
|
+
const hl = focusNode ? new Set([focusNode.id]) : hlIds.length ? new Set(hlIds) : null;
|
|
513
|
+
const near = new Set();
|
|
514
|
+
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); } }
|
|
515
|
+
const on = (n) => !hl || near.has(n.id);
|
|
168
516
|
|
|
169
|
-
// Etiquetas
|
|
517
|
+
// Etiquetas candidatas.
|
|
170
518
|
const labelSize = (theme.type?.label ?? 12) - 0.5;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
519
|
+
const labelPx = labelSize * ctx.textScale;
|
|
520
|
+
let cand;
|
|
521
|
+
if (Array.isArray(spec.labels)) { const s = new Set(spec.labels.map(String)); cand = nodes.filter((n) => s.has(n.id) || s.has(n.label)); }
|
|
522
|
+
else if (spec.labels === false) cand = [];
|
|
523
|
+
else if (typeof spec.labels === 'number') cand = [...nodes].sort(byWeight).slice(0, Math.max(0, spec.labels));
|
|
524
|
+
else if (spec.labels === true || nodes.length <= 14 || layout === 'circular' || layout === 'grid') cand = [...nodes];
|
|
174
525
|
else {
|
|
175
|
-
const
|
|
176
|
-
|
|
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); }
|
|
526
|
+
const big = nodes.filter((n) => n.r0 >= 0.45 * rTop);
|
|
527
|
+
cand = big.length >= 8 ? big : [...nodes].sort(byWeight).slice(0, 8);
|
|
184
528
|
}
|
|
529
|
+
if (focusNode) { const s = new Set(cand); for (const id of near) s.add(byId.get(id)); cand = [...s]; }
|
|
185
530
|
|
|
186
|
-
// Disposición + ajuste al área
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 {
|
|
531
|
+
// ── Disposición + ajuste al área ──────────────────────────────────────────
|
|
532
|
+
const gap = Math.max(3, rMax * 0.25);
|
|
533
|
+
const pad0 = rMax + 4;
|
|
534
|
+
let pad = { l: pad0, r: pad0, t: pad0, b: pad0 }, lim = 2.4, center = !!focusNode, withR = false, geometric = true, tree = null;
|
|
535
|
+
let circ = null;
|
|
536
|
+
if (layout === 'force') {
|
|
201
537
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
}
|
|
538
|
+
forceLayout(nodes, edges, byId, { spacing, pin: focusNode, wide, iterations: spec.iterations ?? (nodes.length > 1000 ? 140 : nodes.length > 200 ? 200 : 320) });
|
|
539
|
+
withR = true; geometric = false;
|
|
540
|
+
} else if (layout === 'circular') {
|
|
541
|
+
circularLayout(nodes, { pin: focusNode });
|
|
542
|
+
const ringLab = cand.filter((n) => n.ring);
|
|
543
|
+
const cap = Math.min(130, Math.min(area.w, area.h) * (area.w < 480 ? 0.17 : nodes.length > 40 ? 0.19 : 0.24));
|
|
544
|
+
const lw = Math.min(cap, Math.max(0, ...ringLab.map((n) => measure(n.label, labelSize, 500))));
|
|
545
|
+
const rGuess = Math.min(rMax, 12);
|
|
546
|
+
const p = (ringLab.length ? lw + 6 : 0) + rGuess + 2;
|
|
547
|
+
pad = { l: p, r: p, t: p, b: p };
|
|
548
|
+
circ = { lw: Math.max(24, lw) };
|
|
549
|
+
lim = 1.5; center = true;
|
|
550
|
+
} else if (layout === 'radial') {
|
|
551
|
+
tree = radialLayout(nodes, edges, byId, { pin: focusNode, gap });
|
|
552
|
+
lim = 1.4; center = true;
|
|
553
|
+
} else if (layout === 'concentric') {
|
|
554
|
+
concentricLayout(nodes, { pin: focusNode, gap });
|
|
555
|
+
lim = 1.35; center = true;
|
|
556
|
+
} else if (layout === 'clusters') {
|
|
557
|
+
const aspect = Math.max(1, Math.min(1.9, area.w / area.h));
|
|
558
|
+
clustersLayout(nodes, groups.length, { pin: focusNode, gap, aspect });
|
|
559
|
+
lim = 1.15; withR = true; geometric = false;
|
|
560
|
+
} else if (layout === 'tree') {
|
|
561
|
+
tree = treeLayout(nodes, edges, byId, { pin: focusNode, wide });
|
|
562
|
+
lim = Infinity; center = false;
|
|
563
|
+
if (wide) {
|
|
564
|
+
// Las hojas del último nivel rotulan a la derecha: se les deja espacio.
|
|
565
|
+
const last = cand.filter((n) => n.x === Math.max(...nodes.map((q) => q.x)));
|
|
566
|
+
const lw = Math.min(area.w * 0.18, Math.max(0, ...last.map((n) => measure(n.label, labelSize, 500))));
|
|
567
|
+
pad = { ...pad, r: pad0 + (lw ? lw + 6 : 0) };
|
|
568
|
+
} else pad = { ...pad, b: pad0 + labelPx + 4 };
|
|
569
|
+
} else if (layout === 'grid') {
|
|
570
|
+
gridLayout(nodes, { pin: focusNode, aw: area.w, ah: area.h });
|
|
571
|
+
lim = Infinity; center = false;
|
|
572
|
+
// Nombres centrados bajo cada nodo: los de los bordes necesitan medio ancho de margen.
|
|
573
|
+
const half = Math.min(area.w * 0.12, Math.max(0, ...cand.map((n) => measure(n.label, labelSize, 500) / 2)));
|
|
574
|
+
pad = { l: Math.max(pad0, half + 2), r: Math.max(pad0, half + 2), t: pad0, b: pad0 + labelPx + 2 };
|
|
245
575
|
}
|
|
576
|
+
fitToArea(nodes, area, { pad, lim, center, withR });
|
|
577
|
+
if (geometric) shrinkToFit(nodes);
|
|
578
|
+
separate(nodes, area, layout === 'circular' || layout === 'grid' || layout === 'tree' ? 2 : 6);
|
|
579
|
+
const cx = area.x + pad.l + (area.w - pad.l - pad.r) / 2, cy = area.y + pad.t + (area.h - pad.t - pad.b) / 2;
|
|
580
|
+
const body = [];
|
|
246
581
|
|
|
247
|
-
//
|
|
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.
|
|
582
|
+
// ── Conexiones: curvas del color del nodo de origen, ancho ∝ √peso ───────
|
|
254
583
|
const maxE = Math.max(...edges.map((e) => e.v), 1e-9);
|
|
255
|
-
const
|
|
584
|
+
const baseOp = edges.length > 150 ? 0.3 : 0.5;
|
|
585
|
+
// Curvatura al estilo ECharts (0,3); en la grilla, más suave para no salirse de las filas.
|
|
586
|
+
const curv = spec.curveness !== undefined && spec.curveness !== null && spec.curveness !== '' && Number.isFinite(+spec.curveness) ? +spec.curveness : layout === 'grid' ? 0.18 : 0.3;
|
|
587
|
+
const P = (x, y) => `${f1(x)},${f1(y)}`;
|
|
256
588
|
const linkG = [];
|
|
257
589
|
for (const e of [...edges].sort((a, b) => a.v - b.v)) {
|
|
258
|
-
const a = byId.get(e.
|
|
259
|
-
const w = 0.
|
|
590
|
+
const a = byId.get(e.src), b = byId.get(e.dst);
|
|
591
|
+
const w = valueField ? 0.6 + 2.4 * Math.sqrt(e.v / maxE) : 1;
|
|
592
|
+
const isTree = tree ? tree.treeEdges.has(e.s + '\u0000' + e.t) : true;
|
|
260
593
|
let d;
|
|
261
|
-
if (layout === '
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
d = `M${a.px
|
|
594
|
+
if (layout === 'tree' && isTree) {
|
|
595
|
+
// Conector de árbol: curva en S entre padre e hijo.
|
|
596
|
+
if (wide) { const mx = (a.px + b.px) / 2; d = `M${P(a.px, a.py)}C${P(mx, a.py)} ${P(mx, b.py)} ${P(b.px, b.py)}`; }
|
|
597
|
+
else { const my = (a.py + b.py) / 2; d = `M${P(a.px, a.py)}C${P(a.px, my)} ${P(b.px, my)} ${P(b.px, b.py)}`; }
|
|
598
|
+
} else if (layout === 'circular' && a.ring && b.ring) {
|
|
599
|
+
const mx = (a.px + b.px) / 2, my = (a.py + b.py) / 2, k = Math.min(0.9, (0.55 * curv) / 0.3);
|
|
600
|
+
d = `M${P(a.px, a.py)}Q${P(mx + (cx - mx) * k, my + (cy - my) * k)} ${P(b.px, b.py)}`;
|
|
601
|
+
} else if (curv === 0) {
|
|
602
|
+
d = `M${P(a.px, a.py)}L${P(b.px, b.py)}`;
|
|
265
603
|
} else {
|
|
266
604
|
const dx = b.px - a.px, dy = b.py - a.py, len = Math.hypot(dx, dy) || 1;
|
|
267
|
-
const bend =
|
|
268
|
-
|
|
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)}`;
|
|
605
|
+
const bend = curv * len * (layout === 'radial' && isTree ? 0.35 : 1); // radios del árbol, casi rectos
|
|
606
|
+
d = `M${P(a.px, a.py)}Q${P((a.px + b.px) / 2 - (dy / len) * bend, (a.py + b.py) / 2 + (dx / len) * bend)} ${P(b.px, b.py)}`;
|
|
270
607
|
}
|
|
271
608
|
const active = hl ? hl.has(e.s) || hl.has(e.t) : true;
|
|
609
|
+
const op = hl ? (active ? 0.8 : 0.06) : !isTree ? 0.15 : baseOp;
|
|
610
|
+
const sw = hl && active ? Math.max(1.2, w) : w;
|
|
611
|
+
// Lo común (relleno, extremos, opacidad y ancho base) va en el <g>; cada arista lleva solo lo propio.
|
|
272
612
|
linkG.push(el('path', {
|
|
273
|
-
class: 'pa-m', d,
|
|
274
|
-
strokeOpacity:
|
|
613
|
+
class: 'pa-m', d, stroke: hl && !active ? theme.muted : colorOf(a),
|
|
614
|
+
strokeOpacity: op === baseOp ? undefined : op, strokeWidth: sw === 1 ? undefined : sw,
|
|
615
|
+
'data-s': e.src, 'data-t': e.dst,
|
|
275
616
|
'data-tip': tip(`${a.label} — ${b.label}`, fmtE(e.v)),
|
|
276
617
|
}));
|
|
277
618
|
}
|
|
278
|
-
|
|
619
|
+
const links = g({ class: 'pa-links', fill: 'none', strokeLinecap: 'round', strokeOpacity: baseOp, strokeWidth: 1 }, linkG);
|
|
279
620
|
|
|
280
|
-
// Nodos: grandes debajo, chicos encima
|
|
621
|
+
// ── Nodos: grandes debajo, chicos encima ──────────────────────────────────
|
|
622
|
+
const ring = nodes.length > 60 ? 0 : 0.75;
|
|
281
623
|
const nodeG = [];
|
|
282
|
-
for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r)) {
|
|
624
|
+
for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r || (a.id < b.id ? -1 : 1))) {
|
|
283
625
|
const lines = [n.label];
|
|
284
626
|
if (hasGroups && n.group) lines.push(n.group);
|
|
285
627
|
lines.push(`${en ? 'Connections' : 'Conexiones'}: ${n.deg}`);
|
|
286
628
|
lines.push(`${n.size !== null ? (en ? 'Size' : 'Tamaño') : en ? 'Weighted degree' : 'Grado ponderado'}: ${fmt(n.w)}`);
|
|
287
629
|
const isF = focusNode && n.id === focusNode.id;
|
|
288
|
-
nodeG.push(el('circle', {
|
|
630
|
+
nodeG.push(el('circle', {
|
|
631
|
+
class: 'pa-m', cx: n.px, cy: n.py, r: n.r, fill: on(n) ? colorOf(n) : theme.dim,
|
|
632
|
+
stroke: isF ? theme.ink : ring ? theme.surface : undefined, strokeWidth: isF ? 2.5 : ring || undefined,
|
|
633
|
+
'data-tip': tip(...lines), 'data-node': n.id, style: 'cursor:pointer',
|
|
634
|
+
}));
|
|
289
635
|
}
|
|
290
|
-
|
|
636
|
+
const nodesSvg = g({ class: 'pa-nodes' }, nodeG);
|
|
291
637
|
|
|
292
|
-
// Etiquetas con halo, sin choques
|
|
293
|
-
const
|
|
638
|
+
// ── Etiquetas: tinta con halo, sin choques con otras etiquetas ni nodos ──
|
|
639
|
+
const prio = (a, b) => (focusNode ? (b === focusNode) - (a === focusNode) : 0) || (hl ? hl.has(b.id) - hl.has(a.id) : 0) || byWeight(a, b);
|
|
640
|
+
const lbls = cand.filter((n) => on(n) || hl?.has(n.id)).sort(prio);
|
|
641
|
+
const placed = [];
|
|
642
|
+
const topIds = new Set([...nodes].sort(byWeight).slice(0, 3).map((n) => n.id));
|
|
643
|
+
const angles = []; // etiquetas radiales ya puestas: [ángulo, medio alto angular]
|
|
294
644
|
const labG = [];
|
|
295
|
-
|
|
296
|
-
const
|
|
645
|
+
const h = labelPx + 1;
|
|
646
|
+
const inArea = (b) => b[0] >= area.x - 2 && b[2] <= area.x + area.w + 2 && b[1] >= area.y - 6 && b[3] <= area.y + area.h + 6;
|
|
297
647
|
for (const n of lbls) {
|
|
298
|
-
const
|
|
299
|
-
const weight =
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
]
|
|
648
|
+
const isF = focusNode && n === focusNode;
|
|
649
|
+
const weight = isF ? 600 : 500;
|
|
650
|
+
const attrs = { fill: theme.ink, fontSize: labelSize, fontWeight: weight, stroke: theme.surface, strokeWidth: 2, paintOrder: 'stroke', strokeLinejoin: 'round', 'data-node': n.id };
|
|
651
|
+
if (circ && n.ring) {
|
|
652
|
+
// En el círculo: radial hacia afuera, girada; a la izquierda, volteada para leerse.
|
|
653
|
+
const a = Math.atan2(n.py - cy, n.px - cx), rr = Math.hypot(n.px - cx, n.py - cy) + n.r + 5;
|
|
654
|
+
const half = (h * 0.5 + 0.5) / rr;
|
|
655
|
+
if (angles.some(([b, hb]) => { let dA = Math.abs(a - b) % (2 * Math.PI); if (dA > Math.PI) dA = 2 * Math.PI - dA; return dA < half + hb; })) continue;
|
|
656
|
+
angles.push([a, half]);
|
|
657
|
+
const label = truncate(n.label, circ.lw, labelSize, weight);
|
|
658
|
+
const lx = n.px + Math.cos(a) * (n.r + 5), ly = n.py + Math.sin(a) * (n.r + 5);
|
|
659
|
+
const flip = Math.cos(a) < 0;
|
|
660
|
+
const deg = ((a * 180) / Math.PI + (flip ? 180 : 0));
|
|
661
|
+
labG.push(el('text', { x: lx, y: ly, dy: '0.35em', textAnchor: flip ? 'end' : 'start', transform: `rotate(${f1(deg)} ${f1(lx)} ${f1(ly)})`, ...attrs }, esc(label)));
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const label = truncate(n.label, 150, labelSize, weight);
|
|
665
|
+
const w = measure(label, labelSize, weight);
|
|
666
|
+
const cands = [
|
|
667
|
+
[n.px + n.r + 5, n.py + labelPx * 0.35, 'start'],
|
|
668
|
+
[n.px - n.r - 5, n.py + labelPx * 0.35, 'end'],
|
|
669
|
+
[n.px, n.py - n.r - 4, 'middle'],
|
|
670
|
+
[n.px, n.py + n.r + labelPx + 1, 'middle'],
|
|
671
|
+
[n.px + n.r * 0.7 + 4, n.py - n.r * 0.7 - 2, 'start'],
|
|
672
|
+
[n.px - n.r * 0.7 - 4, n.py - n.r * 0.7 - 2, 'end'],
|
|
673
|
+
[n.px + n.r * 0.7 + 4, n.py + n.r * 0.7 + labelPx * 0.8, 'start'],
|
|
674
|
+
[n.px - n.r * 0.7 - 4, n.py + n.r * 0.7 + labelPx * 0.8, 'end'],
|
|
675
|
+
];
|
|
676
|
+
// Árbol horizontal: a los nodos con hijos, el nombre arriba o abajo (a la derecha salen sus conectores).
|
|
677
|
+
if (layout === 'tree' && wide && tree.children.get(n.id)?.length) cands.unshift(cands[2], cands[3]);
|
|
678
|
+
// Grilla: el nombre bajo el nodo, centrado en su celda.
|
|
679
|
+
if (layout === 'grid') cands.unshift(cands[3]);
|
|
680
|
+
let done = false, fallback = null;
|
|
681
|
+
// Los tres mayores (y el foco) pueden, en segunda vuelta, pisar nodos chicos: el halo los mantiene legibles.
|
|
682
|
+
const tries = isF || topIds.has(n.id) ? [0, 0.35 * rTop] : [0];
|
|
683
|
+
for (const minR of tries) {
|
|
684
|
+
for (const [lx, ly, anchor] of cands) {
|
|
685
|
+
const bx0 = anchor === 'start' ? lx : anchor === 'end' ? lx - w : lx - w / 2;
|
|
686
|
+
const box = [bx0 - 1, ly - labelPx * 0.85, bx0 + w + 1, ly + labelPx * 0.25];
|
|
687
|
+
if (!inArea(box)) continue;
|
|
688
|
+
if (!fallback) fallback = [lx, ly, anchor, box];
|
|
689
|
+
if (placed.some((b) => hitsBox(box, b))) continue;
|
|
690
|
+
if (nodes.some((q) => q !== n && q.r >= minR && hitsCircle(box, q))) continue;
|
|
691
|
+
placed.push(box);
|
|
692
|
+
labG.push(el('text', { x: lx, y: ly, textAnchor: anchor, ...attrs }, esc(label)));
|
|
693
|
+
done = true;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
if (done) break;
|
|
320
697
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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;
|
|
698
|
+
// El nodo en foco siempre lleva su nombre.
|
|
699
|
+
if (!done && isF && fallback) {
|
|
700
|
+
placed.push(fallback[3]);
|
|
701
|
+
labG.push(el('text', { x: fallback[0], y: fallback[1], textAnchor: fallback[2], ...attrs }, esc(label)));
|
|
335
702
|
}
|
|
336
703
|
}
|
|
337
|
-
|
|
704
|
+
const labels = g({ class: 'pa-labels' }, labG);
|
|
705
|
+
body.push(g({ class: 'pa-net', 'data-layout': layout }, [links, nodesSvg, labels]));
|
|
338
706
|
|
|
339
707
|
const top = [...nodes].sort((a, b) => b.deg - a.deg || b.w - a.w).slice(0, 3);
|
|
340
708
|
const desc = en
|