plotagents 0.5.0 → 0.8.0

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