plotagents 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- package/src/theme.js +101 -14
package/src/charts/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,268 @@ 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
|
+
const visit = (id) => {
|
|
280
|
+
const ch = children.get(id);
|
|
281
|
+
if (!ch.length) { pos.set(id, leaf++); return; }
|
|
282
|
+
for (const c of ch) visit(c);
|
|
283
|
+
pos.set(id, ch.reduce((a, c) => a + pos.get(c), 0) / ch.length);
|
|
284
|
+
};
|
|
285
|
+
visit(root.id);
|
|
286
|
+
const maxDepth = Math.max(0, ...depth.values());
|
|
287
|
+
const rest = nodes.filter((n) => !depth.has(n.id)).sort(byGroup);
|
|
288
|
+
return { depth, pos, leaves: leaf, maxDepth, rest, treeEdges, children };
|
|
129
289
|
}
|
|
130
290
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
291
|
+
function treeRoot(nodes, pin) {
|
|
292
|
+
return pin ?? [...nodes].sort((a, b) => b.wdeg - a.wdeg || b.w - a.w || (a.id < b.id ? -1 : 1))[0];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Árbol radial: raíz al centro, hojas repartidas en ángulo y radio según la
|
|
297
|
+
* profundidad. Cada anillo se abre lo justo para que sus nodos vecinos no se
|
|
298
|
+
* pisen (unidades ≈ px).
|
|
299
|
+
*/
|
|
300
|
+
function radialLayout(nodes, edges, byId, { pin, gap }) {
|
|
301
|
+
const root = treeRoot(nodes, pin);
|
|
302
|
+
const T = spanningTree(nodes, edges, byId, root);
|
|
303
|
+
const L = Math.max(1, T.leaves);
|
|
304
|
+
const levels = [];
|
|
305
|
+
for (const n of nodes) if (T.depth.has(n.id)) (levels[T.depth.get(n.id)] ||= []).push(n);
|
|
306
|
+
const R = [0];
|
|
307
|
+
const rmax = (list) => Math.max(0, ...(list || []).map((n) => n.r));
|
|
308
|
+
for (let d = 1; d < levels.length; d++) {
|
|
309
|
+
const list = levels[d].sort((a, b) => T.pos.get(a.id) - T.pos.get(b.id));
|
|
310
|
+
const cap = (L * (2 * rmax(list) + gap)) / (2 * Math.PI);
|
|
311
|
+
let need = 0;
|
|
312
|
+
for (let i = 0; i < list.length; i++) {
|
|
313
|
+
const p = list[i], q = list[(i + 1) % list.length];
|
|
314
|
+
if (p === q) break;
|
|
315
|
+
let da = ((T.pos.get(q.id) - T.pos.get(p.id)) * 2 * Math.PI) / L;
|
|
316
|
+
if (da <= 0) da += 2 * Math.PI;
|
|
317
|
+
need = Math.max(need, Math.min(cap, (p.r + q.r + gap) / Math.min(da, Math.PI)));
|
|
318
|
+
}
|
|
319
|
+
R[d] = Math.max(R[d - 1] + rmax(levels[d - 1]) + rmax(list) + gap * 3, need);
|
|
320
|
+
}
|
|
321
|
+
for (const n of nodes) {
|
|
322
|
+
if (!T.depth.has(n.id)) continue;
|
|
323
|
+
const a = -Math.PI / 2 + (2 * Math.PI * T.pos.get(n.id)) / L, rr = R[T.depth.get(n.id)];
|
|
324
|
+
n.x = rr * Math.cos(a); n.y = rr * Math.sin(a);
|
|
325
|
+
}
|
|
326
|
+
// Inalcanzables: un anillo exterior.
|
|
327
|
+
const Rout = R[R.length - 1] + rmax(levels[levels.length - 1]) + rmax(T.rest) + gap * 3;
|
|
328
|
+
T.rest.forEach((n, i) => {
|
|
329
|
+
const a = -Math.PI / 2 + (2 * Math.PI * (i + 0.5)) / T.rest.length;
|
|
330
|
+
n.x = Rout * Math.cos(a); n.y = Rout * Math.sin(a);
|
|
331
|
+
});
|
|
332
|
+
return T;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Árbol plano: profundidad en un eje, hojas en el otro. */
|
|
336
|
+
function treeLayout(nodes, edges, byId, { pin, wide }) {
|
|
337
|
+
const root = treeRoot(nodes, pin);
|
|
338
|
+
const T = spanningTree(nodes, edges, byId, root);
|
|
339
|
+
const L = Math.max(1, T.leaves);
|
|
340
|
+
const put = (n, depth, along) => { if (wide) { n.x = depth; n.y = along; } else { n.x = along; n.y = depth; } };
|
|
341
|
+
for (const n of nodes) if (T.depth.has(n.id)) put(n, T.depth.get(n.id), T.pos.get(n.id));
|
|
342
|
+
T.rest.forEach((n, i) => put(n, T.maxDepth + 1, T.rest.length > 1 ? (i * (L - 1)) / (T.rest.length - 1) : (L - 1) / 2));
|
|
343
|
+
return T;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Anillos por peso: el mayor (o el foco) al centro; dentro de cada anillo, por grupo. */
|
|
347
|
+
function concentricLayout(nodes, { pin, gap }) {
|
|
348
|
+
const order = [...nodes].sort(byWeight);
|
|
349
|
+
if (pin) { order.splice(order.indexOf(pin), 1); order.unshift(pin); }
|
|
350
|
+
const rings = [[order[0]]];
|
|
351
|
+
let R = 0, i = 1;
|
|
352
|
+
while (i < order.length) {
|
|
353
|
+
const prev = rings[rings.length - 1];
|
|
354
|
+
const rPrev = Math.max(...prev.map((n) => n.r));
|
|
355
|
+
const rHere = order[i].r; // el mayor del anillo (vienen ordenados)
|
|
356
|
+
R += rPrev + rHere + gap * 3;
|
|
357
|
+
const room = 2 * Math.PI * R;
|
|
358
|
+
const ring = [];
|
|
359
|
+
let used = 0;
|
|
360
|
+
while (i < order.length && (ring.length === 0 || used + 2 * order[i].r + gap <= room)) { used += 2 * order[i].r + gap; ring.push(order[i++]); }
|
|
361
|
+
ring.R = R;
|
|
362
|
+
rings.push(ring);
|
|
363
|
+
}
|
|
364
|
+
order[0].x = 0; order[0].y = 0;
|
|
365
|
+
rings.slice(1).forEach((ring, k) => {
|
|
366
|
+
ring.sort(byGroup);
|
|
367
|
+
const off = k % 2 ? Math.PI / ring.length : 0; // anillos alternados, para no alinear radios
|
|
368
|
+
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); });
|
|
138
369
|
});
|
|
139
370
|
}
|
|
140
371
|
|
|
372
|
+
/** Un racimo por grupo (espiral de girasol), con los centros en una elipse. */
|
|
373
|
+
function clustersLayout(nodes, groupsCount, { pin, gap, aspect }) {
|
|
374
|
+
const by = new Map();
|
|
375
|
+
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); }
|
|
376
|
+
const cl = [...by.entries()].map(([gi, list]) => {
|
|
377
|
+
const c = 2 * Math.sqrt(list.reduce((a, n) => a + n.r * n.r, 0) / list.length) + gap;
|
|
378
|
+
const offs = list.map((n, i) => [c * Math.sqrt(i) * Math.cos(i * GOLDEN), c * Math.sqrt(i) * Math.sin(i * GOLDEN)]);
|
|
379
|
+
const rad = Math.max(...list.map((n, i) => Math.hypot(...offs[i]) + n.r));
|
|
380
|
+
return { gi, list, offs, rad };
|
|
381
|
+
});
|
|
382
|
+
const spacing = gap * 3;
|
|
383
|
+
if (cl.length === 1) {
|
|
384
|
+
cl[0].cx = 0; cl[0].cy = 0;
|
|
385
|
+
} else {
|
|
386
|
+
// Cada grupo ocupa en la circunferencia un arco ∝ su diámetro (∝ √cantidad).
|
|
387
|
+
const total = cl.reduce((a, c) => a + 2 * c.rad + spacing, 0);
|
|
388
|
+
let R = total / (2 * Math.PI);
|
|
389
|
+
// Con pocos grupos, el círculo debe al menos separar a los vecinos.
|
|
390
|
+
const need = Math.max(...cl.map((c) => c.rad)) * (cl.length === 2 ? 1.05 : 1) + spacing / 2 + (pin ? pin.r * 2 : 0);
|
|
391
|
+
R = Math.max(R, need);
|
|
392
|
+
let acc = 0;
|
|
393
|
+
for (const c of cl) {
|
|
394
|
+
const a = -Math.PI / 2 + (2 * Math.PI * (acc + c.rad + spacing / 2)) / total;
|
|
395
|
+
acc += 2 * c.rad + spacing;
|
|
396
|
+
c.cx = R * Math.cos(a) * aspect; c.cy = R * Math.sin(a);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
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]; });
|
|
400
|
+
if (pin) { pin.x = 0; pin.y = 0; }
|
|
401
|
+
return groupsCount;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Grilla ordenada por grupo y peso; el foco, en la celda central. */
|
|
405
|
+
function gridLayout(nodes, { pin, aw, ah }) {
|
|
406
|
+
const n = nodes.length;
|
|
407
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt((n * aw) / Math.max(1, ah))));
|
|
408
|
+
const rows = Math.ceil(n / cols);
|
|
409
|
+
const order = nodes.filter((q) => q !== pin).sort(byGroup);
|
|
410
|
+
if (pin) order.splice(Math.min(order.length, Math.floor(rows / 2) * cols + Math.floor(cols / 2)), 0, pin);
|
|
411
|
+
order.forEach((q, i) => { q.x = i % cols; q.y = Math.floor(i / cols); });
|
|
412
|
+
return { cols, rows };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Ajuste al área (común a todas) ─────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
function fitToArea(nodes, area, { pad, lim, center, withR }) {
|
|
418
|
+
const aw = Math.max(10, area.w - pad.l - pad.r), ah = Math.max(10, area.h - pad.t - pad.b);
|
|
419
|
+
const ox = area.x + pad.l, oy = area.y + pad.t;
|
|
420
|
+
const ext = withR ? (n) => n.r : () => 0;
|
|
421
|
+
const limit = (sx, sy) => {
|
|
422
|
+
if (!Number.isFinite(sx)) sx = Number.isFinite(sy) ? sy : 1;
|
|
423
|
+
if (!Number.isFinite(sy)) sy = sx;
|
|
424
|
+
if (sx / sy > lim) sx = sy * lim; else if (sy / sx > lim) sy = sx * lim;
|
|
425
|
+
return [sx, sy];
|
|
426
|
+
};
|
|
427
|
+
if (center) {
|
|
428
|
+
// Escala simétrica alrededor del origen (el foco queda en el centro exacto).
|
|
429
|
+
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)));
|
|
430
|
+
const [fx, fy] = limit(ex > 1e-9 ? aw / 2 / ex : Infinity, ey > 1e-9 ? ah / 2 / ey : Infinity);
|
|
431
|
+
for (const n of nodes) { n.px = ox + aw / 2 + n.x * fx; n.py = oy + ah / 2 + n.y * fy; }
|
|
432
|
+
return [fx, fy];
|
|
433
|
+
}
|
|
434
|
+
const x0 = Math.min(...nodes.map((n) => n.x - ext(n))), x1 = Math.max(...nodes.map((n) => n.x + ext(n)));
|
|
435
|
+
const y0 = Math.min(...nodes.map((n) => n.y - ext(n))), y1 = Math.max(...nodes.map((n) => n.y + ext(n)));
|
|
436
|
+
const [sx, sy] = limit(x1 - x0 > 1e-9 ? aw / (x1 - x0) : Infinity, y1 - y0 > 1e-9 ? ah / (y1 - y0) : Infinity);
|
|
437
|
+
const bx = ox + (aw - (x1 - x0) * sx) / 2, by = oy + (ah - (y1 - y0) * sy) / 2;
|
|
438
|
+
for (const n of nodes) { n.px = bx + (n.x - x0) * sx; n.py = by + (n.y - y0) * sy; }
|
|
439
|
+
return [sx, sy];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Achica los radios si la disposición geométrica deja nodos encimados. */
|
|
443
|
+
function shrinkToFit(nodes) {
|
|
444
|
+
let k = 1;
|
|
445
|
+
const cell = 2 * Math.max(...nodes.map((q) => q.r)) + 2;
|
|
446
|
+
forNearPairs(nodes, 'px', 'py', cell, (p, q) => {
|
|
447
|
+
const d = Math.hypot(q.px - p.px, q.py - p.py);
|
|
448
|
+
if (d < (p.r + q.r) * k + 1.5) k = Math.max(0.35, (d - 1.5) / (p.r + q.r));
|
|
449
|
+
});
|
|
450
|
+
if (k < 1) for (const n of nodes) n.r = Math.max(1.5, n.r * k);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function separate(nodes, area, passes = 6) {
|
|
454
|
+
const cell = 2 * Math.max(...nodes.map((q) => q.r)) + 2;
|
|
455
|
+
for (let pass = 0; pass < passes; pass++) collide(nodes, 'px', 'py', 1.5, cell);
|
|
456
|
+
for (const n of nodes) {
|
|
457
|
+
n.px = Math.max(area.x + n.r + 1, Math.min(area.x + area.w - n.r - 1, n.px));
|
|
458
|
+
n.py = Math.max(area.y + n.r + 1, Math.min(area.y + area.h - n.r - 1, n.py));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Etiquetas ──────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
const hitsCircle = (b, c) => {
|
|
465
|
+
const x = Math.max(b[0], Math.min(c.px, b[2])), y = Math.max(b[1], Math.min(c.py, b[3]));
|
|
466
|
+
const dx = c.px - x, dy = c.py - y;
|
|
467
|
+
return dx * dx + dy * dy < (c.r + 0.5) * (c.r + 0.5);
|
|
468
|
+
};
|
|
469
|
+
const hitsBox = (a, b) => !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
|
|
470
|
+
|
|
141
471
|
export function network(spec, theme) {
|
|
142
472
|
const locale = spec.locale || 'es';
|
|
143
473
|
const en = String(locale).startsWith('en');
|
|
144
|
-
const { edges, nodes, byId } = prepareNetwork(spec);
|
|
474
|
+
const { edges, nodes, byId, valueField } = prepareNetwork(spec);
|
|
145
475
|
if (!nodes.length) throw new PlotError('La red no tiene conexiones válidas.', 'Entrega filas con { from, to, value }.');
|
|
146
|
-
const layout = spec.layout
|
|
476
|
+
const layout = layoutName(spec.layout);
|
|
147
477
|
|
|
148
478
|
// Grupos en orden de peso total (color estable por entidad).
|
|
149
479
|
const gtot = new Map();
|
|
150
480
|
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));
|
|
481
|
+
const groups = [...gtot.keys()].sort((a, b) => gtot.get(b) - gtot.get(a) || (a < b ? -1 : a > b ? 1 : 0));
|
|
482
|
+
for (const n of nodes) n.gi = groups.indexOf(n.group ?? '');
|
|
152
483
|
const hasGroups = groups.length > 1 || (groups.length === 1 && groups[0] !== '');
|
|
153
484
|
const cmap = colorMap(groups, theme, spec.colors);
|
|
154
485
|
const colorOf = (n) => (spec.colors && spec.colors[n.id]) || (hasGroups ? cmap.get(n.group ?? '') : theme.categorical[0]);
|
|
@@ -157,184 +488,216 @@ export function network(spec, theme) {
|
|
|
157
488
|
|
|
158
489
|
const legend = hasGroups && spec.legend !== false ? groups.map((k) => ({ label: k || (en ? 'No group' : 'Sin grupo'), color: cmap.get(k) })) : [];
|
|
159
490
|
const W = spec.width || 720;
|
|
160
|
-
|
|
491
|
+
// El círculo rotula hacia afuera en todas direcciones: con muchos nodos pide más alto.
|
|
492
|
+
const tall = layout === 'circular' && nodes.length > 30;
|
|
493
|
+
const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(W * 1.05 + 150) : tall ? 600 : 520 });
|
|
161
494
|
const area = frame.layout(legend);
|
|
495
|
+
const wide = area.w >= area.h;
|
|
162
496
|
|
|
163
|
-
// Radios: área ∝ peso,
|
|
497
|
+
// Radios: área ∝ peso, de 2,5 px hasta un máximo que depende del espacio por nodo (≤ 30).
|
|
164
498
|
const maxW = Math.max(...nodes.map((n) => n.w), 1e-9);
|
|
165
|
-
const rMax = spec.maxRadius ?? Math.max(
|
|
166
|
-
const rMin = Math.min(
|
|
167
|
-
|
|
499
|
+
const rMax = spec.maxRadius ?? Math.max(8, Math.min(30, Math.sqrt((area.w * area.h) / nodes.length) * 0.32));
|
|
500
|
+
const rMin = Math.min(2.5, rMax / 2);
|
|
501
|
+
// En anillos concéntricos los mayores van juntos al centro: algo más chicos, para que se lean los anillos.
|
|
502
|
+
const rTop = layout === 'concentric' && spec.maxRadius === undefined ? rMax * 0.75 : rMax;
|
|
503
|
+
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; }
|
|
168
504
|
|
|
169
|
-
//
|
|
505
|
+
// Foco (clic en un nodo): va al centro y se destacan sus vecinos, con nombre.
|
|
506
|
+
const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) ?? null : null;
|
|
507
|
+
const hl = focusNode ? new Set([focusNode.id]) : spec.highlight === undefined || spec.highlight === null ? null : new Set([].concat(spec.highlight).map(String));
|
|
508
|
+
const near = new Set();
|
|
509
|
+
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); } }
|
|
510
|
+
const on = (n) => !hl || near.has(n.id);
|
|
511
|
+
|
|
512
|
+
// Etiquetas candidatas.
|
|
170
513
|
const labelSize = (theme.type?.label ?? 12) - 0.5;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
514
|
+
const labelPx = labelSize * ctx.textScale;
|
|
515
|
+
let cand;
|
|
516
|
+
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)); }
|
|
517
|
+
else if (spec.labels === false) cand = [];
|
|
518
|
+
else if (typeof spec.labels === 'number') cand = [...nodes].sort(byWeight).slice(0, Math.max(0, spec.labels));
|
|
519
|
+
else if (spec.labels === true || nodes.length <= 14 || layout === 'circular' || layout === 'grid') cand = [...nodes];
|
|
174
520
|
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); }
|
|
521
|
+
const big = nodes.filter((n) => n.r0 >= 0.45 * rTop);
|
|
522
|
+
cand = big.length >= 8 ? big : [...nodes].sort(byWeight).slice(0, 8);
|
|
184
523
|
}
|
|
524
|
+
if (focusNode) { const s = new Set(cand); for (const id of near) s.add(byId.get(id)); cand = [...s]; }
|
|
185
525
|
|
|
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 {
|
|
526
|
+
// ── Disposición + ajuste al área ──────────────────────────────────────────
|
|
527
|
+
const gap = Math.max(3, rMax * 0.25);
|
|
528
|
+
const pad0 = rMax + 4;
|
|
529
|
+
let pad = { l: pad0, r: pad0, t: pad0, b: pad0 }, lim = 2.4, center = !!focusNode, withR = false, geometric = true, tree = null;
|
|
530
|
+
let circ = null;
|
|
531
|
+
if (layout === 'force') {
|
|
201
532
|
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
|
-
}
|
|
533
|
+
forceLayout(nodes, edges, byId, { spacing, pin: focusNode, wide, iterations: spec.iterations ?? (nodes.length > 1000 ? 140 : nodes.length > 200 ? 200 : 320) });
|
|
534
|
+
withR = true; geometric = false;
|
|
535
|
+
} else if (layout === 'circular') {
|
|
536
|
+
circularLayout(nodes, { pin: focusNode });
|
|
537
|
+
const ringLab = cand.filter((n) => n.ring);
|
|
538
|
+
const cap = Math.min(130, Math.min(area.w, area.h) * (area.w < 480 ? 0.17 : nodes.length > 40 ? 0.19 : 0.24));
|
|
539
|
+
const lw = Math.min(cap, Math.max(0, ...ringLab.map((n) => measure(n.label, labelSize, 500))));
|
|
540
|
+
const rGuess = Math.min(rMax, 12);
|
|
541
|
+
const p = (ringLab.length ? lw + 6 : 0) + rGuess + 2;
|
|
542
|
+
pad = { l: p, r: p, t: p, b: p };
|
|
543
|
+
circ = { lw: Math.max(24, lw) };
|
|
544
|
+
lim = 1.5; center = true;
|
|
545
|
+
} else if (layout === 'radial') {
|
|
546
|
+
tree = radialLayout(nodes, edges, byId, { pin: focusNode, gap });
|
|
547
|
+
lim = 1.4; center = true;
|
|
548
|
+
} else if (layout === 'concentric') {
|
|
549
|
+
concentricLayout(nodes, { pin: focusNode, gap });
|
|
550
|
+
lim = 1.35; center = true;
|
|
551
|
+
} else if (layout === 'clusters') {
|
|
552
|
+
const aspect = Math.max(1, Math.min(1.9, area.w / area.h));
|
|
553
|
+
clustersLayout(nodes, groups.length, { pin: focusNode, gap, aspect });
|
|
554
|
+
lim = 1.15; withR = true; geometric = false;
|
|
555
|
+
} else if (layout === 'tree') {
|
|
556
|
+
tree = treeLayout(nodes, edges, byId, { pin: focusNode, wide });
|
|
557
|
+
lim = Infinity; center = false;
|
|
558
|
+
if (wide) {
|
|
559
|
+
// Las hojas del último nivel rotulan a la derecha: se les deja espacio.
|
|
560
|
+
const last = cand.filter((n) => n.x === Math.max(...nodes.map((q) => q.x)));
|
|
561
|
+
const lw = Math.min(area.w * 0.18, Math.max(0, ...last.map((n) => measure(n.label, labelSize, 500))));
|
|
562
|
+
pad = { ...pad, r: pad0 + (lw ? lw + 6 : 0) };
|
|
563
|
+
} else pad = { ...pad, b: pad0 + labelPx + 4 };
|
|
564
|
+
} else if (layout === 'grid') {
|
|
565
|
+
gridLayout(nodes, { pin: focusNode, aw: area.w, ah: area.h });
|
|
566
|
+
lim = Infinity; center = false;
|
|
567
|
+
// Nombres centrados bajo cada nodo: los de los bordes necesitan medio ancho de margen.
|
|
568
|
+
const half = Math.min(area.w * 0.12, Math.max(0, ...cand.map((n) => measure(n.label, labelSize, 500) / 2)));
|
|
569
|
+
pad = { l: Math.max(pad0, half + 2), r: Math.max(pad0, half + 2), t: pad0, b: pad0 + labelPx + 2 };
|
|
245
570
|
}
|
|
571
|
+
fitToArea(nodes, area, { pad, lim, center, withR });
|
|
572
|
+
if (geometric) shrinkToFit(nodes);
|
|
573
|
+
separate(nodes, area, layout === 'circular' || layout === 'grid' || layout === 'tree' ? 2 : 6);
|
|
574
|
+
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;
|
|
575
|
+
const body = [];
|
|
246
576
|
|
|
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.
|
|
577
|
+
// ── Conexiones: curvas del color del nodo de origen, ancho ∝ √peso ───────
|
|
254
578
|
const maxE = Math.max(...edges.map((e) => e.v), 1e-9);
|
|
255
|
-
const
|
|
579
|
+
const baseOp = edges.length > 150 ? 0.3 : 0.5;
|
|
580
|
+
// Curvatura al estilo ECharts (0,3); en la grilla, más suave para no salirse de las filas.
|
|
581
|
+
const curv = spec.curveness !== undefined && spec.curveness !== null && spec.curveness !== '' && Number.isFinite(+spec.curveness) ? +spec.curveness : layout === 'grid' ? 0.18 : 0.3;
|
|
582
|
+
const P = (x, y) => `${f1(x)},${f1(y)}`;
|
|
256
583
|
const linkG = [];
|
|
257
584
|
for (const e of [...edges].sort((a, b) => a.v - b.v)) {
|
|
258
|
-
const a = byId.get(e.
|
|
259
|
-
const w = 0.
|
|
585
|
+
const a = byId.get(e.src), b = byId.get(e.dst);
|
|
586
|
+
const w = valueField ? 0.6 + 2.4 * Math.sqrt(e.v / maxE) : 1;
|
|
587
|
+
const isTree = tree ? tree.treeEdges.has(e.s + '\u0000' + e.t) : true;
|
|
260
588
|
let d;
|
|
261
|
-
if (layout === '
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
d = `M${a.px
|
|
589
|
+
if (layout === 'tree' && isTree) {
|
|
590
|
+
// Conector de árbol: curva en S entre padre e hijo.
|
|
591
|
+
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)}`; }
|
|
592
|
+
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)}`; }
|
|
593
|
+
} else if (layout === 'circular' && a.ring && b.ring) {
|
|
594
|
+
const mx = (a.px + b.px) / 2, my = (a.py + b.py) / 2, k = Math.min(0.9, (0.55 * curv) / 0.3);
|
|
595
|
+
d = `M${P(a.px, a.py)}Q${P(mx + (cx - mx) * k, my + (cy - my) * k)} ${P(b.px, b.py)}`;
|
|
596
|
+
} else if (curv === 0) {
|
|
597
|
+
d = `M${P(a.px, a.py)}L${P(b.px, b.py)}`;
|
|
265
598
|
} else {
|
|
266
599
|
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)}`;
|
|
600
|
+
const bend = curv * len * (layout === 'radial' && isTree ? 0.35 : 1); // radios del árbol, casi rectos
|
|
601
|
+
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
602
|
}
|
|
271
603
|
const active = hl ? hl.has(e.s) || hl.has(e.t) : true;
|
|
604
|
+
const op = hl ? (active ? 0.8 : 0.06) : !isTree ? 0.15 : baseOp;
|
|
605
|
+
const sw = hl && active ? Math.max(1.2, w) : w;
|
|
606
|
+
// Lo común (relleno, extremos, opacidad y ancho base) va en el <g>; cada arista lleva solo lo propio.
|
|
272
607
|
linkG.push(el('path', {
|
|
273
|
-
class: 'pa-m', d,
|
|
274
|
-
strokeOpacity:
|
|
608
|
+
class: 'pa-m', d, stroke: hl && !active ? theme.muted : colorOf(a),
|
|
609
|
+
strokeOpacity: op === baseOp ? undefined : op, strokeWidth: sw === 1 ? undefined : sw,
|
|
610
|
+
'data-s': e.src, 'data-t': e.dst,
|
|
275
611
|
'data-tip': tip(`${a.label} — ${b.label}`, fmtE(e.v)),
|
|
276
612
|
}));
|
|
277
613
|
}
|
|
278
|
-
|
|
614
|
+
const links = g({ class: 'pa-links', fill: 'none', strokeLinecap: 'round', strokeOpacity: baseOp, strokeWidth: 1 }, linkG);
|
|
279
615
|
|
|
280
|
-
// Nodos: grandes debajo, chicos encima
|
|
616
|
+
// ── Nodos: grandes debajo, chicos encima ──────────────────────────────────
|
|
617
|
+
const ring = nodes.length > 60 ? 0 : 0.75;
|
|
281
618
|
const nodeG = [];
|
|
282
|
-
for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r)) {
|
|
619
|
+
for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r || (a.id < b.id ? -1 : 1))) {
|
|
283
620
|
const lines = [n.label];
|
|
284
621
|
if (hasGroups && n.group) lines.push(n.group);
|
|
285
622
|
lines.push(`${en ? 'Connections' : 'Conexiones'}: ${n.deg}`);
|
|
286
623
|
lines.push(`${n.size !== null ? (en ? 'Size' : 'Tamaño') : en ? 'Weighted degree' : 'Grado ponderado'}: ${fmt(n.w)}`);
|
|
287
624
|
const isF = focusNode && n.id === focusNode.id;
|
|
288
|
-
nodeG.push(el('circle', {
|
|
625
|
+
nodeG.push(el('circle', {
|
|
626
|
+
class: 'pa-m', cx: n.px, cy: n.py, r: n.r, fill: on(n) ? colorOf(n) : theme.dim,
|
|
627
|
+
stroke: isF ? theme.ink : ring ? theme.surface : undefined, strokeWidth: isF ? 2.5 : ring || undefined,
|
|
628
|
+
'data-tip': tip(...lines), 'data-node': n.id, style: 'cursor:pointer',
|
|
629
|
+
}));
|
|
289
630
|
}
|
|
290
|
-
|
|
631
|
+
const nodesSvg = g({ class: 'pa-nodes' }, nodeG);
|
|
291
632
|
|
|
292
|
-
// Etiquetas con halo, sin choques
|
|
293
|
-
const
|
|
633
|
+
// ── Etiquetas: tinta con halo, sin choques con otras etiquetas ni nodos ──
|
|
634
|
+
const prio = (a, b) => (focusNode ? (b === focusNode) - (a === focusNode) : 0) || (hl ? hl.has(b.id) - hl.has(a.id) : 0) || byWeight(a, b);
|
|
635
|
+
const lbls = cand.filter((n) => on(n) || hl?.has(n.id)).sort(prio);
|
|
636
|
+
const placed = [];
|
|
637
|
+
const topIds = new Set([...nodes].sort(byWeight).slice(0, 3).map((n) => n.id));
|
|
638
|
+
const angles = []; // etiquetas radiales ya puestas: [ángulo, medio alto angular]
|
|
294
639
|
const labG = [];
|
|
295
|
-
|
|
296
|
-
const
|
|
640
|
+
const h = labelPx + 1;
|
|
641
|
+
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
642
|
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
|
-
]
|
|
643
|
+
const isF = focusNode && n === focusNode;
|
|
644
|
+
const weight = isF ? 600 : 500;
|
|
645
|
+
const attrs = { fill: theme.ink, fontSize: labelSize, fontWeight: weight, stroke: theme.surface, strokeWidth: 2, paintOrder: 'stroke', strokeLinejoin: 'round', 'data-node': n.id };
|
|
646
|
+
if (circ && n.ring) {
|
|
647
|
+
// En el círculo: radial hacia afuera, girada; a la izquierda, volteada para leerse.
|
|
648
|
+
const a = Math.atan2(n.py - cy, n.px - cx), rr = Math.hypot(n.px - cx, n.py - cy) + n.r + 5;
|
|
649
|
+
const half = (h * 0.5 + 0.5) / rr;
|
|
650
|
+
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;
|
|
651
|
+
angles.push([a, half]);
|
|
652
|
+
const label = truncate(n.label, circ.lw, labelSize, weight);
|
|
653
|
+
const lx = n.px + Math.cos(a) * (n.r + 5), ly = n.py + Math.sin(a) * (n.r + 5);
|
|
654
|
+
const flip = Math.cos(a) < 0;
|
|
655
|
+
const deg = ((a * 180) / Math.PI + (flip ? 180 : 0));
|
|
656
|
+
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)));
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const label = truncate(n.label, 150, labelSize, weight);
|
|
660
|
+
const w = measure(label, labelSize, weight);
|
|
661
|
+
const cands = [
|
|
662
|
+
[n.px + n.r + 5, n.py + labelPx * 0.35, 'start'],
|
|
663
|
+
[n.px - n.r - 5, n.py + labelPx * 0.35, 'end'],
|
|
664
|
+
[n.px, n.py - n.r - 4, 'middle'],
|
|
665
|
+
[n.px, n.py + n.r + labelPx + 1, 'middle'],
|
|
666
|
+
[n.px + n.r * 0.7 + 4, n.py - n.r * 0.7 - 2, 'start'],
|
|
667
|
+
[n.px - n.r * 0.7 - 4, n.py - n.r * 0.7 - 2, 'end'],
|
|
668
|
+
[n.px + n.r * 0.7 + 4, n.py + n.r * 0.7 + labelPx * 0.8, 'start'],
|
|
669
|
+
[n.px - n.r * 0.7 - 4, n.py + n.r * 0.7 + labelPx * 0.8, 'end'],
|
|
670
|
+
];
|
|
671
|
+
// Árbol horizontal: a los nodos con hijos, el nombre arriba o abajo (a la derecha salen sus conectores).
|
|
672
|
+
if (layout === 'tree' && wide && tree.children.get(n.id)?.length) cands.unshift(cands[2], cands[3]);
|
|
673
|
+
// Grilla: el nombre bajo el nodo, centrado en su celda.
|
|
674
|
+
if (layout === 'grid') cands.unshift(cands[3]);
|
|
675
|
+
let done = false, fallback = null;
|
|
676
|
+
// Los tres mayores (y el foco) pueden, en segunda vuelta, pisar nodos chicos: el halo los mantiene legibles.
|
|
677
|
+
const tries = isF || topIds.has(n.id) ? [0, 0.35 * rTop] : [0];
|
|
678
|
+
for (const minR of tries) {
|
|
679
|
+
for (const [lx, ly, anchor] of cands) {
|
|
680
|
+
const bx0 = anchor === 'start' ? lx : anchor === 'end' ? lx - w : lx - w / 2;
|
|
681
|
+
const box = [bx0 - 1, ly - labelPx * 0.85, bx0 + w + 1, ly + labelPx * 0.25];
|
|
682
|
+
if (!inArea(box)) continue;
|
|
683
|
+
if (!fallback) fallback = [lx, ly, anchor, box];
|
|
684
|
+
if (placed.some((b) => hitsBox(box, b))) continue;
|
|
685
|
+
if (nodes.some((q) => q !== n && q.r >= minR && hitsCircle(box, q))) continue;
|
|
686
|
+
placed.push(box);
|
|
687
|
+
labG.push(el('text', { x: lx, y: ly, textAnchor: anchor, ...attrs }, esc(label)));
|
|
688
|
+
done = true;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
if (done) break;
|
|
320
692
|
}
|
|
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;
|
|
693
|
+
// El nodo en foco siempre lleva su nombre.
|
|
694
|
+
if (!done && isF && fallback) {
|
|
695
|
+
placed.push(fallback[3]);
|
|
696
|
+
labG.push(el('text', { x: fallback[0], y: fallback[1], textAnchor: fallback[2], ...attrs }, esc(label)));
|
|
335
697
|
}
|
|
336
698
|
}
|
|
337
|
-
|
|
699
|
+
const labels = g({ class: 'pa-labels' }, labG);
|
|
700
|
+
body.push(g({ class: 'pa-net', 'data-layout': layout }, [links, nodesSvg, labels]));
|
|
338
701
|
|
|
339
702
|
const top = [...nodes].sort((a, b) => b.deg - a.deg || b.w - a.w).slice(0, 3);
|
|
340
703
|
const desc = en
|