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.
Files changed (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
@@ -1,14 +1,27 @@
1
- // Red (grafo): nodos y conexiones con disposición por fuerzas, determinista.
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
- // La disposición se ajusta al área disponible para usar todo el espacio.
4
- import { el, g, text, tip } from '../svg.js';
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, readable } from '../theme.js';
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
- /** Disposición por fuerzas (velocity Verlet simplificado, al estilo d3-force). */
67
- function forceLayout(nodes, edges, byId, { iterations = 320, spacing = 40, pin = null } = {}) {
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
- const order = [...nodes].sort((a, b) => String(a.group).localeCompare(String(b.group)) || b.w - a.w);
71
- order.forEach((nd, i) => {
72
- const r = spacing * 0.6 * Math.sqrt(i + 0.5), a = i * 2.399963229728653;
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 entre todos los pares (O(n²), suficiente hasta ~400 nodos).
84
- for (let i = 0; i < n; i++) {
85
- const p = nodes[i];
86
- for (let j = i + 1; j < n; j++) {
87
- const q = nodes[j];
88
- let dx = q.x - p.x, dy = q.y - p.y;
89
- let d2 = dx * dx + dy * dy;
90
- if (d2 < 1e-6) { dx = ((i * 7 + j * 13) % 11) / 11 - 0.5; dy = ((i * 5 + j * 3) % 7) / 7 - 0.5; d2 = dx * dx + dy * dy; }
91
- const f = (charge * alpha) / Math.max(d2, spacing * 0.25);
92
- const d = Math.sqrt(d2);
93
- const fx = (dx / d) * f, fy = (dy / d) * f;
94
- p.vx += fx; p.vy += fy; q.vx -= fx; q.vy -= fy;
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
- /** Círculo ordenado por grupo y tamaño. */
132
- function circleLayout(nodes, groups) {
133
- const order = [...nodes].sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group) || b.w - a.w);
134
- const n = order.length;
135
- order.forEach((nd, i) => {
136
- const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
137
- nd.x = Math.cos(a); nd.y = Math.sin(a); nd.angle = a;
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 === 'circle' ? 'circle' : 'force';
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
- const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(W * 1.05 + 150) : 520 });
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, entre 4 y 22 px (más chicos si hay muchos nodos).
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(9, Math.min(22, Math.sqrt((area.w * area.h) / nodes.length) * 0.32));
166
- const rMin = Math.min(4, rMax / 2);
167
- for (const n of nodes) n.r = rMin + (rMax - rMin) * Math.sqrt(Math.max(0, n.w) / maxW);
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
- // Etiquetas: las N mayores (o una lista).
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
- let labelSet;
172
- if (Array.isArray(spec.labels)) labelSet = new Set(spec.labels.map(String));
173
- else if (spec.labels === false) labelSet = new Set();
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 k = typeof spec.labels === 'number' ? spec.labels : nodes.length <= 14 ? nodes.length : layout === 'circle' ? nodes.length : 10;
176
- labelSet = new Set([...nodes].sort((a, b) => b.w - a.w).slice(0, k).map((n) => n.id));
177
- }
178
-
179
- // Foco (clic en un nodo): va al centro y se destacan sus vecinos, con nombre.
180
- const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) : null;
181
- if (focusNode) {
182
- labelSet.add(focusNode.id);
183
- for (const e of edges) { if (e.s === focusNode.id) labelSet.add(e.t); if (e.t === focusNode.id) labelSet.add(e.s); }
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 body = [];
188
- if (layout === 'circle') {
189
- circleLayout(nodes, groups);
190
- const lwFull = Math.max(0, ...nodes.filter((n) => labelSet.has(n.id)).map((n) => measure(n.label, labelSize, 600)));
191
- const lw = Math.min(area.w * (area.w < 480 ? 0.2 : 0.28), lwFull) + 10;
192
- const rGuess = Math.min(rMax, 10);
193
- const R = Math.max(40, Math.min(area.w / 2 - lw - rGuess, area.h / 2 - rGuess - 16));
194
- // En el círculo, el radio de cada nodo no puede superar su espacio en la circunferencia.
195
- const rCap = Math.max(3.5, ((Math.PI * 2 * R) / nodes.length) * 0.4);
196
- if (rCap < rMax) for (const n of nodes) n.r = Math.max(3, n.r * (rCap / rMax));
197
- const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
198
- for (const n of nodes) { n.px = cx + n.x * R; n.py = cy + n.y * R; }
199
- nodes.cx = cx; nodes.cy = cy; nodes.R = R; nodes.lw = lw;
200
- } else {
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
- // Rota la red para que su eje principal siga el lado largo del área (sin foco).
204
- if (!focusNode) {
205
- const mx = nodes.reduce((a, n) => a + n.x, 0) / nodes.length, my = nodes.reduce((a, n) => a + n.y, 0) / nodes.length;
206
- let sxx = 0, syy = 0, sxy = 0;
207
- for (const n of nodes) { const dx = n.x - mx, dy = n.y - my; sxx += dx * dx; syy += dy * dy; sxy += dx * dy; }
208
- let th = 0.5 * Math.atan2(2 * sxy, sxx - syy); // ángulo del eje principal
209
- if (area.h > area.w) th -= Math.PI / 2;
210
- const c = Math.cos(-th), si = Math.sin(-th);
211
- for (const n of nodes) { const dx = n.x - mx, dy = n.y - my; n.x = dx * c - dy * si; n.y = dx * si + dy * c; }
212
- }
213
- const x0 = Math.min(...nodes.map((n) => n.x - n.r)), x1 = Math.max(...nodes.map((n) => n.x + n.r));
214
- const y0 = Math.min(...nodes.map((n) => n.y - n.r)), y1 = Math.max(...nodes.map((n) => n.y + n.r));
215
- const pad = rMax + 4;
216
- const aw = Math.max(10, area.w - pad * 2), ah = Math.max(10, area.h - pad * 2);
217
- let sx = aw / Math.max(1e-6, x1 - x0), sy = ah / Math.max(1e-6, y1 - y0);
218
- // Se permite estirar, pero sin deformar demasiado la red.
219
- const ratio = sx / sy;
220
- const lim = 2.4;
221
- if (ratio > lim) sx = sy * lim; else if (ratio < 1 / lim) sy = sx * lim;
222
- if (focusNode) {
223
- // Escala simétrica alrededor del nodo en foco, que queda en el centro exacto.
224
- const ex = Math.max(...nodes.map((n) => Math.abs(n.x) + n.r), 1e-6), ey = Math.max(...nodes.map((n) => Math.abs(n.y) + n.r), 1e-6);
225
- let fx = aw / 2 / ex, fy = ah / 2 / ey;
226
- if (fx / fy > lim) fx = fy * lim; else if (fy / fx > lim) fy = fx * lim;
227
- const ccx = area.x + area.w / 2, ccy = area.y + area.h / 2;
228
- for (const n of nodes) { n.px = ccx + n.x * fx; n.py = ccy + n.y * fy; }
229
- } else {
230
- const ox = area.x + pad + (aw - (x1 - x0) * sx) / 2, oy = area.y + pad + (ah - (y1 - y0) * sy) / 2;
231
- for (const n of nodes) { n.px = ox + (n.x - x0) * sx; n.py = oy + (n.y - y0) * sy; }
232
- }
233
- // Tras escalar, separa nodos que quedaron encimados.
234
- for (let pass = 0; pass < 6; pass++)
235
- for (let i = 0; i < nodes.length; i++)
236
- for (let j = i + 1; j < nodes.length; j++) {
237
- const p = nodes[i], q = nodes[j];
238
- const dx = q.px - p.px, dy = q.py - p.py, min = p.r + q.r + 2, d2 = dx * dx + dy * dy;
239
- if (d2 < min * min) { const d = Math.sqrt(d2) || 1e-6, s = ((min - d) / d) * 0.5; p.px -= dx * s; p.py -= dy * s; q.px += dx * s; q.py += dy * s; }
240
- }
241
- for (const n of nodes) {
242
- n.px = Math.max(area.x + n.r + 1, Math.min(area.x + area.w - n.r - 1, n.px));
243
- n.py = Math.max(area.y + n.r + 1, Math.min(area.y + area.h - n.r - 1, n.py));
244
- }
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
- // Foco: nodos destacados y sus vecinos.
248
- const hl = focusNode ? new Set([focusNode.id]) : spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
249
- const near = new Set();
250
- if (hl) { for (const id of hl) near.add(id); for (const e of edges) { if (hl.has(e.s)) near.add(e.t); if (hl.has(e.t)) near.add(e.s); } }
251
- const on = (n) => !hl || near.has(n.id);
252
-
253
- // Conexiones: líneas apenas curvas, ancho ∝ √peso.
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 manyLinks = edges.length > 150;
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.s), b = byId.get(e.t);
259
- const w = 0.75 + 3.25 * Math.sqrt(e.v / maxE);
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 === 'circle') {
262
- const mx = (a.px + b.px) / 2, my = (a.py + b.py) / 2;
263
- const qx = mx + (nodes.cx - mx) * 0.55, qy = my + (nodes.cy - my) * 0.55;
264
- d = `M${a.px.toFixed(1)},${a.py.toFixed(1)}Q${qx.toFixed(1)},${qy.toFixed(1)} ${b.px.toFixed(1)},${b.py.toFixed(1)}`;
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 = Math.min(18, len * 0.08);
268
- const qx = (a.px + b.px) / 2 - (dy / len) * bend, qy = (a.py + b.py) / 2 + (dx / len) * bend;
269
- d = `M${a.px.toFixed(1)},${a.py.toFixed(1)}Q${qx.toFixed(1)},${qy.toFixed(1)} ${b.px.toFixed(1)},${b.py.toFixed(1)}`;
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, fill: 'none', stroke: active && hl ? theme.ink : theme.muted,
274
- strokeOpacity: hl ? (active ? 0.55 : 0.08) : manyLinks ? 0.22 : 0.38, strokeWidth: w, strokeLinecap: 'round',
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
- body.push(g({ class: 'pa-links' }, linkG));
614
+ const links = g({ class: 'pa-links', fill: 'none', strokeLinecap: 'round', strokeOpacity: baseOp, strokeWidth: 1 }, linkG);
279
615
 
280
- // Nodos: grandes debajo, chicos encima; anillo del color de fondo.
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', { class: 'pa-m', cx: n.px, cy: n.py, r: n.r, fill: on(n) ? colorOf(n) : theme.dim, stroke: isF ? theme.ink : theme.surface, strokeWidth: isF ? 2.5 : theme.marks?.ring ? Math.min(2, theme.marks.ring) * 0.75 : 1.5, 'data-tip': tip(...lines), 'data-node': n.id, style: 'cursor:pointer' }));
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
- body.push(g({ class: 'pa-nodes' }, nodeG));
631
+ const nodesSvg = g({ class: 'pa-nodes' }, nodeG);
291
632
 
292
- // Etiquetas con halo, sin choques: derecha → izquierda → arriba → abajo.
293
- const placed = nodes.map((n) => [n.px - n.r, n.py - n.r, n.px + n.r, n.py + n.r, n.id, n.r]);
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
- // En foco: primero el nodo central, luego sus vecinos; los nodos atenuados no bloquean etiquetas.
296
- const lbls = nodes.filter((n) => labelSet.has(n.id) && (on(n) || hl?.has(n.id))).sort((a, b) => (hl ? (hl.has(b.id) - hl.has(a.id)) : 0) || b.w - a.w);
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 strong = hl ? hl.has(n.id) : true;
299
- const weight = strong ? 600 : 500;
300
- const label = truncate(n.label, layout === 'circle' ? nodes.lw - 8 : 150, labelSize, weight);
301
- const w = measure(label, labelSize, weight), h = labelSize + 2;
302
- let cands;
303
- if (layout === 'circle') {
304
- const ca = Math.cos(n.angle), sa = Math.sin(n.angle);
305
- const lx = n.px + ca * (n.r + 6), ly = n.py + sa * (n.r + 6) + labelSize * 0.35;
306
- const anchor = Math.abs(ca) < 0.2 ? 'middle' : ca > 0 ? 'start' : 'end';
307
- const yy = Math.abs(ca) < 0.2 ? (sa < 0 ? n.py - n.r - 6 : n.py + n.r + labelSize + 2) : ly;
308
- cands = [[lx, yy, anchor]];
309
- } else {
310
- cands = [
311
- [n.px + n.r + 4, n.py + labelSize * 0.35, 'start'],
312
- [n.px - n.r - 4, n.py + labelSize * 0.35, 'end'],
313
- [n.px, n.py - n.r - 4, 'middle'],
314
- [n.px, n.py + n.r + labelSize + 1, 'middle'],
315
- [n.px + n.r * 0.75 + 3, n.py - n.r * 0.75 - 2, 'start'],
316
- [n.px - n.r * 0.75 - 3, n.py - n.r * 0.75 - 2, 'end'],
317
- [n.px + n.r * 0.75 + 3, n.py + n.r * 0.75 + labelSize, 'start'],
318
- [n.px - n.r * 0.75 - 3, n.py + n.r * 0.75 + labelSize, 'end'],
319
- ];
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
- for (const [lx, ly, anchor] of cands) {
322
- const bx0 = anchor === 'start' ? lx : anchor === 'end' ? lx - w : lx - w / 2;
323
- const box = [bx0 - 1, ly - h + 2, bx0 + w + 1, ly + 3];
324
- if (box[0] < area.x - 2 || box[2] > area.x + area.w + 2 || box[1] < area.y - 4 || box[3] > area.y + area.h + 4) continue;
325
- // No pisa otras etiquetas ni nodos grandes; sobre nodos chicos sí (el halo la mantiene legible).
326
- const hit = placed.some((b) => b[4] !== n.id && (b[4] === null || (b[5] >= 7 && (!hl || near.has(b[4])))) && !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
327
- const must = hl && hl.has(n.id) && lx === cands[cands.length - 1][0] && ly === cands[cands.length - 1][1];
328
- if (hit && layout !== 'circle' && !must) continue;
329
- placed.push([...box, null, 0]);
330
- labG.push(el('text', {
331
- x: lx, y: ly, textAnchor: anchor, fill: hasGroups ? readable(colorOf(n), theme) : theme.ink, fontSize: labelSize, fontWeight: weight,
332
- stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round',
333
- }, label.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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
- body.push(g({ class: 'pa-labels' }, labG));
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