plotagents 0.0.0-stage → 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 (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
@@ -0,0 +1,707 @@
1
+ // Red (grafo): nodos y conexiones, con disposición a elección y determinista.
2
+ // El tamaño del nodo es su peso (o su grado ponderado); el color, su grupo.
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';
6
+ import { measure, truncate } from '../text.js';
7
+ import { makeFormat } from '../format.js';
8
+ import { colorMap } from '../theme.js';
9
+ import { ctx } from '../context.js';
10
+ import { ensureData, requireField, num, fieldsOf, fieldKind, PlotError } from '../data.js';
11
+ import { createFrame } from '../frame.js';
12
+
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
+ }
25
+
26
+ /** Prepara nodos y aristas a partir del spec. */
27
+ export function prepareNetwork(spec) {
28
+ const data = ensureData(spec);
29
+ const fields = fieldsOf(data);
30
+ const from = spec.from ?? pick(fields, ['from', 'source', 'origen', 'desde']) ?? fields[0];
31
+ const to = spec.to ?? pick(fields, ['to', 'target', 'destino', 'hasta']) ?? fields[1];
32
+ requireField(data, from, 'from');
33
+ requireField(data, to, 'to');
34
+ const vf = spec.value ?? pick(fields, ['value', 'weight', 'peso', 'valor']) ?? fields.find((f) => f !== from && f !== to && fieldKind(data, f) === 'number');
35
+ if (spec.value) requireField(data, vf, 'value');
36
+
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).
39
+ const emap = new Map();
40
+ for (const r of data) {
41
+ const a = r[from], b = r[to];
42
+ if (a === null || a === undefined || b === null || b === undefined) continue;
43
+ const s = String(a), t = String(b);
44
+ if (s === t) continue;
45
+ const w = vf ? num(r[vf]) : 1;
46
+ if (!(w > 0)) continue;
47
+ const key = s < t ? s + '\u0000' + t : t + '\u0000' + s;
48
+ if (!emap.has(key)) emap.set(key, { s: s < t ? s : t, t: s < t ? t : s, src: s, dst: t, v: 0 });
49
+ emap.get(key).v += w;
50
+ }
51
+ const edges = [...emap.values()];
52
+
53
+ // Nodos: de la tabla `nodes` si viene; si no, de las aristas.
54
+ const nodes = new Map();
55
+ const add = (id) => { if (!nodes.has(id)) nodes.set(id, { id, label: id, group: null, size: null, deg: 0, wdeg: 0 }); return nodes.get(id); };
56
+ if (Array.isArray(spec.nodes) && spec.nodes.length) {
57
+ const nf = fieldsOf(spec.nodes);
58
+ const idF = spec.nodeId ?? pick(nf, ['id', 'name', 'nombre', 'node', 'nodo']) ?? nf[0];
59
+ const labF = spec.nodeLabel ?? pick(nf, ['label', 'etiqueta']) ?? idF;
60
+ const grF = spec.group ?? pick(nf, ['group', 'grupo', 'type', 'tipo', 'category', 'categoria']);
61
+ const szF = spec.size ?? pick(nf, ['size', 'value', 'valor', 'tamaño', 'weight']);
62
+ if (!nf.includes(idF)) throw new PlotError(`El campo "${idF}" (nodeId) no existe en nodes.`, `Campos disponibles: ${nf.join(', ')}`);
63
+ for (const r of spec.nodes) {
64
+ if (r[idF] === null || r[idF] === undefined) continue;
65
+ const n = add(String(r[idF]));
66
+ if (labF && r[labF] !== undefined) n.label = String(r[labF]);
67
+ if (grF && r[grF] !== undefined && r[grF] !== null) n.group = String(r[grF]);
68
+ if (szF && Number.isFinite(num(r[szF]))) n.size = num(r[szF]);
69
+ }
70
+ }
71
+ for (const e of edges) {
72
+ const a = add(e.s), b = add(e.t);
73
+ a.deg++; b.deg++; a.wdeg += e.v; b.wdeg += e.v;
74
+ }
75
+ const list = [...nodes.values()];
76
+ for (const n of list) n.w = n.size ?? n.wdeg;
77
+ return { edges, nodes: list, byId: nodes, valueField: vf };
78
+ }
79
+
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 } = {}) {
174
+ const n = nodes.length;
175
+ // Posiciones iniciales en filotaxis (deterministas), agrupadas por grupo.
176
+ [...nodes].sort(byGroup).forEach((nd, i) => {
177
+ const r = spacing * 0.6 * Math.sqrt(i + 0.5), a = i * GOLDEN;
178
+ nd.x = r * Math.cos(a); nd.y = r * Math.sin(a); nd.vx = 0; nd.vy = 0;
179
+ });
180
+ // Nodo en foco: parte y se queda en el centro; el resto se acomoda a su alrededor.
181
+ if (pin) { const c = { x: pin.x, y: pin.y }; for (const nd of nodes) { nd.x -= c.x; nd.y -= c.y; } }
182
+ const links = edges.map((e) => ({ a: byId.get(e.s), b: byId.get(e.t), v: e.v }));
183
+ const maxV = Math.max(1, ...links.map((l) => l.v));
184
+ let alpha = 1;
185
+ const decay = 1 - Math.pow(0.001, 1 / iterations);
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; });
189
+ for (let it = 0; it < iterations; it++) {
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
+ }
205
+ }
206
+ // Resortes en las conexiones (más fuertes si el peso es mayor).
207
+ for (const l of links) {
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;
209
+ const d = Math.sqrt(dx * dx + dy * dy) || 1e-6;
210
+ const target = spacing * (1.05 - 0.35 * Math.sqrt(l.v / maxV)) + (l.a.r + l.b.r) * 0.8;
211
+ const k = (alpha * (0.35 + 0.65 * Math.sqrt(l.v / maxV))) / Math.max(1, Math.min(l.a.deg, l.b.deg));
212
+ const f = ((d - target) / d) * k;
213
+ const bias = l.a.deg / (l.a.deg + l.b.deg);
214
+ l.b.vx -= dx * f * bias; l.b.vy -= dy * f * bias;
215
+ l.a.vx += dx * f * (1 - bias); l.a.vy += dy * f * (1 - bias);
216
+ }
217
+ // Gravedad suave hacia el centro (mantiene juntos los componentes sueltos).
218
+ for (const p of nodes) { p.vx -= p.x * 0.02 * alpha; p.vy -= p.y * 0.02 * alpha; }
219
+ for (const p of nodes) { p.vx *= 0.6; p.vy *= 0.6; p.x += p.vx; p.y += p.vy; }
220
+ if (pin) { pin.x = 0; pin.y = 0; pin.vx = 0; pin.vy = 0; }
221
+ // Colisión por radio (grilla uniforme: solo pares vecinos).
222
+ for (let pass = 0; pass < 2; pass++) collide(nodes, 'x', 'y', 3, cell);
223
+ if (pin) { pin.x = 0; pin.y = 0; }
224
+ alpha -= alpha * decay;
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 };
289
+ }
290
+
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); });
369
+ });
370
+ }
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
+
471
+ export function network(spec, theme) {
472
+ const locale = spec.locale || 'es';
473
+ const en = String(locale).startsWith('en');
474
+ const { edges, nodes, byId, valueField } = prepareNetwork(spec);
475
+ if (!nodes.length) throw new PlotError('La red no tiene conexiones válidas.', 'Entrega filas con { from, to, value }.');
476
+ const layout = layoutName(spec.layout);
477
+
478
+ // Grupos en orden de peso total (color estable por entidad).
479
+ const gtot = new Map();
480
+ for (const n of nodes) { const k = n.group ?? ''; gtot.set(k, (gtot.get(k) || 0) + n.w); }
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 ?? '');
483
+ const hasGroups = groups.length > 1 || (groups.length === 1 && groups[0] !== '');
484
+ const cmap = colorMap(groups, theme, spec.colors);
485
+ const colorOf = (n) => (spec.colors && spec.colors[n.id]) || (hasGroups ? cmap.get(n.group ?? '') : theme.categorical[0]);
486
+ const fmt = makeFormat(spec.format, locale, nodes.map((n) => n.w));
487
+ const fmtE = makeFormat(spec.format, locale, edges.map((e) => e.v));
488
+
489
+ const legend = hasGroups && spec.legend !== false ? groups.map((k) => ({ label: k || (en ? 'No group' : 'Sin grupo'), color: cmap.get(k) })) : [];
490
+ const W = spec.width || 720;
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 });
494
+ const area = frame.layout(legend);
495
+ const wide = area.w >= area.h;
496
+
497
+ // Radios: área ∝ peso, de 2,5 px hasta un máximo que depende del espacio por nodo (≤ 30).
498
+ const maxW = Math.max(...nodes.map((n) => n.w), 1e-9);
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; }
504
+
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.
513
+ const labelSize = (theme.type?.label ?? 12) - 0.5;
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];
520
+ else {
521
+ const big = nodes.filter((n) => n.r0 >= 0.45 * rTop);
522
+ cand = big.length >= 8 ? big : [...nodes].sort(byWeight).slice(0, 8);
523
+ }
524
+ if (focusNode) { const s = new Set(cand); for (const id of near) s.add(byId.get(id)); cand = [...s]; }
525
+
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') {
532
+ const spacing = Math.max(26, Math.sqrt((area.w * area.h) / nodes.length) * 0.75);
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 };
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 = [];
576
+
577
+ // ── Conexiones: curvas del color del nodo de origen, ancho ∝ √peso ───────
578
+ const maxE = Math.max(...edges.map((e) => e.v), 1e-9);
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)}`;
583
+ const linkG = [];
584
+ for (const e of [...edges].sort((a, b) => a.v - b.v)) {
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;
588
+ let d;
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)}`;
598
+ } else {
599
+ const dx = b.px - a.px, dy = b.py - a.py, len = Math.hypot(dx, dy) || 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)}`;
602
+ }
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.
607
+ linkG.push(el('path', {
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,
611
+ 'data-tip': tip(`${a.label} — ${b.label}`, fmtE(e.v)),
612
+ }));
613
+ }
614
+ const links = g({ class: 'pa-links', fill: 'none', strokeLinecap: 'round', strokeOpacity: baseOp, strokeWidth: 1 }, linkG);
615
+
616
+ // ── Nodos: grandes debajo, chicos encima ──────────────────────────────────
617
+ const ring = nodes.length > 60 ? 0 : 0.75;
618
+ const nodeG = [];
619
+ for (const n of [...nodes].sort((a, b) => (on(a) - on(b)) || b.r - a.r || (a.id < b.id ? -1 : 1))) {
620
+ const lines = [n.label];
621
+ if (hasGroups && n.group) lines.push(n.group);
622
+ lines.push(`${en ? 'Connections' : 'Conexiones'}: ${n.deg}`);
623
+ lines.push(`${n.size !== null ? (en ? 'Size' : 'Tamaño') : en ? 'Weighted degree' : 'Grado ponderado'}: ${fmt(n.w)}`);
624
+ const isF = focusNode && n.id === focusNode.id;
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
+ }));
630
+ }
631
+ const nodesSvg = g({ class: 'pa-nodes' }, nodeG);
632
+
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]
639
+ const labG = [];
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;
642
+ for (const n of lbls) {
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;
692
+ }
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)));
697
+ }
698
+ }
699
+ const labels = g({ class: 'pa-labels' }, labG);
700
+ body.push(g({ class: 'pa-net', 'data-layout': layout }, [links, nodesSvg, labels]));
701
+
702
+ const top = [...nodes].sort((a, b) => b.deg - a.deg || b.w - a.w).slice(0, 3);
703
+ const desc = en
704
+ ? `Network of ${nodes.length} nodes and ${edges.length} connections. Most connected: ${top.map((n) => `${n.label} (${n.deg})`).join(', ')}.`
705
+ : `Red de ${nodes.length} nodos y ${edges.length} conexiones. Más conectados: ${top.map((n) => `${n.label} (${n.deg})`).join(', ')}.`;
706
+ return frame.finish(body, { desc, type: 'network' });
707
+ }