@aurea-uds/react 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/graph-geometria.d.ts +84 -0
- package/dist/graph-geometria.js +437 -0
- package/dist/graph.d.ts +156 -2
- package/dist/graph.js +1118 -74
- package/dist/pure.d.ts +30 -0
- package/dist/pure.js +2 -2
- package/dist/system.d.ts +11 -2
- package/dist/system.js +8 -1
- package/package.json +14 -2
package/dist/graph.js
CHANGED
|
@@ -39,8 +39,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
39
39
|
// Quem importa a folha é o CONSUMIDOR, uma vez, como já importa o `@aurea-uds/core/css`: um
|
|
40
40
|
// `import "…css"` dentro deste módulo quebraria em Node puro e o build daqui é `tsc` só.
|
|
41
41
|
import React from "react";
|
|
42
|
-
import { ReactFlow, ReactFlowProvider, Background, BaseEdge, Handle, Position, applyNodeChanges, getBezierPath } from "@xyflow/react";
|
|
42
|
+
import { ReactFlow, ReactFlowProvider, Background, BaseEdge, EdgeLabelRenderer, EdgeText, Handle, MiniMap, Panel, Position, SelectionMode, applyNodeChanges, getBezierPath, getSmoothStepPath, useOnViewportChange, useReactFlow, useStore } from "@xyflow/react";
|
|
43
43
|
import { cx, useAureaStrings } from "./internal.js";
|
|
44
|
+
import { Icon } from "./system.js";
|
|
45
|
+
import { Badge } from "./markup.js";
|
|
46
|
+
import { IconButton, Toolbar, ToolbarButton, ToolbarSeparator } from "./actions.js";
|
|
47
|
+
import { alinhar, distribuir, subarvores, recolher, remapear, simplificar, pontes, tracado, meioDoTracado, ladoDoTrecho, rotaCruza, bordaNaDirecao, rotulosQueSomem, contornoDeNuvem } from "./graph-geometria.js";
|
|
44
48
|
// ── O layout ─────────────────────────────────────────────────────────────────────────────
|
|
45
49
|
// O motor recebe `x`/`y` PRONTOS: ele não posiciona nada. Sem isto, um grafo sem coordenadas
|
|
46
50
|
// empilha tudo em (0,0) — que foi a medição que quase derrubou a escolha do motor.
|
|
@@ -53,6 +57,23 @@ import { cx, useAureaStrings } from "./internal.js";
|
|
|
53
57
|
// subida é uma biblioteca de layout automático de grafos, a que a referência usa, e ela é DEPENDÊNCIA NOVA: entra com
|
|
54
58
|
// autorização, não de contrabando.
|
|
55
59
|
const LARGURA = 180, ALTURA = 52, VAO_X = 90, VAO_Y = 24;
|
|
60
|
+
// ── AS MEDIDAS DA RODADA 2 (ADR-0065), cada uma com o token que dá o mesmo número ─────────────────
|
|
61
|
+
// O contêiner: a cabeça (botão de fechar + nome) tem `--space-10` (40) e o recheio em volta dos
|
|
62
|
+
// filhos é `--space-4` (16) — a caixa desenhada e o recheio que o arrumador usa são os MESMOS números.
|
|
63
|
+
const CABECA_DO_GRUPO = 40, RECHEIO_DO_GRUPO = 16;
|
|
64
|
+
// A grade do modo de arrumar: `--space-4` (16). As setas andam um passo dela; com Shift, quatro.
|
|
65
|
+
const GRADE = 16;
|
|
66
|
+
// A ponte no cruzamento: meia elipse de raio `--radius-xs` (6) — o tamanho do salto do draw.io.
|
|
67
|
+
const RAIO_DA_PONTE = 6;
|
|
68
|
+
// O desvio de linhas (`@tisoap/react-flow-smart-edge`): folga em volta do nó e malha da busca, as duas
|
|
69
|
+
// `--space-2` (8). Com folga de 16, o vão de 24 entre dois irmãos (`VAO_Y`) fechava de vez, e a linha
|
|
70
|
+
// entre dois vizinhos dava a volta no mapa inteiro (medido na bancada, na arrumação em árvore).
|
|
71
|
+
const FOLGA_DO_DESVIO = 8, MALHA_DO_DESVIO = 8;
|
|
72
|
+
// Abaixo desta escala o texto das linhas some (MNT-12.5 e 19.1): a 60%, o `--text-xs` vira ~8px.
|
|
73
|
+
const ESCALA_DOS_ROTULOS = 0.6;
|
|
74
|
+
// O mapa grande (MNT-19.1) precisa caber na tela: o motor para em 50% por padrão, e um site de 100
|
|
75
|
+
// equipamentos com andares não cabe nisso. Afasta até 10%.
|
|
76
|
+
const ESCALA_MINIMA = 0.1;
|
|
56
77
|
// O VÃO ENTRE COLUNAS CRESCE COM O RÓTULO (A-09, 23/09/2026). Com o rótulo legível, a primeira
|
|
57
78
|
// imagem mostrou o que o preto escondia: "Te1/1/1 ↔ Te1/0/1" é mais largo que 90px e entrava por
|
|
58
79
|
// baixo dos nós. O layout roda também no servidor, onde não há texto para medir, então a largura
|
|
@@ -64,7 +85,9 @@ const LARGURA = 180, ALTURA = 52, VAO_X = 90, VAO_Y = 24;
|
|
|
64
85
|
// `VAO_X`: grafo sem rótulo sai idêntico ao de antes.
|
|
65
86
|
const FONTE_DO_ROTULO = 13, GLIFO_DO_ROTULO = 0.6, FUNDO_X = 8, FOLGA_X = 32;
|
|
66
87
|
const vaoEntreColunas = (edges) => Math.max(VAO_X, ...edges.map(e => e.label ? Math.ceil(e.label.length * FONTE_DO_ROTULO * GLIFO_DO_ROTULO) + FUNDO_X + FOLGA_X : 0));
|
|
67
|
-
|
|
88
|
+
const medidasDoNo = (texto) => texto === "lg"
|
|
89
|
+
? { larg: Math.round(LARGURA * 16 / 14), alt: Math.round(ALTURA * 16 / 14) } : { larg: LARGURA, alt: ALTURA };
|
|
90
|
+
function dispor(nodes, edges, orientacao = "horizontal", m = { larg: LARGURA, alt: ALTURA }) {
|
|
68
91
|
const entram = new Map();
|
|
69
92
|
for (const n of nodes)
|
|
70
93
|
entram.set(n.id, []);
|
|
@@ -90,14 +113,33 @@ function dispor(nodes, edges) {
|
|
|
90
113
|
};
|
|
91
114
|
for (const n of nodes)
|
|
92
115
|
calcular(n.id);
|
|
93
|
-
const vaoX = vaoEntreColunas(edges);
|
|
94
116
|
const ocupacao = new Map();
|
|
95
117
|
const posicoes = new Map();
|
|
118
|
+
// DE CIMA PARA BAIXO (MNT-06): a profundidade vira a LINHA, e os irmãos ficam lado a lado. Cada
|
|
119
|
+
// camada se centra na mais larga — a raiz no meio e os filhos equilibrados, como pede a MNT-11.4.
|
|
120
|
+
// O vão entre camadas é o `VAO_X` de sempre: o rótulo da linha fica deitado no meio dela, e a
|
|
121
|
+
// largura dele não pede mais altura. Esquerda→direita continua EXATAMENTE como era.
|
|
122
|
+
if (orientacao === "vertical") {
|
|
123
|
+
const porCamada = new Map();
|
|
124
|
+
for (const n of nodes) {
|
|
125
|
+
const d = profundidade.get(n.id) ?? 0;
|
|
126
|
+
porCamada.set(d, [...(porCamada.get(d) ?? []), n.id]);
|
|
127
|
+
}
|
|
128
|
+
const maior = Math.max(...[...porCamada.values()].map(l => l.length), 1);
|
|
129
|
+
const passo = m.larg + VAO_Y;
|
|
130
|
+
for (const [d, ids] of porCamada) {
|
|
131
|
+
const recuo = (maior - ids.length) * passo / 2;
|
|
132
|
+
ids.forEach((id, i) => posicoes.set(id, { x: recuo + i * passo, y: d * (m.alt + VAO_X) }));
|
|
133
|
+
}
|
|
134
|
+
const camadas = Math.max(...[...porCamada.keys()], 0) + 1;
|
|
135
|
+
return { posicoes, extensao: { larg: maior * m.larg + (maior - 1) * VAO_Y, alt: camadas * m.alt + (camadas - 1) * VAO_X } };
|
|
136
|
+
}
|
|
137
|
+
const vaoX = vaoEntreColunas(edges);
|
|
96
138
|
for (const n of nodes) {
|
|
97
139
|
const d = profundidade.get(n.id) ?? 0;
|
|
98
140
|
const linha = ocupacao.get(d) ?? 0;
|
|
99
141
|
ocupacao.set(d, linha + 1);
|
|
100
|
-
posicoes.set(n.id, { x: d * (
|
|
142
|
+
posicoes.set(n.id, { x: d * (m.larg + vaoX), y: linha * (m.alt + VAO_Y) });
|
|
101
143
|
}
|
|
102
144
|
// A EXTENSÃO existe para o SERVIDOR: sem ela o `fitView` não tem viewport para caber, e o
|
|
103
145
|
// grafo renderizado fora do navegador sai ancorado no canto, com metade dos nós do lado de
|
|
@@ -109,24 +151,203 @@ function dispor(nodes, edges) {
|
|
|
109
151
|
// interno o acionava sem ser prop de ninguém.
|
|
110
152
|
const colunas = Math.max(...[...ocupacao.keys()], 0) + 1;
|
|
111
153
|
const linhas = Math.max(...[...ocupacao.values()], 1);
|
|
112
|
-
return { posicoes, extensao: { larg: colunas *
|
|
154
|
+
return { posicoes, extensao: { larg: colunas * m.larg + (colunas - 1) * vaoX, alt: linhas * m.alt + (linhas - 1) * VAO_Y } };
|
|
113
155
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
// ── A ARRUMAÇÃO EM CAMADAS (MNT-06 e MNT-11, ADR-0064) ──────────────────────────────────────
|
|
157
|
+
// `layout="layered"` chama o `elkjs` (peer OPCIONAL, EPL-2.0): camadas, MENOS cruzamento de linha,
|
|
158
|
+
// a raiz no topo (`rootId`, restrição FIRST), a camada que o app sugere (`layer`) e as portas no
|
|
159
|
+
// lado declarado. Ele entra por `import()` só quando pedido — quem não usa não baixa os ~465 KB — e
|
|
160
|
+
// roda depois do primeiro desenho: até a resposta, e no servidor, vale a arrumação simples acima.
|
|
161
|
+
// Sem o pacote instalado, o mapa avisa uma vez no console e fica na simples: não quebra a tela.
|
|
162
|
+
const LADO_ELK = { top: "NORTH", right: "EAST", bottom: "SOUTH", left: "WEST" };
|
|
163
|
+
// ⚠ 10/10/2026, rodada 1: a porta ia com ORDEM FIXA (`FIXED_ORDER`), e não só com o lado — o arrumador
|
|
164
|
+
// livre reordenava as portas por dentro e a "porta 1" à esquerda ia ao vizinho da direita (as linhas se
|
|
165
|
+
// cruzavam, medido na bancada). Na rodada 2 a ordem virou POSIÇÃO FIXA (`FIXED_POS`): a rota que o
|
|
166
|
+
// arrumador devolve tem de nascer EXATAMENTE na alça desenhada, e a alça fica na fração do lado.
|
|
167
|
+
// E no nó com porta, a linha SEM porta vai a uma porta "de base", no meio do lado de entrada ou de
|
|
168
|
+
// saída: medido no protótipo, sem ela o arrumador ligava a linha no CANTO do nó (o 0,0 da caixa).
|
|
169
|
+
let avisouSemElk = false;
|
|
170
|
+
let elkNaTela = null;
|
|
171
|
+
// FORA DA TELA PRINCIPAL (MNT-19.1): o `elk.bundled.js` roda na própria tela (é um "falso trabalhador").
|
|
172
|
+
// Com `layoutWorker`, o APP entrega o trabalhador de verdade — o jeito de criar um trabalhador muda de
|
|
173
|
+
// empacotador para empacotador, e copiar o do `elkjs` para dentro do nosso pacote seria redistribuí-lo
|
|
174
|
+
// (decisão do Victor, 10/10/2026). A receita de cada empacotador está no README.
|
|
175
|
+
async function motorElk(trabalhador) {
|
|
176
|
+
try {
|
|
177
|
+
if (trabalhador) {
|
|
178
|
+
const ELK = (await import("elkjs/lib/elk-api.js")).default;
|
|
179
|
+
return new ELK({ workerFactory: () => trabalhador() });
|
|
180
|
+
}
|
|
181
|
+
elkNaTela ??= import("elkjs/lib/elk.bundled.js").then(m => new m.default());
|
|
182
|
+
return await elkNaTela;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
elkNaTela = null;
|
|
186
|
+
if (!avisouSemElk) {
|
|
187
|
+
avisouSemElk = true;
|
|
188
|
+
console.warn("DependencyGraph: layout=\"layered\", \"tree\" e \"radial\" precisam do pacote `elkjs` (peer opcional). Ficou a arrumação simples.");
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const PORTA_DE_ENTRADA = "\u0000entra", PORTA_DE_SAIDA = "\u0000sai";
|
|
194
|
+
async function arrumarComElk(elk, e) {
|
|
195
|
+
const vertical = e.orientacao === "vertical";
|
|
196
|
+
const hierarquia = e.algoritmo === "layered" && e.grupos.length > 0;
|
|
197
|
+
const ids = new Set(e.folhas.map(f => f.id)), grupos = new Set(hierarquia ? e.grupos.map(g => g.id) : []);
|
|
198
|
+
const existe = (x) => ids.has(x) || grupos.has(x);
|
|
199
|
+
let arestas = e.arestas.filter(a => existe(a.de) && existe(a.para));
|
|
200
|
+
if (e.algoritmo !== "layered") {
|
|
201
|
+
// A árvore geradora: a primeira linha que alcança cada nó, em largura, a partir da raiz.
|
|
202
|
+
const raizes = new Set([...e.folhas.filter(f => f.raiz).map(f => f.id), ...e.folhas.filter(f => !arestas.some(a => a.para === f.id)).map(f => f.id)]);
|
|
203
|
+
const visto = new Set(raizes), fila = [...raizes], arvore = [];
|
|
204
|
+
while (fila.length || visto.size < ids.size) {
|
|
205
|
+
if (!fila.length) {
|
|
206
|
+
const solto = e.folhas.find(f => !visto.has(f.id));
|
|
207
|
+
visto.add(solto.id);
|
|
208
|
+
fila.push(solto.id);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const n = fila.shift();
|
|
212
|
+
for (const a of arestas) {
|
|
213
|
+
const outro = a.de === n ? a.para : a.para === n ? a.de : null;
|
|
214
|
+
if (outro && ids.has(outro) && !visto.has(outro)) {
|
|
215
|
+
visto.add(outro);
|
|
216
|
+
fila.push(outro);
|
|
217
|
+
arvore.push({ ...a, de: n, para: outro, portaDe: undefined, portaPara: undefined });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
arestas = arvore;
|
|
222
|
+
}
|
|
223
|
+
const comPortas = new Set(e.algoritmo === "layered" ? e.folhas.filter(f => f.portas.length).map(f => f.id) : []);
|
|
224
|
+
const noElk = (f) => {
|
|
225
|
+
const opcoes = {};
|
|
226
|
+
if (f.raiz && e.algoritmo === "layered")
|
|
227
|
+
opcoes["elk.layered.layering.layerConstraint"] = "FIRST";
|
|
228
|
+
if (f.camada != null && e.algoritmo === "layered")
|
|
229
|
+
opcoes["elk.layered.layering.layerChoiceConstraint"] = String(f.camada);
|
|
230
|
+
if (!comPortas.has(f.id))
|
|
231
|
+
return { id: f.id, width: f.larg, height: f.alt, layoutOptions: opcoes };
|
|
232
|
+
opcoes["elk.portConstraints"] = "FIXED_POS";
|
|
233
|
+
const porLado = new Map();
|
|
234
|
+
for (const p of f.portas)
|
|
235
|
+
porLado.set(p.side, [...(porLado.get(p.side) ?? []), p]);
|
|
236
|
+
const xy = (s, frac) => s === "left" ? { x: 0, y: f.alt * frac } : s === "right" ? { x: f.larg, y: f.alt * frac } : s === "top" ? { x: f.larg * frac, y: 0 } : { x: f.larg * frac, y: f.alt };
|
|
237
|
+
const porta = (id, s, frac) => ({ id: `${f.id}::${id}`, width: 0, height: 0, ...xy(s, frac), layoutOptions: { "elk.port.side": LADO_ELK[s] } });
|
|
238
|
+
const entra = vertical ? "top" : "left", sai = vertical ? "bottom" : "right";
|
|
239
|
+
return { id: f.id, width: f.larg, height: f.alt, layoutOptions: opcoes,
|
|
240
|
+
ports: [...[...porLado].flatMap(([s, l]) => l.map((p, k) => porta(p.id, s, (k + 1) / (l.length + 1)))), porta(PORTA_DE_ENTRADA, entra, 1 / 2), porta(PORTA_DE_SAIDA, sai, 1 / 2)] };
|
|
241
|
+
};
|
|
242
|
+
const portasValidas = new Set(e.folhas.flatMap(f => f.portas.map(p => `${f.id}::${p.id}`)));
|
|
243
|
+
const ponta = (no, porta, saida) => !comPortas.has(no) ? no
|
|
244
|
+
: porta && portasValidas.has(`${no}::${porta}`) ? `${no}::${porta}` : `${no}::${saida ? PORTA_DE_SAIDA : PORTA_DE_ENTRADA}`;
|
|
245
|
+
// Cada folha DENTRO do contêiner dela. O recheio do contêiner é o mesmo da caixa desenhada.
|
|
246
|
+
const raiz = { id: "\u0000raiz", children: [], edges: [] };
|
|
247
|
+
const caixas = new Map();
|
|
248
|
+
const recheio = `[top=${RECHEIO_DO_GRUPO + CABECA_DO_GRUPO},left=${RECHEIO_DO_GRUPO},bottom=${RECHEIO_DO_GRUPO},right=${RECHEIO_DO_GRUPO}]`;
|
|
249
|
+
if (hierarquia)
|
|
250
|
+
for (const g of e.grupos)
|
|
251
|
+
caixas.set(g.id, { id: g.id, children: [], layoutOptions: { "elk.padding": recheio } });
|
|
252
|
+
const dentro = (pai) => (pai && caixas.get(pai)) || raiz;
|
|
253
|
+
if (hierarquia)
|
|
254
|
+
for (const g of e.grupos)
|
|
255
|
+
dentro(g.pai).children.push(caixas.get(g.id));
|
|
256
|
+
// A raiz pedida vai PRIMEIRO: o círculo e a árvore começam pelo primeiro nó.
|
|
257
|
+
for (const f of [...e.folhas].sort((a, b) => Number(!!b.raiz) - Number(!!a.raiz)))
|
|
258
|
+
dentro(hierarquia ? f.pai : undefined).children.push(noElk(f));
|
|
259
|
+
raiz.edges = arestas.map(a => ({ id: a.id, sources: [ponta(a.de, a.portaDe, true)], targets: [ponta(a.para, a.portaPara, false)] }));
|
|
260
|
+
raiz.layoutOptions = { "elk.json.shapeCoords": "ROOT", "elk.json.edgeCoords": "ROOT", "elk.spacing.nodeNode": String(VAO_Y),
|
|
261
|
+
...(e.algoritmo === "layered" ? { "elk.algorithm": "layered", "elk.direction": vertical ? "DOWN" : "RIGHT",
|
|
262
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(VAO_X),
|
|
263
|
+
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
|
|
264
|
+
...(hierarquia ? { "elk.hierarchyHandling": "INCLUDE_CHILDREN" } : {}) }
|
|
265
|
+
: e.algoritmo === "mrtree" ? { "elk.algorithm": "mrtree", "elk.direction": vertical ? "DOWN" : "RIGHT" }
|
|
266
|
+
: { "elk.algorithm": "radial" }) };
|
|
267
|
+
let r;
|
|
268
|
+
try {
|
|
269
|
+
r = await elk.layout(raiz);
|
|
270
|
+
}
|
|
271
|
+
catch (erro) {
|
|
272
|
+
console.warn("DependencyGraph: o arrumador recusou o mapa; ficou a arrumação simples.", erro);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const posicoes = new Map(), rotas = new Map();
|
|
276
|
+
const percorrer = (n) => {
|
|
277
|
+
for (const c of n.children ?? []) {
|
|
278
|
+
posicoes.set(c.id, { x: c.x ?? 0, y: c.y ?? 0 });
|
|
279
|
+
percorrer(c);
|
|
280
|
+
}
|
|
281
|
+
// A rota só vale no `layered`: a árvore devolve diagonais tortas, e o círculo, retas que o mapa já traça.
|
|
282
|
+
if (e.algoritmo === "layered")
|
|
283
|
+
for (const a of (n.edges ?? []))
|
|
284
|
+
if (a.sections?.length)
|
|
285
|
+
rotas.set(a.id, simplificar(a.sections.flatMap(s => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint])));
|
|
286
|
+
};
|
|
287
|
+
percorrer(r);
|
|
288
|
+
return { posicoes, rotas };
|
|
289
|
+
}
|
|
290
|
+
// ── AS ALÇAS: o lado de sempre, e as portas (MNT-06 e MNT-14) ─────────────────────────────────
|
|
291
|
+
// Sem porta, a linha entra por um lado e sai pelo oposto: esquerda→direita no horizontal, cima→
|
|
292
|
+
// baixo no vertical. Com porta, cada uma fica no lado dela, repartindo o lado em partes iguais. As
|
|
293
|
+
// alças entram também na lista do nó (`handles`) para a linha existir sem medir — no servidor e no
|
|
294
|
+
// modo de leitura, em que a alça não é desenhada (ver o nó, abaixo).
|
|
295
|
+
const LADO = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
|
|
296
|
+
function alcasDoNo(n, orientacao, m) {
|
|
297
|
+
const entra = orientacao === "vertical" ? Position.Top : Position.Left;
|
|
298
|
+
const sai = orientacao === "vertical" ? Position.Bottom : Position.Right;
|
|
299
|
+
const ponto = (pos, frac) => ({ frac, ...(pos === Position.Left ? { x: 0, y: m.alt * frac } : pos === Position.Right ? { x: m.larg, y: m.alt * frac }
|
|
300
|
+
: pos === Position.Top ? { x: m.larg * frac, y: 0 } : { x: m.larg * frac, y: m.alt }) });
|
|
301
|
+
const base = [{ type: "target", position: entra, ...ponto(entra, 1 / 2) }, { type: "source", position: sai, ...ponto(sai, 1 / 2) }];
|
|
302
|
+
const porLado = new Map();
|
|
303
|
+
for (const p of n.ports ?? [])
|
|
304
|
+
porLado.set(p.side, [...(porLado.get(p.side) ?? []), p]);
|
|
305
|
+
const portas = [];
|
|
306
|
+
for (const [lado, lista] of porLado)
|
|
307
|
+
lista.forEach((p, k) => {
|
|
308
|
+
const pos = LADO[lado], xy = ponto(pos, (k + 1) / (lista.length + 1));
|
|
309
|
+
// A porta é as duas coisas: o cabo de rede não tem sentido, e a linha pode sair ou chegar nela.
|
|
310
|
+
portas.push({ id: p.id, type: "source", position: pos, ...xy, rotulo: p.label }, { id: p.id, type: "target", position: pos, ...xy, rotulo: p.label });
|
|
311
|
+
});
|
|
312
|
+
return [...base, ...portas];
|
|
313
|
+
}
|
|
314
|
+
/** Onde a alça fica NO MAPA, com a caixa medida (a fração do lado vale para qualquer tamanho). */
|
|
315
|
+
const pontoDaAlca = (c, a) => a.position === Position.Left ? { x: c.x, y: c.y + c.alt * a.frac } : a.position === Position.Right ? { x: c.x + c.larg, y: c.y + c.alt * a.frac }
|
|
316
|
+
: a.position === Position.Top ? { x: c.x + c.larg * a.frac, y: c.y } : { x: c.x + c.larg * a.frac, y: c.y + c.alt };
|
|
317
|
+
const SETAS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
|
318
|
+
/**
|
|
319
|
+
* O botão de fechar e abrir (MNT-16.4 e 16.7), no canto de baixo à direita — fora do nome, longe do
|
|
320
|
+
* selo de estado (embaixo, à esquerda) e da alça (no meio do lado). Fechado, ele mostra QUANTOS o nó
|
|
321
|
+
* guarda ("+12"), o "número no canto" do mercado; aberto, um traço. O nome diz o que ele faz.
|
|
322
|
+
*/
|
|
323
|
+
// O clique NÃO sobe até a caixa do motor: no modo de arrumar, ela escolheria o nó junto (medido na bancada).
|
|
324
|
+
function BotaoDeRecolher({ r, rotulo }) {
|
|
325
|
+
return _jsx("button", { type: "button", className: "graph-node-toggle nodrag nopan", "aria-expanded": !r.fechado, "aria-label": `${r.fechado ? r.abrir : r.fechar} ${rotulo}${r.fechado ? ` (${r.n} ${r.ocultos})` : ""}`, onClick: e => { e.stopPropagation(); r.alternar(); }, children: r.fechado ? _jsxs("span", { "aria-hidden": "true", children: ["+", r.n] }) : _jsx(Icon, { name: "minus", size: "sm" }) });
|
|
128
326
|
}
|
|
129
|
-
const
|
|
327
|
+
const NoAurea = React.memo(function NoAurea({ data, selected, width, height }) {
|
|
328
|
+
const d = data;
|
|
329
|
+
const corpo = _jsxs(_Fragment, { children: [d.icone && _jsx(Icon, { name: d.icone, size: "md", className: "graph-node-icon" }), _jsxs("span", { className: "graph-node-text", children: [_jsx("span", { className: "graph-node-label", children: d.label }), d.kind && _jsx("span", { className: "graph-node-kind", children: d.kind }), d.detail && _jsx("span", { className: "hint", children: d.detail })] })] });
|
|
330
|
+
// As SETAS andam de nó em nó (MNT-19.4) — o mais perto na direção da seta. Só no nó que é botão:
|
|
331
|
+
// nó de leitura não recebe foco, e dar foco a um `<span>` sem papel seria pior que não dar.
|
|
332
|
+
const teclar = (e) => { if (d.aoTeclar && SETAS.has(e.key)) {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
d.aoTeclar(e.key);
|
|
335
|
+
} };
|
|
336
|
+
// A NUVEM (MNT-16.5) é desenho de FUNDO, do tamanho medido do nó: recortar a caixa cortaria o foco.
|
|
337
|
+
const larg = width ?? LARGURA, alt = height ?? ALTURA;
|
|
338
|
+
return _jsxs("div", { className: "graph-node", "data-selected": d.selecionado || selected || undefined, "data-focused": d.focado || undefined, "data-dimmed": d.apagado || undefined, "data-shape": d.nuvem ? "cloud" : undefined, children: [d.alcas.map(a => {
|
|
339
|
+
// A alça de porta fica no ponto dela ao longo do lado, em porcentagem: o nó pode crescer.
|
|
340
|
+
const lugar = a.position === Position.Left || a.position === Position.Right ? { top: `${a.frac * 100}%` } : { left: `${a.frac * 100}%` };
|
|
341
|
+
return _jsx(Handle, { id: a.id, type: a.type, position: a.position, isConnectable: !!d.conectavel, className: cx("graph-handle", !d.conectavel && "graph-handle-oculta"), style: a.id ? lugar : undefined, title: d.conectavel ? a.rotulo : undefined }, `${a.type}:${a.id ?? "lado"}`);
|
|
342
|
+
}), d.nuvem && _jsx("svg", { className: "graph-node-cloud", viewBox: `0 0 ${larg} ${alt}`, "aria-hidden": "true", children: _jsx("path", { d: contornoDeNuvem(larg, alt) }) }), d.aoSelecionar && !d.editavel
|
|
343
|
+
? _jsx("button", { type: "button", className: cx("graph-node-body", d.icone && "graph-node-has-icon"), "aria-pressed": !!d.selecionado, onClick: d.aoSelecionar, onKeyDown: teclar, children: corpo })
|
|
344
|
+
: _jsx("span", { className: cx("graph-node-body", d.icone && "graph-node-has-icon"), children: corpo }), d.contagem != null && _jsxs("span", { className: "graph-node-count", children: [_jsx(Badge, { size: "xs", variant: "neutral", emphasis: "solid", "aria-hidden": d.nomeDaContagem ? true : undefined, children: d.contagem }), d.nomeDaContagem && _jsx("span", { className: "sr-only", children: d.nomeDaContagem })] }), d.estado && _jsx("span", { className: "graph-node-status", children: _jsx(Badge, { size: "xs", variant: d.estado.tone ?? "neutral", children: d.estado.label }) }), d.fixo && _jsxs("span", { className: "graph-node-pin", children: [_jsx(Icon, { name: "push-pin", size: "sm" }), _jsx("span", { className: "sr-only", children: d.fixo.nome })] }), d.recolher && _jsx(BotaoDeRecolher, { r: d.recolher, rotulo: d.label })] });
|
|
345
|
+
});
|
|
346
|
+
const NoGrupo = React.memo(function NoGrupo({ data, selected }) {
|
|
347
|
+
const d = data;
|
|
348
|
+
return _jsxs("div", { className: "graph-group", "data-selected": selected || undefined, "data-dimmed": d.apagado || undefined, children: [d.alcas.map(a => _jsx(Handle, { type: a.type, position: a.position, isConnectable: false, className: "graph-handle graph-handle-oculta" }, `${a.type}:lado`)), _jsxs("div", { className: "graph-group-head", children: [_jsx("button", { type: "button", className: "graph-group-toggle nodrag nopan", "aria-expanded": "true", "aria-label": `${d.recolher.fechar} ${d.label}`, onClick: e => { e.stopPropagation(); d.recolher.alternar(); }, children: _jsx(Icon, { name: "caret-down", size: "sm" }) }), d.icone && _jsx(Icon, { name: d.icone, size: "sm", className: "graph-node-icon" }), _jsx("span", { className: "graph-group-label", children: d.label }), d.kind && _jsx("span", { className: "graph-node-kind", children: d.kind }), d.fixo && _jsxs("span", { className: "graph-node-pin", children: [_jsx(Icon, { name: "push-pin", size: "sm" }), _jsx("span", { className: "sr-only", children: d.fixo.nome })] })] })] });
|
|
349
|
+
});
|
|
350
|
+
const TIPOS = { aurea: NoAurea, aureaGrupo: NoGrupo };
|
|
130
351
|
// ── A ARESTA, e por que ela também é nossa (A-09 e A-10, 23/09/2026) ─────────────────────────
|
|
131
352
|
// A-09 · O RÓTULO SAÍA PRETO SOBRE PRETO no tema escuro. O core declara `--xy-edge-label-color` e
|
|
132
353
|
// `--xy-edge-label-background-color` desde o H14, e NINGUÉM AS LIA: quem consome essas duas é o
|
|
@@ -138,16 +359,92 @@ const TIPOS = { aurea: NoAurea };
|
|
|
138
359
|
// MESMO caminho e os rótulos se sobrepõem. Cada uma sai de uma altura própria do nó: as `n`
|
|
139
360
|
// paralelas dividem a altura do nó em `n + 1` partes iguais. Uma aresta só fica no meio, que é o
|
|
140
361
|
// desenho de antes. O número vem de `ALTURA`, a do próprio nó — não há distância inventada aqui.
|
|
141
|
-
const ROTULO = { fill: "var(--xy-edge-label-color)", fontSize: "var(--text-xs)" };
|
|
362
|
+
const ROTULO = { fill: "var(--xy-edge-label-color)", fontSize: "var(--graph-edge-label-size,var(--text-xs))" };
|
|
142
363
|
const FUNDO_DO_ROTULO = { fill: "var(--xy-edge-label-background-color)" };
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
364
|
+
// O canto do DEGRAU (MNT-10) é o `--radius-sm` (8): o traçado em ângulo reto dobra arredondado,
|
|
365
|
+
// como o resto da casa, e não em quina viva.
|
|
366
|
+
const CANTO_DO_DEGRAU = 8;
|
|
367
|
+
// O rótulo da PONTA fica a um `--space-1` (4) da alça, do lado de fora da linha.
|
|
368
|
+
const FOLGA_DA_PONTA = 4;
|
|
369
|
+
const pontaDoRotulo = (x, y, pos) => {
|
|
370
|
+
const f = FOLGA_DA_PONTA;
|
|
371
|
+
if (pos === Position.Bottom)
|
|
372
|
+
return `translate(${x + f}px,${y + f}px)`;
|
|
373
|
+
if (pos === Position.Top)
|
|
374
|
+
return `translate(${x + f}px,${y - f}px) translateY(-100%)`;
|
|
375
|
+
if (pos === Position.Right)
|
|
376
|
+
return `translate(${x + f}px,${y - f}px) translateY(-100%)`;
|
|
377
|
+
return `translate(${x - f}px,${y - f}px) translate(-100%,-100%)`;
|
|
378
|
+
};
|
|
379
|
+
const ArestaAurea = React.memo(function ArestaAurea({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, label, data, markerEnd, style }) {
|
|
380
|
+
const d = (data ?? { deslocamento: 0, vertical: false, forma: "curve" });
|
|
381
|
+
// A-10 · as paralelas se afastam na direção ATRAVESSADA à linha: na vertical, para os lados.
|
|
382
|
+
const dx = d.vertical ? d.deslocamento : 0, dy = d.vertical ? 0 : d.deslocamento;
|
|
383
|
+
// COM ROTA (MNT-12.1 e 12.3), o traçado é NOSSO: passa pelos pontos, dobra com o canto da casa e salta
|
|
384
|
+
// nas pontes. A ponta e o lado de cada rótulo da ponta saem do primeiro e do último trecho dela — no
|
|
385
|
+
// arrumador, a linha nasce onde ele a repartiu no lado, e não no meio da alça.
|
|
386
|
+
const rota = d.rota && d.rota.length > 1 ? d.rota : undefined;
|
|
387
|
+
let caminho, lx, ly, ini, fim, ladoIni, ladoFim;
|
|
388
|
+
if (rota) {
|
|
389
|
+
caminho = tracado(rota, CANTO_DO_DEGRAU, d.saltos ? new Map(d.saltos) : undefined, RAIO_DA_PONTE);
|
|
390
|
+
({ x: lx, y: ly } = meioDoTracado(rota));
|
|
391
|
+
ini = rota[0];
|
|
392
|
+
fim = rota[rota.length - 1];
|
|
393
|
+
ladoIni = LADO[ladoDoTrecho(rota[0], rota[1], true)];
|
|
394
|
+
ladoFim = LADO[ladoDoTrecho(rota[rota.length - 2], fim, false)];
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
const pontos = { sourceX: sourceX + dx, sourceY: sourceY + dy, sourcePosition, targetX: targetX + dx, targetY: targetY + dy, targetPosition };
|
|
398
|
+
[caminho, lx, ly] = d.forma === "step" ? getSmoothStepPath({ ...pontos, borderRadius: CANTO_DO_DEGRAU }) : getBezierPath(pontos);
|
|
399
|
+
ini = { x: pontos.sourceX, y: pontos.sourceY };
|
|
400
|
+
fim = { x: pontos.targetX, y: pontos.targetY };
|
|
401
|
+
ladoIni = sourcePosition;
|
|
402
|
+
ladoFim = targetPosition;
|
|
403
|
+
}
|
|
404
|
+
// O rótulo de texto é SVG (sai também no servidor). Quando a linha tem marca, número ou grupo, o
|
|
405
|
+
// rótulo vai para a pastilha de HTML do meio, junto deles — dois desenhos no mesmo ponto se cobririam.
|
|
406
|
+
// O traço DUPLO desenha o miolo por cima da linha — e por cima do rótulo SVG, que sairia riscado
|
|
407
|
+
// (medido na bancada). Com rótulo, ele também vai para a pastilha.
|
|
408
|
+
const pastilha = !!(d.marca || d.contagem || d.grupo || (d.padrao === "double" && label != null));
|
|
409
|
+
const cls = cx("graph-edge", d.padrao && d.padrao !== "solid" && `graph-edge-${d.padrao}`, d.peso && d.peso !== "regular" && `graph-edge-${d.peso}`, d.apagada && "is-dimmed");
|
|
410
|
+
// MNT-12.5: cada rótulo leva quem é, a prioridade e a ordem — o mapa esconde o que encosta.
|
|
411
|
+
const marca = (parte) => ({ "data-edge-label": `${id}${parte}`, "data-priority": d.prioridade ?? 0, "data-order": d.ordem ?? 0 });
|
|
412
|
+
return _jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: caminho, className: cls, markerEnd: markerEnd, style: style }), d.padrao === "double" && _jsx("path", { d: caminho, fill: "none", className: cx("graph-edge-double-core", d.peso && d.peso !== "regular" && `graph-edge-${d.peso}`, d.apagada && "is-dimmed") }), !pastilha && label != null && _jsx(EdgeText, { x: lx, y: ly, label: label, labelStyle: ROTULO, labelShowBg: true, labelBgStyle: FUNDO_DO_ROTULO, className: cx("graph-edge-text", d.apagada && "is-dimmed"), ...marca("") }), (pastilha || d.origem || d.destino) && _jsxs(EdgeLabelRenderer, { children: [pastilha && _jsxs("div", { className: cx("graph-edge-chip nodrag nopan", d.apagada && "is-dimmed"), style: { transform: `translate(-50%,-50%) translate(${lx}px,${ly}px)` }, ...marca(""), children: [d.marca && _jsx("span", { className: `graph-edge-mark graph-edge-mark-${d.marca}`, "aria-hidden": "true", children: d.marca === "lock" && _jsx(Icon, { name: "lock-key", size: "sm" }) }), label != null && _jsx("span", { className: "graph-edge-chip-text", children: label }), d.contagem != null && _jsxs("span", { className: "graph-edge-count", children: ["\u00D7", d.contagem] }), d.grupo && _jsxs("button", { type: "button", className: "graph-edge-group", "aria-expanded": d.grupo.aberto, "aria-label": `${d.grupo.aberto ? d.grupo.juntar : d.grupo.mostrar} ${d.grupo.n} ${d.grupo.conexoes}`, onClick: d.grupo.alternar, children: ["\u00D7", d.grupo.n] })] }), d.origem && _jsx("div", { className: cx("graph-edge-end", d.apagada && "is-dimmed"), style: { transform: pontaDoRotulo(ini.x, ini.y, ladoIni) }, ...marca(":origem"), children: d.origem }), d.destino && _jsx("div", { className: cx("graph-edge-end", d.apagada && "is-dimmed"), style: { transform: pontaDoRotulo(fim.x, fim.y, ladoFim) }, ...marca(":destino"), children: d.destino })] })] });
|
|
413
|
+
});
|
|
148
414
|
const TIPOS_DE_ARESTA = { aurea: ArestaAurea };
|
|
415
|
+
/** A chave de um PAR sem sentido: o cabo entre A e B é o mesmo que entre B e A. */
|
|
416
|
+
const parSemSentido = (e) => e.from < e.to ? `${e.from}|${e.to}` : `${e.to}|${e.from}`;
|
|
417
|
+
/**
|
|
418
|
+
* O que se desenha com `groupParallel` (MNT-12.4): de cada par com duas ou mais linhas, fechado,
|
|
419
|
+
* só a PRIMEIRA, com o número do grupo; aberto, todas, e a primeira leva o botão de juntar.
|
|
420
|
+
*/
|
|
421
|
+
function agruparParalelas(edges, ligado, abertos) {
|
|
422
|
+
if (!ligado)
|
|
423
|
+
return edges.map(item => ({ item }));
|
|
424
|
+
const total = new Map();
|
|
425
|
+
for (const e of edges) {
|
|
426
|
+
const k = parSemSentido(e);
|
|
427
|
+
total.set(k, (total.get(k) ?? 0) + 1);
|
|
428
|
+
}
|
|
429
|
+
const visto = new Set();
|
|
430
|
+
const saida = [];
|
|
431
|
+
for (const e of edges) {
|
|
432
|
+
const k = parSemSentido(e), n = total.get(k);
|
|
433
|
+
if (n < 2) {
|
|
434
|
+
saida.push({ item: e });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const aberto = abertos.has(k), primeira = !visto.has(k);
|
|
438
|
+
visto.add(k);
|
|
439
|
+
if (primeira)
|
|
440
|
+
saida.push({ item: e, grupo: { chave: k, n, aberto } });
|
|
441
|
+
else if (aberto)
|
|
442
|
+
saida.push({ item: e });
|
|
443
|
+
}
|
|
444
|
+
return saida;
|
|
445
|
+
}
|
|
149
446
|
/** Identidade e deslocamento de cada aresta — interno; o teste lê o resultado no DOM. */
|
|
150
|
-
function arestasParalelas(edges) {
|
|
447
|
+
function arestasParalelas(edges, alt = ALTURA) {
|
|
151
448
|
const total = new Map();
|
|
152
449
|
for (const e of edges) {
|
|
153
450
|
const k = `${e.from}->${e.to}`;
|
|
@@ -165,51 +462,330 @@ function arestasParalelas(edges) {
|
|
|
165
462
|
while (usados.has(id))
|
|
166
463
|
id = `${id}#`;
|
|
167
464
|
usados.add(id);
|
|
168
|
-
return { id, deslocamento: n > 1 ?
|
|
465
|
+
return { id, deslocamento: n > 1 ? alt * ((k + 1) / (n + 1) - 1 / 2) : 0 };
|
|
169
466
|
});
|
|
170
467
|
}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
468
|
+
// ── AS ALÇAS DECLARADAS SÓ VALEM ATÉ A MEDIDA (achado na bancada da rodada 1, 10/10/2026) ──────
|
|
469
|
+
// O motor, quando o nó traz `handles`, usa SEMPRE a lista declarada — mesmo depois de medir a tela
|
|
470
|
+
// (`parseHandles`, no `@xyflow/system`). A lista é o que faz o grafo existir no servidor, mas ela
|
|
471
|
+
// foi calculada para o nó de 52 de altura: num nó com endereço, mais alto, a linha nascia DENTRO
|
|
472
|
+
// dele, e o nome da porta na ponta ficava escondido atrás do texto. Medido: os seis nomes de porta
|
|
473
|
+
// cobertos pelo endereço do nó. Depois da primeira medida, a lista sai e vale a borda real.
|
|
474
|
+
const semAlcasDeclaradas = (n) => { if (!n.measured?.width)
|
|
475
|
+
return n; const { handles: _, ...resto } = n; return resto; };
|
|
476
|
+
const EM_PAR = (a) => a ? new Set(a) : null;
|
|
477
|
+
/** O arquivo do draw.io, escrito à mão como o formato documenta (`mxfile > diagram > mxGraphModel`). */
|
|
478
|
+
// As aspas vão por código (\x22, \x27): o leitor de declarações do check 46 não casa aspa solta.
|
|
479
|
+
const XML = (t) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\x22/g, """).replace(/\x27/g, "'");
|
|
480
|
+
const TRACO_DRAWIO = { solid: "", dashed: "dashed=1;dashPattern=8 4;", dotted: "dashed=1;dashPattern=1 4;", double: "shape=link;" };
|
|
481
|
+
const PESO_DRAWIO = { regular: 1, thick: 2, heavy: 3 };
|
|
482
|
+
// Rodada 2 (ADR-0065): o CONTÊINER sai como contêiner do draw.io (`container=1`), com os filhos dentro
|
|
483
|
+
// dele em coordenada RELATIVA — a mesma do motor —; a NUVEM sai com a forma de nuvem do draw.io; e a
|
|
484
|
+
// linha com rota leva as DOBRAS (`Array as="points"`), para o desenho abrir lá como está aqui.
|
|
485
|
+
function paraDrawio(nos, linhas, forma, titulo) {
|
|
486
|
+
const visiveis = nos.filter(n => !n.hidden);
|
|
487
|
+
const ids = new Set(visiveis.map(n => n.id));
|
|
488
|
+
const celulas = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
|
|
489
|
+
for (const n of visiveis) {
|
|
490
|
+
const d = n.data;
|
|
491
|
+
const texto = [d.label, d.kind, typeof d.detail === "string" ? d.detail : undefined].filter(Boolean).join("\n");
|
|
492
|
+
const w = n.width ?? n.measured?.width ?? n.initialWidth ?? LARGURA, h = n.height ?? n.measured?.height ?? n.initialHeight ?? ALTURA;
|
|
493
|
+
const estilo = n.type === "aureaGrupo" ? "rounded=1;arcSize=6;container=1;collapsible=0;verticalAlign=top;align=left;spacingLeft=8;whiteSpace=wrap;html=0;"
|
|
494
|
+
: d.nuvem ? "ellipse;shape=cloud;whiteSpace=wrap;html=0;" : "rounded=1;arcSize=20;whiteSpace=wrap;html=0;";
|
|
495
|
+
const pai = n.parentId && ids.has(n.parentId) ? XML(`n-${n.parentId}`) : "1";
|
|
496
|
+
celulas.push(`<mxCell id="${XML(`n-${n.id}`)}" value="${XML(texto)}" style="${estilo}" vertex="1" parent="${pai}"><mxGeometry x="${Math.round(n.position.x)}" y="${Math.round(n.position.y)}" width="${Math.round(w)}" height="${Math.round(h)}" as="geometry"/></mxCell>`);
|
|
497
|
+
}
|
|
498
|
+
linhas.forEach(({ item: e, rota }, i) => {
|
|
499
|
+
if (!ids.has(e.from) || !ids.has(e.to))
|
|
500
|
+
return;
|
|
501
|
+
const id = `e-${i}`;
|
|
502
|
+
const estilo = `endArrow=none;html=0;${forma === "step" ? "edgeStyle=orthogonalEdgeStyle;rounded=1;" : "curved=1;"}${TRACO_DRAWIO[e.pattern ?? "solid"]}strokeWidth=${PESO_DRAWIO[e.weight ?? "regular"]};`;
|
|
503
|
+
const dobras = rota && rota.length > 2 ? `<Array as="points">${rota.slice(1, -1).map(p => `<mxPoint x="${Math.round(p.x)}" y="${Math.round(p.y)}"/>`).join("")}</Array>` : "";
|
|
504
|
+
celulas.push(`<mxCell id="${id}" value="${XML(e.label ?? "")}" style="${estilo}" edge="1" parent="1" source="${XML(`n-${e.from}`)}" target="${XML(`n-${e.to}`)}"><mxGeometry relative="1" as="geometry">${dobras}</mxGeometry></mxCell>`);
|
|
505
|
+
// O nome da porta na ponta: o rótulo-filho da linha, a 80% do caminho para cada lado (o idioma do draw.io).
|
|
506
|
+
for (const [txt, x, suf] of [[e.sourceLabel, -0.8, "s"], [e.targetLabel, 0.8, "t"]])
|
|
507
|
+
if (txt)
|
|
508
|
+
celulas.push(`<mxCell id="${id}-${suf}" value="${XML(txt)}" style="edgeLabel;html=0;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="${id}"><mxGeometry x="${x}" relative="1" as="geometry"><mxPoint as="offset"/></mxGeometry></mxCell>`);
|
|
509
|
+
});
|
|
510
|
+
return `<mxfile host="Aurea"><diagram name="${XML(titulo)}" id="aurea"><mxGraphModel><root>${celulas.join("")}</root></mxGraphModel></diagram></mxfile>`;
|
|
511
|
+
}
|
|
512
|
+
// ── O DESVIO (MNT-12.1), peer OPCIONAL ──────────────────────────────────────────────────────────
|
|
513
|
+
// Depois de arrumar à mão, a rota do arrumador deixa de valer. A linha que passa POR DENTRO de um nó
|
|
514
|
+
// pede o desvio do `@tisoap/react-flow-smart-edge` (MIT, sem dependência; autorizado pelo Victor em
|
|
515
|
+
// 10/10/2026): só a CONTA dele — a função pura que devolve os pontos —, e não a aresta dele; o desenho
|
|
516
|
+
// continua o nosso, com o canto e as pontes. Roda em lotes pequenos entre um quadro e outro, para a
|
|
517
|
+
// tela não travar num mapa grande. Sem o pacote, a linha fica como está e o console avisa uma vez.
|
|
518
|
+
let desviadorCarregado = null;
|
|
519
|
+
let avisouSemDesvio = false;
|
|
520
|
+
function desviador() {
|
|
521
|
+
desviadorCarregado ??= import("@tisoap/react-flow-smart-edge").catch(() => {
|
|
522
|
+
if (!avisouSemDesvio) {
|
|
523
|
+
avisouSemDesvio = true;
|
|
524
|
+
console.warn("DependencyGraph: o desvio de linhas precisa do pacote `@tisoap/react-flow-smart-edge` (peer opcional). As linhas que cruzam nós ficaram como estão.");
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
});
|
|
528
|
+
return desviadorCarregado;
|
|
529
|
+
}
|
|
530
|
+
const LOTE_DO_DESVIO = 16;
|
|
531
|
+
/** O degrau simples, o mesmo do motor quando os nós estão bem postos: sai, dobra no meio, entra. */
|
|
532
|
+
function esqueleto(s, ls, t, lt) {
|
|
533
|
+
if (ls === Position.Bottom && lt === Position.Top && t.y > s.y) {
|
|
534
|
+
const my = (s.y + t.y) / 2;
|
|
535
|
+
return simplificar([s, { x: s.x, y: my }, { x: t.x, y: my }, t]);
|
|
536
|
+
}
|
|
537
|
+
if (ls === Position.Right && lt === Position.Left && t.x > s.x) {
|
|
538
|
+
const mx = (s.x + t.x) / 2;
|
|
539
|
+
return simplificar([s, { x: mx, y: s.y }, { x: mx, y: t.y }, t]);
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const centroDe = (c) => ({ x: c.x + c.larg / 2, y: c.y + c.alt / 2 });
|
|
544
|
+
const naBorda = (p, c) => p.x >= c.x - 1 && p.x <= c.x + c.larg + 1 && p.y >= c.y - 1 && p.y <= c.y + c.alt + 1
|
|
545
|
+
&& (Math.abs(p.x - c.x) < 1 || Math.abs(p.x - c.x - c.larg) < 1 || Math.abs(p.y - c.y) < 1 || Math.abs(p.y - c.y - c.alt) < 1);
|
|
546
|
+
// O modo de arrumar: o laço é o botão principal na área vazia; a tela anda com o do meio e o direito
|
|
547
|
+
// (e com Espaço + arrastar, o padrão do motor). Shift, Ctrl e Cmd somam à escolha, como no draw.io.
|
|
548
|
+
const BOTOES_DE_ARRASTAR = [1, 2], TECLAS_DE_SOMAR = ["Shift", "Meta", "Control"];
|
|
549
|
+
// Esperar a medida chegar antes de chamar o arrumador: sem isto, ele roda duas vezes no começo.
|
|
550
|
+
const ESPERA_DO_ARRUMADOR = 30;
|
|
551
|
+
// O desfazer guarda os últimos CEM passos, como o draw.io. É PILHA DE COMANDOS (cada passo com o
|
|
552
|
+
// inverso), e não foto do mapa inteiro: o mapa é controlado pelo app, e cada passo — para frente ou
|
|
553
|
+
// para trás — vira um `onLayoutChange` com as posições, que o app guarda (decisão do Victor, 10/10/2026).
|
|
554
|
+
const LIMITE_DO_DESFAZER = 100;
|
|
555
|
+
export function DependencyGraph({ nodes, edges, label, selectedId, onSelect, connectable, onConnect, onNodeMove, height, className, orientation = "horizontal", layout = "simple", rootId, edgeShape = "curve", groupParallel, minimap, controls, legend, focusId, highlight, highlightNeighbors, hiddenIds, onNodeHover, onNodeContextMenu, onEdgeSelect, textSize = "md", highContrast, apiRef, editable, grid = true, onLayoutChange, onSelectionChange, collapsible, onCollapseChange, visibleOnly, layoutWorker, style, ...props }) {
|
|
179
556
|
const s = useAureaStrings();
|
|
557
|
+
const m = React.useMemo(() => medidasDoNo(textSize), [textSize]);
|
|
558
|
+
const vertical = orientation === "vertical";
|
|
559
|
+
const editavel = !!editable;
|
|
560
|
+
const porId = React.useMemo(() => new Map(nodes.map(n => [n.id, n])), [nodes]);
|
|
561
|
+
// ── O CONTÊINER (MNT-16.6): quem está dentro de quem ──────────────────────────────────────
|
|
562
|
+
const pais = React.useMemo(() => {
|
|
563
|
+
const p = new Map();
|
|
564
|
+
for (const n of nodes)
|
|
565
|
+
if (n.parentId && n.parentId !== n.id && porId.has(n.parentId))
|
|
566
|
+
p.set(n.id, n.parentId);
|
|
567
|
+
// Contêiner em ciclo (A dentro de B dentro de A) é defeito do dado; o elo que fecha o ciclo sai.
|
|
568
|
+
for (const id of [...p.keys()]) {
|
|
569
|
+
const visto = new Set([id]);
|
|
570
|
+
for (let q = p.get(id); q; q = p.get(q)) {
|
|
571
|
+
if (visto.has(q)) {
|
|
572
|
+
p.delete(id);
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
visto.add(q);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return p;
|
|
579
|
+
}, [nodes, porId]);
|
|
580
|
+
const contemFilhos = React.useMemo(() => new Set(pais.values()), [pais]);
|
|
581
|
+
// ── O QUE O APP DISSE × O QUE A PESSOA FEZ (MNT-13) ─────────────────────────────────────────
|
|
582
|
+
// O mapa é controlado pelo app (`x`/`y`, `pinned`, `collapsed`), mas a pessoa mexe nele antes de o app
|
|
583
|
+
// guardar. Vale o ÚLTIMO QUE ESCREVEU: o que a pessoa fez vale até o app mudar AQUELE valor — aí vale
|
|
584
|
+
// o do app. Quem devolve o que recebeu pelo `onLayoutChange` não muda nada.
|
|
585
|
+
const [posMao, setPosMao] = React.useState(() => new Map());
|
|
586
|
+
const [fixoMao, setFixoMao] = React.useState(() => new Map());
|
|
587
|
+
const [fechadoMao, setFechadoMao] = React.useState(() => new Map());
|
|
588
|
+
const nodesAntes = React.useRef(nodes);
|
|
589
|
+
React.useEffect(() => {
|
|
590
|
+
const antes = new Map(nodesAntes.current.map(n => [n.id, n]));
|
|
591
|
+
nodesAntes.current = nodes;
|
|
592
|
+
const mudou = (f) => nodes.filter(n => { const a = antes.get(n.id); return !!a && f(a, n); }).map(n => n.id);
|
|
593
|
+
const sem = (ids) => (x) => { if (!ids.some(i => x.has(i)))
|
|
594
|
+
return x; const b = new Map(x); for (const i of ids)
|
|
595
|
+
b.delete(i); return b; };
|
|
596
|
+
setPosMao(sem(mudou((a, n) => a.x !== n.x || a.y !== n.y)));
|
|
597
|
+
setFixoMao(sem(mudou((a, n) => a.pinned !== n.pinned)));
|
|
598
|
+
setFechadoMao(sem(mudou((a, n) => a.collapsed !== n.collapsed)));
|
|
599
|
+
}, [nodes]);
|
|
600
|
+
const fechados = React.useMemo(() => new Set(nodes.filter(n => fechadoMao.get(n.id) ?? n.collapsed).map(n => n.id)), [nodes, fechadoMao]);
|
|
601
|
+
const fixos = React.useMemo(() => new Set(nodes.filter(n => fixoMao.get(n.id) ?? n.pinned).map(n => n.id)), [nodes, fixoMao]);
|
|
602
|
+
// ── O QUE FECHA (MNT-16.4, 16.6 e 16.7) ────────────────────────────────────────────────────
|
|
603
|
+
const dominados = React.useMemo(() => collapsible ? subarvores(nodes.filter(n => !contemFilhos.has(n.id)).map(n => n.id), edges, rootId) : new Map(), [collapsible, nodes, edges, rootId, contemFilhos]);
|
|
604
|
+
const { rep, guardados } = React.useMemo(() => recolher(nodes.map(n => n.id), pais, dominados, fechados, contemFilhos), [nodes, pais, dominados, fechados, contemFilhos]);
|
|
605
|
+
// ABERTO: o contêiner visível, não fechado, com algum filho visível. Todo o resto que se vê é FOLHA.
|
|
606
|
+
const abertosLogicos = React.useMemo(() => new Set([...contemFilhos].filter(c => rep.get(c) === c && !fechados.has(c)
|
|
607
|
+
&& nodes.some(n => pais.get(n.id) === c && rep.get(n.id) === n.id))), [contemFilhos, rep, fechados, nodes, pais]);
|
|
608
|
+
// Na ÁRVORE e no CÍRCULO o contêiner aberto não vira caixa: o arrumador só põe filho dentro de pai na
|
|
609
|
+
// arrumação em camadas, e a caixa em volta de filhos espalhados cobria os outros nós (medido na
|
|
610
|
+
// bancada). Ali os filhos aparecem soltos; fechado, o contêiner continua sendo um nó.
|
|
611
|
+
const abertos = React.useMemo(() => layout === "tree" || layout === "radial" ? new Set() : abertosLogicos, [layout, abertosLogicos]);
|
|
612
|
+
const folhas = React.useMemo(() => nodes.filter(n => rep.get(n.id) === n.id && !abertosLogicos.has(n.id)), [nodes, rep, abertosLogicos]);
|
|
613
|
+
const paiAberto = React.useCallback((id) => { const p = pais.get(id); return p && abertos.has(p) ? p : undefined; }, [pais, abertos]);
|
|
614
|
+
const profundidade = React.useCallback((id) => { let k = 0; for (let q = pais.get(id); q; q = pais.get(q))
|
|
615
|
+
k++; return k; }, [pais]);
|
|
616
|
+
const descendentes = React.useCallback((g) => folhas.filter(n => { for (let q = pais.get(n.id); q; q = pais.get(q))
|
|
617
|
+
if (q === g)
|
|
618
|
+
return true; return false; }).map(n => n.id), [folhas, pais]);
|
|
619
|
+
const desenhaveis = React.useMemo(() => new Set([...folhas.map(n => n.id), ...abertos]), [folhas, abertos]);
|
|
620
|
+
// As linhas depois do fechamento: a de dentro some, a do escondido sai do representante, e as que
|
|
621
|
+
// sobram entre o mesmo par viram UMA com o número. A porta do escondido não existe no representante.
|
|
622
|
+
const remapeadas = React.useMemo(() => remapear(edges, rep).map(({ item, de, para, n }) => de === item.from && para === item.to ? item : { ...item, from: de, to: para,
|
|
623
|
+
fromPort: de === item.from ? item.fromPort : undefined, toPort: para === item.to ? item.toPort : undefined,
|
|
624
|
+
sourceLabel: de === item.from ? item.sourceLabel : undefined, targetLabel: para === item.to ? item.targetLabel : undefined,
|
|
625
|
+
label: n > 1 ? undefined : item.label, count: n > 1 ? n : item.count })
|
|
626
|
+
// A linha que liga num contêiner sem caixa (árvore e círculo) não tem onde chegar: não se desenha.
|
|
627
|
+
.filter(e => desenhaveis.has(e.from) && desenhaveis.has(e.to)), [edges, rep, desenhaveis]);
|
|
628
|
+
const [gruposAbertos, setGruposAbertos] = React.useState(() => new Set());
|
|
629
|
+
const alternarGrupo = React.useCallback((k) => setGruposAbertos(a => { const b = new Set(a); if (b.has(k))
|
|
630
|
+
b.delete(k);
|
|
631
|
+
else
|
|
632
|
+
b.add(k); return b; }), []);
|
|
633
|
+
const desenhadas = React.useMemo(() => agruparParalelas(remapeadas, !!groupParallel, gruposAbertos), [remapeadas, groupParallel, gruposAbertos]);
|
|
634
|
+
const ident = React.useMemo(() => arestasParalelas(desenhadas.map(x => x.item), vertical ? m.larg : m.alt), [desenhadas, vertical, m]);
|
|
635
|
+
// ── AS MEDIDAS (vêm do motor; antes de medir, a estimada) ─────────────────────────────────
|
|
636
|
+
const [medidas, setMedidas] = React.useState(() => new Map());
|
|
637
|
+
const tamanho = React.useCallback((id) => medidas.get(id) ?? m, [medidas, m]);
|
|
638
|
+
// ── A ARRUMAÇÃO AUTOMÁTICA ─────────────────────────────────────────────────────────────────
|
|
180
639
|
// O layout só roda para quem não trouxe coordenada. Quem traz manda — é o que deixa a Parte I
|
|
181
640
|
// guardar a posição que a pessoa arrastou sem brigar com a disposição automática.
|
|
182
|
-
const automatico = React.useMemo(() => dispor(
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
641
|
+
const automatico = React.useMemo(() => dispor(folhas, remapeadas, orientation, m), [folhas, remapeadas, orientation, m]);
|
|
642
|
+
const algoritmo = layout === "layered" ? "layered" : layout === "tree" ? "mrtree" : layout === "radial" ? "radial" : null;
|
|
643
|
+
const entradaElk = React.useMemo(() => algoritmo ? { algoritmo, orientacao: orientation,
|
|
644
|
+
folhas: folhas.map(n => ({ id: n.id, ...tamanho(n.id), portas: n.ports ?? [], camada: n.layer, pai: paiAberto(n.id), raiz: n.id === rootId })),
|
|
645
|
+
grupos: [...abertos].sort((a, b) => profundidade(a) - profundidade(b)).map(id => ({ id, pai: paiAberto(id) })),
|
|
646
|
+
arestas: desenhadas.map((x, i) => ({ id: ident[i].id, de: x.item.from, para: x.item.to, portaDe: x.item.fromPort, portaPara: x.item.toPort })) } : null, [algoritmo, orientation, folhas, tamanho, paiAberto, rootId, abertos, profundidade, desenhadas, ident]);
|
|
647
|
+
const chaveElk = React.useMemo(() => entradaElk ? JSON.stringify(entradaElk) : "", [entradaElk]);
|
|
648
|
+
// A arrumação chega DEPOIS (é assíncrona e o pacote é opcional); até lá, e no servidor, a simples.
|
|
649
|
+
const [elk, setElk] = React.useState(null);
|
|
650
|
+
const trabalhador = React.useRef(layoutWorker);
|
|
651
|
+
trabalhador.current = layoutWorker;
|
|
652
|
+
const motorProprio = React.useRef(null);
|
|
653
|
+
React.useEffect(() => () => { motorProprio.current?.terminateWorker(); motorProprio.current = null; }, []);
|
|
654
|
+
// O mapa cabe na tela de novo quando chega a PRIMEIRA arrumação de um dado novo — e não quando um
|
|
655
|
+
// grupo abre ou fecha, que a câmera pular a cada clique desorienta.
|
|
656
|
+
const [pedidoDeCaber, setPedidoDeCaber] = React.useState(0);
|
|
657
|
+
const dadosCabidos = React.useRef(null);
|
|
658
|
+
React.useEffect(() => {
|
|
659
|
+
if (!entradaElk) {
|
|
660
|
+
setElk(null);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
let vivo = true;
|
|
664
|
+
const espera = setTimeout(async () => {
|
|
665
|
+
let motor;
|
|
666
|
+
if (trabalhador.current) {
|
|
667
|
+
motorProprio.current ??= await motorElk(() => trabalhador.current());
|
|
668
|
+
motor = motorProprio.current;
|
|
669
|
+
}
|
|
670
|
+
else
|
|
671
|
+
motor = await motorElk();
|
|
672
|
+
if (!motor || !vivo)
|
|
673
|
+
return;
|
|
674
|
+
const r = await arrumarComElk(motor, entradaElk);
|
|
675
|
+
if (!vivo || !r)
|
|
676
|
+
return;
|
|
677
|
+
setElk(r);
|
|
678
|
+
if (dadosCabidos.current !== nodes) {
|
|
679
|
+
dadosCabidos.current = nodes;
|
|
680
|
+
setPedidoDeCaber(x => x + 1);
|
|
681
|
+
}
|
|
682
|
+
}, ESPERA_DO_ARRUMADOR);
|
|
683
|
+
return () => { vivo = false; clearTimeout(espera); };
|
|
684
|
+
// `entradaElk` muda junto com a chave; a chave é o que diz se mudou DE VERDADE.
|
|
685
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
686
|
+
}, [chaveElk]);
|
|
687
|
+
const autoDe = React.useCallback((id) => elk?.posicoes.get(id) ?? automatico.posicoes.get(id) ?? { x: 0, y: 0 }, [elk, automatico]);
|
|
688
|
+
// ── ONDE CADA UM ESTÁ (coordenada ABSOLUTA) ──────────────────────────────────────────────────
|
|
689
|
+
const posFinal = React.useMemo(() => {
|
|
690
|
+
const p = new Map();
|
|
691
|
+
for (const n of folhas) {
|
|
692
|
+
const auto = autoDe(n.id), mao = posMao.get(n.id);
|
|
693
|
+
// O `x`/`y` do contêiner não vale: a caixa dele é a dos filhos. Fechado, ele está onde a arrumação o pôs.
|
|
694
|
+
const proprio = contemFilhos.has(n.id) ? undefined : n;
|
|
695
|
+
p.set(n.id, mao ?? { x: proprio?.x ?? auto.x, y: proprio?.y ?? auto.y });
|
|
696
|
+
}
|
|
697
|
+
return p;
|
|
698
|
+
}, [folhas, posMao, autoDe, contemFilhos]);
|
|
699
|
+
const caixas = React.useMemo(() => {
|
|
700
|
+
const c = new Map();
|
|
701
|
+
for (const n of folhas) {
|
|
702
|
+
const p = posFinal.get(n.id), t = tamanho(n.id);
|
|
703
|
+
c.set(n.id, { x: p.x, y: p.y, larg: t.larg, alt: t.alt });
|
|
704
|
+
}
|
|
705
|
+
// O contêiner ABERTO envolve os filhos, mais o recheio e a cabeça — do mais fundo para o de fora.
|
|
706
|
+
for (const g of [...abertos].sort((a, b) => profundidade(b) - profundidade(a))) {
|
|
707
|
+
const filhos = [...c].filter(([id]) => pais.get(id) === g).map(([, x]) => x);
|
|
708
|
+
if (!filhos.length)
|
|
709
|
+
continue;
|
|
710
|
+
const x0 = Math.min(...filhos.map(f => f.x)) - RECHEIO_DO_GRUPO, y0 = Math.min(...filhos.map(f => f.y)) - RECHEIO_DO_GRUPO - CABECA_DO_GRUPO;
|
|
711
|
+
const x1 = Math.max(...filhos.map(f => f.x + f.larg)) + RECHEIO_DO_GRUPO, y1 = Math.max(...filhos.map(f => f.y + f.alt)) + RECHEIO_DO_GRUPO;
|
|
712
|
+
c.set(g, { x: x0, y: y0, larg: x1 - x0, alt: y1 - y0 });
|
|
713
|
+
}
|
|
714
|
+
return c;
|
|
715
|
+
}, [folhas, posFinal, tamanho, abertos, profundidade, pais]);
|
|
716
|
+
// ── O que fica aceso: o caminho pedido, ou o nó escolhido e os vizinhos ────────────────────
|
|
717
|
+
const ocultos = React.useMemo(() => new Set(hiddenIds ?? []), [hiddenIds]);
|
|
718
|
+
const aceso = React.useMemo(() => {
|
|
719
|
+
if (highlight)
|
|
720
|
+
return { nos: EM_PAR(highlight.nodes), arestas: EM_PAR(highlight.edges) };
|
|
721
|
+
if (highlightNeighbors && selectedId) {
|
|
722
|
+
const nos = new Set([selectedId]), arestas = new Set();
|
|
723
|
+
const ident0 = arestasParalelas(edges, vertical ? m.larg : m.alt);
|
|
724
|
+
edges.forEach((e, i) => { if (e.from === selectedId || e.to === selectedId) {
|
|
725
|
+
nos.add(e.from);
|
|
726
|
+
nos.add(e.to);
|
|
727
|
+
arestas.add(ident0[i].id);
|
|
728
|
+
} });
|
|
729
|
+
return { nos, arestas };
|
|
730
|
+
}
|
|
731
|
+
return null;
|
|
732
|
+
}, [highlight, highlightNeighbors, selectedId, edges, vertical, m]);
|
|
733
|
+
// As setas e o "ir até" moram no lado de DENTRO do provedor (eles precisam do motor); o nó chama
|
|
734
|
+
// por esta referência estável, sem refazer a lista de nós a cada render. O botão de fechar também.
|
|
735
|
+
const teclado = React.useRef(undefined);
|
|
736
|
+
const alternar = React.useRef(() => { });
|
|
737
|
+
const nosDoMotor = React.useMemo(() => {
|
|
738
|
+
const lista = [];
|
|
739
|
+
const relativa = (id) => { const c = caixas.get(id), p = paiAberto(id), cp = p ? caixas.get(p) : undefined; return cp ? { x: c.x - cp.x, y: c.y - cp.y } : { x: c.x, y: c.y }; };
|
|
740
|
+
const recolherDe = (id) => ({ fechado: fechados.has(id), n: guardados.get(id) ?? 0, alternar: () => alternar.current(id),
|
|
741
|
+
abrir: s.graphExpand, fechar: s.graphCollapse, ocultos: s.graphHidden });
|
|
742
|
+
const fixoDe = (id) => editavel && fixos.has(id) ? { nome: s.graphPinned } : undefined;
|
|
743
|
+
// O pai vem ANTES dos filhos na lista: é o que o motor pede para o filho andar junto com ele.
|
|
744
|
+
for (const g of [...abertos].filter(x => caixas.has(x)).sort((a, b) => profundidade(a) - profundidade(b))) {
|
|
745
|
+
const n = porId.get(g), c = caixas.get(g), pai = paiAberto(g);
|
|
746
|
+
lista.push({ id: g, type: "aureaGrupo", position: relativa(g), ...(pai ? { parentId: pai } : {}), width: c.larg, height: c.alt,
|
|
747
|
+
draggable: editavel && !fixos.has(g), selectable: editavel, focusable: editavel, hidden: ocultos.has(g),
|
|
748
|
+
ariaLabel: editavel ? [n.label, n.kind].filter(Boolean).join(", ") : undefined,
|
|
749
|
+
data: { label: n.label, kind: n.kind, icone: n.icon, alcas: alcasDoNo({ ...n, ports: undefined }, orientation, { larg: c.larg, alt: c.alt }),
|
|
750
|
+
recolher: recolherDe(g), apagado: aceso?.nos ? !aceso.nos.has(g) : false, fixo: fixoDe(g) } });
|
|
751
|
+
}
|
|
752
|
+
for (const n of folhas) {
|
|
753
|
+
const pai = paiAberto(n.id);
|
|
754
|
+
// O botão de fechar: todo contêiner (fechado, ele é folha) e, com `collapsible`, todo nó com subárvore.
|
|
755
|
+
const recolhivel = contemFilhos.has(n.id) || (!!collapsible && dominados.has(n.id));
|
|
756
|
+
lista.push({ id: n.id, type: "aurea", position: relativa(n.id), ...(pai ? { parentId: pai } : {}),
|
|
757
|
+
draggable: (editavel || !!onNodeMove) && !fixos.has(n.id),
|
|
758
|
+
// `selectable:false` no PRÓPRIO nó, como na aresta: no servidor (a prévia do catálogo) o motor ainda
|
|
759
|
+
// não recebeu o `elementsSelectable={false}` do fluxo, e o nó saía com a classe `.selectable` e a
|
|
760
|
+
// mão de ponteiro — num mapa sem `onSelect`, uma promessa de clique sem teclado. O gate
|
|
761
|
+
// `alvo-clicavel` pegou na página do padrão de rede (10/10/2026).
|
|
762
|
+
// (Rodada 2) No modo de arrumar, escolher é o trabalho: aí ele é escolhível e recebe o foco.
|
|
763
|
+
selectable: editavel, focusable: editavel,
|
|
764
|
+
ariaLabel: editavel ? [n.label, n.kind].filter(Boolean).join(", ") : undefined,
|
|
765
|
+
hidden: ocultos.has(n.id),
|
|
766
|
+
// ── O QUE FAZ ESTE GRAFO EXISTIR FORA DO NAVEGADOR ─────────────────────────────────
|
|
767
|
+
// O motor só desenha nó que tem tamanho, e no servidor não há o que medir. `initialWidth`
|
|
768
|
+
// e `initialHeight` valem SÓ até a primeira medição — no navegador o tamanho real assume
|
|
769
|
+
// e um nó de três linhas deixa de ser aproximado. E sem `handles` declarados a aresta não
|
|
770
|
+
// sabe de onde sai nem onde chega, então ela some.
|
|
771
|
+
//
|
|
772
|
+
// Eu tinha concluído que grafo não cabia em HTML estático e escrevi isso como limite do
|
|
773
|
+
// componente. Era conclusão minha, não medição: o motor tem caminho de SSR documentado, e
|
|
774
|
+
// são estas três coisas. A prévia do catálogo existe por causa deste bloco.
|
|
775
|
+
initialWidth: m.larg, initialHeight: m.alt,
|
|
776
|
+
handles: alcasDoNo(n, orientation, m).map(({ rotulo: _r, frac: _f, ...a }) => a),
|
|
777
|
+
data: { label: n.label, kind: n.kind, detail: n.detail, icone: n.icon, contagem: n.count, nomeDaContagem: n.countLabel, estado: n.status,
|
|
778
|
+
alcas: alcasDoNo(n, orientation, m), selecionado: selectedId === n.id, focado: focusId === n.id,
|
|
779
|
+
apagado: aceso?.nos ? !aceso.nos.has(n.id) : false,
|
|
780
|
+
conectavel: !!connectable, aoSelecionar: onSelect ? () => onSelect(n.id) : undefined,
|
|
781
|
+
aoTeclar: onSelect && !editavel ? (tecla) => teclado.current?.(n.id, tecla) : undefined,
|
|
782
|
+
nuvem: n.shape === "cloud", fixo: fixoDe(n.id), recolher: recolhivel ? recolherDe(n.id) : undefined, editavel },
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return lista;
|
|
786
|
+
}, [abertos, caixas, paiAberto, profundidade, porId, editavel, fixos, ocultos, orientation, aceso, folhas, contemFilhos, collapsible, dominados,
|
|
787
|
+
onNodeMove, m, selectedId, focusId, connectable, onSelect, fechados, guardados, s]);
|
|
788
|
+
// ── A LISTA VIVA DO MOTOR ────────────────────────────────────────────────────────────────────
|
|
213
789
|
// ── A MEDIDA TEM DE PODER VOLTAR, e isto foi achado NO NAVEGADOR ───────────────────────
|
|
214
790
|
// A primeira versão passava `nodes` e nenhum `onNodesChange`. O motor aceita calado e o
|
|
215
791
|
// resultado é um grafo INVISÍVEL: ele só tira o `visibility:hidden` do nó depois de gravar
|
|
@@ -219,21 +795,489 @@ export function DependencyGraph({ nodes, edges, label, selectedId, onSelect, con
|
|
|
219
795
|
// não há layout para medir.
|
|
220
796
|
//
|
|
221
797
|
// A lista interna preserva `measured` quando os dados de fora mudam: sem isso, trocar a
|
|
222
|
-
// seleção jogaria a medida fora e o grafo piscaria a cada clique.
|
|
798
|
+
// seleção jogaria a medida fora e o grafo piscaria a cada clique. (Rodada 2) Preserva também a
|
|
799
|
+
// ESCOLHA do modo de arrumar — e a medida só passa entre nós do MESMO tipo: o contêiner que fecha
|
|
800
|
+
// vira nó comum, e a caixa grande de antes não é a medida dele.
|
|
223
801
|
const [nosVivos, setNosVivos] = React.useState(nosDoMotor);
|
|
224
802
|
React.useEffect(() => {
|
|
225
803
|
setNosVivos(anteriores => {
|
|
226
|
-
const
|
|
227
|
-
return nosDoMotor.map(n =>
|
|
804
|
+
const antes = new Map(anteriores.map(n => [n.id, n]));
|
|
805
|
+
return nosDoMotor.map(n => {
|
|
806
|
+
const a = antes.get(n.id), mesmo = !!a && a.type === n.type;
|
|
807
|
+
return semAlcasDeclaradas({ ...n, measured: mesmo ? a.measured : undefined, selected: editavel && mesmo ? a.selected : undefined });
|
|
808
|
+
});
|
|
228
809
|
});
|
|
229
|
-
}, [nosDoMotor]);
|
|
810
|
+
}, [nosDoMotor, editavel]);
|
|
811
|
+
const confirmar = React.useRef(() => { });
|
|
812
|
+
const abertosAgora = React.useRef(abertos);
|
|
813
|
+
abertosAgora.current = abertos;
|
|
230
814
|
const aoMudarNos = React.useCallback((mudancas) => {
|
|
231
|
-
setNosVivos(anteriores => applyNodeChanges(mudancas, anteriores));
|
|
815
|
+
setNosVivos(anteriores => applyNodeChanges(mudancas, anteriores).map(semAlcasDeclaradas));
|
|
816
|
+
const dims = mudancas.filter(c => c.type === "dimensions" && !!c.dimensions && !abertosAgora.current.has(c.id));
|
|
817
|
+
if (dims.length)
|
|
818
|
+
setMedidas(md => {
|
|
819
|
+
let b = null;
|
|
820
|
+
for (const c of dims)
|
|
821
|
+
if (c.type === "dimensions" && c.dimensions) {
|
|
822
|
+
const v = md.get(c.id), { width: larg, height: alt } = c.dimensions;
|
|
823
|
+
if (!v || Math.abs(v.larg - larg) > 0.5 || Math.abs(v.alt - alt) > 0.5) {
|
|
824
|
+
b ??= new Map(md);
|
|
825
|
+
b.set(c.id, { larg, alt });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return b ?? md;
|
|
829
|
+
});
|
|
830
|
+
// SOLTOU (fim do arraste) ou ANDOU PELA SETA: a mudança de posição que não está mais arrastando.
|
|
831
|
+
const soltos = mudancas.filter((c) => c.type === "position" && !c.dragging && !!c.position);
|
|
832
|
+
if (soltos.length)
|
|
833
|
+
confirmar.current(soltos);
|
|
232
834
|
}, []);
|
|
835
|
+
// ── O DESFAZER (MNT-13.5) E O AVISO PARA O APP GUARDAR (MNT-13.6) ─────────────────────────────
|
|
836
|
+
const pilha = React.useRef({ lista: [], ponteiro: 0 });
|
|
837
|
+
const [, setVersaoDaPilha] = React.useState(0);
|
|
838
|
+
const arrumacao = (pos, fix) => {
|
|
839
|
+
const positions = {};
|
|
840
|
+
for (const n of nodes) {
|
|
841
|
+
if (abertosLogicos.has(n.id))
|
|
842
|
+
continue;
|
|
843
|
+
const p = pos?.get(n.id) ?? posMao.get(n.id) ?? posFinal.get(n.id) ?? (n.x != null && n.y != null ? { x: n.x, y: n.y } : undefined);
|
|
844
|
+
if (p)
|
|
845
|
+
positions[n.id] = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
846
|
+
}
|
|
847
|
+
return { positions, pinned: nodes.filter(n => fix?.get(n.id) ?? fixoMao.get(n.id) ?? n.pinned).map(n => n.id) };
|
|
848
|
+
};
|
|
849
|
+
const aplicar = (pos, fix) => {
|
|
850
|
+
if (pos.size)
|
|
851
|
+
setPosMao(x => { const b = new Map(x); for (const [k, v] of pos)
|
|
852
|
+
b.set(k, v); return b; });
|
|
853
|
+
if (fix?.size)
|
|
854
|
+
setFixoMao(x => { const b = new Map(x); for (const [k, v] of fix)
|
|
855
|
+
b.set(k, v); return b; });
|
|
856
|
+
onLayoutChange?.(arrumacao(pos, fix));
|
|
857
|
+
};
|
|
858
|
+
const executar = (c) => {
|
|
859
|
+
if (!c.depois.size && !c.fixosDepois?.size)
|
|
860
|
+
return;
|
|
861
|
+
const p = pilha.current;
|
|
862
|
+
p.lista = [...p.lista.slice(0, p.ponteiro), c].slice(-LIMITE_DO_DESFAZER);
|
|
863
|
+
p.ponteiro = p.lista.length;
|
|
864
|
+
setVersaoDaPilha(v => v + 1);
|
|
865
|
+
aplicar(c.depois, c.fixosDepois);
|
|
866
|
+
};
|
|
867
|
+
const posAntes = (ids) => new Map([...ids].map(id => [id, posFinal.get(id) ?? caixas.get(id) ?? { x: 0, y: 0 }]));
|
|
868
|
+
confirmar.current = mudancas => {
|
|
869
|
+
const depois = new Map();
|
|
870
|
+
// Primeiro os nós soltos; DEPOIS o contêiner, que arrasta os filhos junto pelo mesmo tanto.
|
|
871
|
+
const ordenadas = [...mudancas].sort((a, b) => Number(abertos.has(a.id)) - Number(abertos.has(b.id)));
|
|
872
|
+
for (const c of ordenadas) {
|
|
873
|
+
const pai = paiAberto(c.id), cp = pai ? caixas.get(pai) : undefined;
|
|
874
|
+
const abs = { x: c.position.x + (cp?.x ?? 0), y: c.position.y + (cp?.y ?? 0) };
|
|
875
|
+
if (abertos.has(c.id)) {
|
|
876
|
+
const antes = caixas.get(c.id);
|
|
877
|
+
if (!antes)
|
|
878
|
+
continue;
|
|
879
|
+
const dx = abs.x - antes.x, dy = abs.y - antes.y;
|
|
880
|
+
if (!dx && !dy)
|
|
881
|
+
continue;
|
|
882
|
+
for (const f of descendentes(c.id)) {
|
|
883
|
+
const p = depois.get(f) ?? posFinal.get(f);
|
|
884
|
+
if (p)
|
|
885
|
+
depois.set(f, { x: p.x + dx, y: p.y + dy });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
const antes = posFinal.get(c.id);
|
|
890
|
+
if (antes && Math.abs(antes.x - abs.x) < 0.5 && Math.abs(antes.y - abs.y) < 0.5)
|
|
891
|
+
continue;
|
|
892
|
+
depois.set(c.id, abs);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (!depois.size)
|
|
896
|
+
return;
|
|
897
|
+
executar({ antes: posAntes(depois.keys()), depois });
|
|
898
|
+
if (onNodeMove)
|
|
899
|
+
for (const [id, p] of depois)
|
|
900
|
+
onNodeMove(id, p);
|
|
901
|
+
};
|
|
902
|
+
const escolhidos = () => nosVivos.filter(n => n.selected && !n.hidden).map(n => n.id);
|
|
903
|
+
const alinhaveis = () => escolhidos().filter(id => caixas.has(id) && !abertos.has(id));
|
|
904
|
+
const semFixos = (r) => { for (const id of [...r.keys()])
|
|
905
|
+
if (fixos.has(id))
|
|
906
|
+
r.delete(id); return r; };
|
|
907
|
+
const acoes = React.useRef(null);
|
|
908
|
+
acoes.current = {
|
|
909
|
+
desfazer() { const p = pilha.current; if (!p.ponteiro)
|
|
910
|
+
return; const c = p.lista[--p.ponteiro]; setVersaoDaPilha(v => v + 1); aplicar(c.antes, c.fixosAntes); },
|
|
911
|
+
refazer() { const p = pilha.current; if (p.ponteiro >= p.lista.length)
|
|
912
|
+
return; const c = p.lista[p.ponteiro++]; setVersaoDaPilha(v => v + 1); aplicar(c.depois, c.fixosDepois); },
|
|
913
|
+
alinhar(b) { const ids = alinhaveis(); if (ids.length < 2)
|
|
914
|
+
return; const r = semFixos(alinhar(new Map(ids.map(id => [id, caixas.get(id)])), b)); executar({ antes: posAntes(r.keys()), depois: r }); },
|
|
915
|
+
distribuir(e) { const ids = alinhaveis(); if (ids.length < 3)
|
|
916
|
+
return; const r = semFixos(distribuir(new Map(ids.map(id => [id, caixas.get(id)])), e)); executar({ antes: posAntes(r.keys()), depois: r }); },
|
|
917
|
+
fixar() {
|
|
918
|
+
const ids = escolhidos();
|
|
919
|
+
if (!ids.length)
|
|
920
|
+
return;
|
|
921
|
+
const todos = ids.every(id => fixos.has(id));
|
|
922
|
+
executar({ antes: new Map(), depois: new Map(), fixosAntes: new Map(ids.map(id => [id, fixos.has(id)])), fixosDepois: new Map(ids.map(id => [id, !todos])) });
|
|
923
|
+
},
|
|
924
|
+
// REORGANIZAR (MNT-13.4): todos voltam à arrumação automática, MENOS os fixos. O arrumador em camadas
|
|
925
|
+
// não tem alfinete por nó (pesquisado), então quem cairia em cima de um fixo é empurrado para o lado,
|
|
926
|
+
// na direção das camadas — e a linha que passar por ele desvia.
|
|
927
|
+
reorganizar() {
|
|
928
|
+
const presos = folhas.filter(n => fixos.has(n.id)).map(n => caixas.get(n.id));
|
|
929
|
+
const encosta = (a, b) => a.x < b.x + b.larg + VAO_Y && b.x < a.x + a.larg + VAO_Y && a.y < b.y + b.alt + VAO_Y && b.y < a.y + a.alt + VAO_Y;
|
|
930
|
+
const depois = new Map();
|
|
931
|
+
for (const n of folhas) {
|
|
932
|
+
if (fixos.has(n.id))
|
|
933
|
+
continue;
|
|
934
|
+
const t = tamanho(n.id), p = { ...autoDe(n.id) };
|
|
935
|
+
for (let k = 0; k <= presos.length; k++) {
|
|
936
|
+
const c = presos.find(c => encosta({ ...p, ...t }, c));
|
|
937
|
+
if (!c)
|
|
938
|
+
break;
|
|
939
|
+
if (vertical)
|
|
940
|
+
p.x = c.x + c.larg + VAO_Y;
|
|
941
|
+
else
|
|
942
|
+
p.y = c.y + c.alt + VAO_Y;
|
|
943
|
+
}
|
|
944
|
+
const antes = posFinal.get(n.id);
|
|
945
|
+
if (!antes || Math.abs(antes.x - p.x) >= 0.5 || Math.abs(antes.y - p.y) >= 0.5)
|
|
946
|
+
depois.set(n.id, p);
|
|
947
|
+
}
|
|
948
|
+
executar({ antes: posAntes(depois.keys()), depois });
|
|
949
|
+
setPedidoDeCaber(x => x + 1);
|
|
950
|
+
},
|
|
951
|
+
escolherTodos() { setNosVivos(ns => ns.map(n => n.selectable && !n.hidden ? { ...n, selected: true } : n)); },
|
|
952
|
+
arrumacao: () => arrumacao(),
|
|
953
|
+
};
|
|
954
|
+
alternar.current = id => { const novo = !fechados.has(id); setFechadoMao(x => new Map(x).set(id, novo)); onCollapseChange?.(id, novo); };
|
|
955
|
+
const escolhaMudou = React.useRef(() => { });
|
|
956
|
+
escolhaMudou.current = ids => { onSelectionChange?.(ids); if (ids.length === 1)
|
|
957
|
+
onSelect?.(ids[0]); };
|
|
958
|
+
const aoMudarEscolha = React.useCallback(({ nodes: sel }) => escolhaMudou.current(sel.map(n => n.id)), []);
|
|
959
|
+
const barra = { podeDesfazer: pilha.current.ponteiro > 0, podeRefazer: pilha.current.ponteiro < pilha.current.lista.length,
|
|
960
|
+
alinhaveis: alinhaveis().length, escolhidos: escolhidos().length, todosFixos: escolhidos().length > 0 && escolhidos().every(id => fixos.has(id)) };
|
|
961
|
+
// ── AS ROTAS (MNT-12.1 a 12.3) ─────────────────────────────────────────────────────────────
|
|
962
|
+
// Cada linha em degrau tem, nesta ordem: a rota do ARRUMADOR, se os dois nós continuam onde ele os pôs
|
|
963
|
+
// e nenhum nó entrou no caminho; o DESVIO já calculado, se as pontas não mudaram e o caminho segue
|
|
964
|
+
// livre; o DEGRAU SIMPLES, se ele não atravessa nó nenhum; e, se nada disso serve, ela vai para a fila
|
|
965
|
+
// do desvio — até a resposta, fica o degrau do motor. Na arrumação radial a linha é RETA, de borda a
|
|
966
|
+
// borda, na direção do outro centro. Durante o arraste, a linha de quem se move segue o motor ao vivo.
|
|
967
|
+
const chaveMovendo = React.useMemo(() => nosVivos.filter(n => n.dragging).map(n => n.id).sort().join("|"), [nosVivos]);
|
|
968
|
+
const movendo = React.useMemo(() => {
|
|
969
|
+
const base = chaveMovendo ? chaveMovendo.split("|") : [], out = new Set(base);
|
|
970
|
+
for (const g of base)
|
|
971
|
+
if (abertos.has(g))
|
|
972
|
+
for (const f of descendentes(g))
|
|
973
|
+
out.add(f);
|
|
974
|
+
return out;
|
|
975
|
+
}, [chaveMovendo, abertos, descendentes]);
|
|
976
|
+
const [desvios, setDesvios] = React.useState(() => new Map());
|
|
977
|
+
const tracados = React.useMemo(() => {
|
|
978
|
+
const mapa = new Map();
|
|
979
|
+
const pendentes = [];
|
|
980
|
+
const obstaculos = [...caixas].filter(([id]) => !abertos.has(id) && !ocultos.has(id));
|
|
981
|
+
const menos = (a, b) => obstaculos.filter(([id]) => id !== a && id !== b).map(([, c]) => c);
|
|
982
|
+
const todos = obstaculos.map(([, c]) => c);
|
|
983
|
+
const alcaDe = (id, porta, tipo) => {
|
|
984
|
+
const n = porId.get(id), c = caixas.get(id);
|
|
985
|
+
const lista = alcasDoNo(abertos.has(id) || contemFilhos.has(id) ? { ...n, ports: undefined } : n, orientation, { larg: c.larg, alt: c.alt });
|
|
986
|
+
return (porta && lista.find(a => a.id === porta && a.type === tipo)) || lista.find(a => !a.id && a.type === tipo);
|
|
987
|
+
};
|
|
988
|
+
desenhadas.forEach(({ item: e }, i) => {
|
|
989
|
+
const id = ident[i].id;
|
|
990
|
+
if (ocultos.has(e.from) || ocultos.has(e.to) || movendo.has(e.from) || movendo.has(e.to))
|
|
991
|
+
return;
|
|
992
|
+
const cs = caixas.get(e.from), ct = caixas.get(e.to);
|
|
993
|
+
if (!cs || !ct || !porId.has(e.from) || !porId.has(e.to))
|
|
994
|
+
return;
|
|
995
|
+
if (layout === "radial") {
|
|
996
|
+
mapa.set(id, [bordaNaDirecao(cs, centroDe(ct)), bordaNaDirecao(ct, centroDe(cs))]);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (edgeShape !== "step")
|
|
1000
|
+
return;
|
|
1001
|
+
const doElk = elk?.rotas.get(id);
|
|
1002
|
+
const noLugar = (no) => { const a = elk?.posicoes.get(no), c = caixas.get(no); return !!a && !!c && Math.abs(a.x - c.x) < 0.5 && Math.abs(a.y - c.y) < 0.5; };
|
|
1003
|
+
if (doElk && noLugar(e.from) && noLugar(e.to) && naBorda(doElk[0], cs) && naBorda(doElk[doElk.length - 1], ct) && !rotaCruza(doElk, menos(e.from, e.to))) {
|
|
1004
|
+
mapa.set(id, doElk);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const as = alcaDe(e.from, e.fromPort, "source"), at = alcaDe(e.to, e.toPort, "target");
|
|
1008
|
+
const desl = ident[i].deslocamento, ddx = vertical ? desl : 0, ddy = vertical ? 0 : desl;
|
|
1009
|
+
const ps = pontoDaAlca(cs, as), pt = pontoDaAlca(ct, at);
|
|
1010
|
+
const sp = { x: ps.x + ddx, y: ps.y + ddy }, tp = { x: pt.x + ddx, y: pt.y + ddy };
|
|
1011
|
+
const chave = `${sp.x},${sp.y},${tp.x},${tp.y}`;
|
|
1012
|
+
const salvo = desvios.get(id);
|
|
1013
|
+
// O desvio vale enquanto as pontas não mudam e nenhum nó ENTROU no caminho. O que já nasceu cruzando —
|
|
1014
|
+
// a ponta largada em cima de outro nó, e não há caminho livre — vale até as pontas mudarem: sem
|
|
1015
|
+
// isto, a linha voltava ao degrau do motor e pedia o mesmo desvio impossível de novo.
|
|
1016
|
+
if (salvo && salvo.chave === chave && (salvo.cruzava || !rotaCruza(salvo.pontos, menos(e.from, e.to)))) {
|
|
1017
|
+
mapa.set(id, salvo.pontos);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const degrau = esqueleto(sp, as.position, tp, at.position);
|
|
1021
|
+
if (degrau && !rotaCruza(degrau, todos)) {
|
|
1022
|
+
mapa.set(id, degrau);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
pendentes.push({ id, de: e.from, para: e.to, s: sp, t: tp, ls: as.position, lt: at.position, chave });
|
|
1026
|
+
});
|
|
1027
|
+
return { mapa, pendentes, obstaculos };
|
|
1028
|
+
}, [desenhadas, ident, caixas, abertos, ocultos, movendo, porId, contemFilhos, orientation, layout, edgeShape, elk, desvios, vertical]);
|
|
1029
|
+
const chavePendentes = tracados.pendentes.map(p => `${p.id}@${p.chave}`).join("|");
|
|
1030
|
+
React.useEffect(() => {
|
|
1031
|
+
if (!tracados.pendentes.length || movendo.size)
|
|
1032
|
+
return;
|
|
1033
|
+
let vivo = true;
|
|
1034
|
+
(async () => {
|
|
1035
|
+
const se = await desviador();
|
|
1036
|
+
if (!se || !vivo)
|
|
1037
|
+
return;
|
|
1038
|
+
const nosObstaculo = tracados.obstaculos.map(([id, c]) => ({ id, position: { x: c.x, y: c.y }, measured: { width: c.larg, height: c.alt }, data: {} }));
|
|
1039
|
+
const novos = new Map(), lote = tracados.pendentes;
|
|
1040
|
+
const caixasDe = (a, b) => tracados.obstaculos.filter(([id]) => id !== a && id !== b).map(([, c]) => c);
|
|
1041
|
+
for (let i = 0; i < lote.length; i += LOTE_DO_DESVIO) {
|
|
1042
|
+
const parte = lote.slice(i, i + LOTE_DO_DESVIO);
|
|
1043
|
+
const r = se.routeSmartEdgeBatch(nosObstaculo, parte.map(p => ({ id: p.id, source: p.de, target: p.para, sourceX: p.s.x, sourceY: p.s.y, targetX: p.t.x, targetY: p.t.y,
|
|
1044
|
+
sourcePosition: p.ls, targetPosition: p.lt, preset: "step", options: { nodePadding: FOLGA_DO_DESVIO, gridRatio: MALHA_DO_DESVIO } })));
|
|
1045
|
+
for (const p of parte) {
|
|
1046
|
+
const x = r[p.id];
|
|
1047
|
+
if (x?.kind !== "routed")
|
|
1048
|
+
continue;
|
|
1049
|
+
const pontos = simplificar([p.s, ...x.points.map(([a, b]) => ({ x: a, y: b })), p.t]);
|
|
1050
|
+
novos.set(p.id, { chave: p.chave, pontos, cruzava: rotaCruza(pontos, caixasDe(p.de, p.para)) });
|
|
1051
|
+
}
|
|
1052
|
+
if (i + LOTE_DO_DESVIO < lote.length) {
|
|
1053
|
+
await new Promise(ok => setTimeout(ok, 0));
|
|
1054
|
+
if (!vivo)
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (vivo && novos.size)
|
|
1059
|
+
setDesvios(d => { const b = new Map(d); for (const [k, v] of novos)
|
|
1060
|
+
b.set(k, v); return b; });
|
|
1061
|
+
})();
|
|
1062
|
+
return () => { vivo = false; };
|
|
1063
|
+
// A fila muda junto com o traçado; a chave é o que diz se ela mudou DE VERDADE.
|
|
1064
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1065
|
+
}, [chavePendentes, movendo.size]);
|
|
1066
|
+
// As PONTES (MNT-12.3): a linha de depois salta sobre as de antes, na ordem da lista do app.
|
|
1067
|
+
const saltos = React.useMemo(() => pontes(ident.filter(x => tracados.mapa.has(x.id)).map(x => ({ id: x.id, pontos: tracados.mapa.get(x.id) })), RAIO_DA_PONTE, CANTO_DO_DEGRAU), [ident, tracados]);
|
|
1068
|
+
// `selectable:false` na PRÓPRIA aresta, e não só no `elementsSelectable` do `<ReactFlow>`:
|
|
1069
|
+
// medido em 29/08/2026, a prop do fluxo não tira a classe `.selectable` da aresta, que é onde
|
|
1070
|
+
// o `base.css` do motor põe `cursor:pointer`. A mão de ponteiro prometia um clique que a API
|
|
1071
|
+
// da Aurea não atende — `onSelect` é do NÓ, e quem o atende é o <button> do corpo dele.
|
|
1072
|
+
// (10/10/2026) Com `onEdgeSelect` a aresta passa a ser alvo de verdade, e aí a mão é honesta.
|
|
1073
|
+
// (Rodada 2) E só aí ela recebe o Tab: medido na bancada da rodada 1, TODA linha recebia o Tab, com
|
|
1074
|
+
// o nome do motor em inglês ("Edge from internet to fw") e a instrução de apagar com Delete. Agora o
|
|
1075
|
+
// nome é nosso ("Ligação: Internet – Firewall") e o Enter escolhe a linha.
|
|
1076
|
+
const nomeDe = React.useCallback((id) => porId.get(id)?.label ?? id, [porId]);
|
|
1077
|
+
const arestasDoMotor = React.useMemo(() => desenhadas.map(({ item: e, grupo }, i) => {
|
|
1078
|
+
const id = ident[i].id, rota = tracados.mapa.get(id), sl = saltos.get(id);
|
|
1079
|
+
return { id, type: "aurea", source: e.from, target: e.to, sourceHandle: e.fromPort, targetHandle: e.toPort,
|
|
1080
|
+
// O grupo FECHADO é a linha de todos: o nome da primeira enganaria ("WAN 1" valendo pelas 10).
|
|
1081
|
+
label: grupo && !grupo.aberto ? undefined : e.label, animated: e.animated, selectable: !!onEdgeSelect, focusable: !!onEdgeSelect,
|
|
1082
|
+
ariaLabel: `${s.graphEdge}: ${nomeDe(e.from)} – ${nomeDe(e.to)}${e.label ? ` (${e.label})` : ""}`,
|
|
1083
|
+
...(onEdgeSelect ? { domAttributes: { onKeyDown: (ev) => { if (ev.key === "Enter" || ev.key === " ") {
|
|
1084
|
+
ev.preventDefault();
|
|
1085
|
+
onEdgeSelect(id);
|
|
1086
|
+
} } } } : {}),
|
|
1087
|
+
hidden: ocultos.has(e.from) || ocultos.has(e.to),
|
|
1088
|
+
data: { deslocamento: rota ? 0 : ident[i].deslocamento, vertical, forma: edgeShape, padrao: e.pattern, peso: e.weight, marca: e.mark, contagem: e.count,
|
|
1089
|
+
origem: e.sourceLabel, destino: e.targetLabel, apagada: aceso?.arestas ? !aceso.arestas.has(e.id ?? id) : aceso?.nos ? !(aceso.nos.has(e.from) && aceso.nos.has(e.to)) : false,
|
|
1090
|
+
grupo: grupo ? { n: grupo.n, aberto: grupo.aberto, alternar: () => alternarGrupo(grupo.chave), mostrar: s.graphShow, juntar: s.graphJoin, conexoes: s.graphConnections } : undefined,
|
|
1091
|
+
rota, saltos: sl ? [...sl] : undefined, prioridade: e.priority, ordem: i },
|
|
1092
|
+
};
|
|
1093
|
+
}), [desenhadas, ident, tracados, saltos, vertical, edgeShape, onEdgeSelect, ocultos, aceso, alternarGrupo, s, nomeDe]);
|
|
1094
|
+
// A LEGENDA se monta com o que está na tela: uma linha por nome de `legend`, com o desenho dela.
|
|
1095
|
+
const legenda = React.useMemo(() => {
|
|
1096
|
+
const vistas = new Map();
|
|
1097
|
+
for (const e of edges)
|
|
1098
|
+
if (e.legend && !vistas.has(e.legend))
|
|
1099
|
+
vistas.set(e.legend, e);
|
|
1100
|
+
return [...vistas.entries()];
|
|
1101
|
+
}, [edges]);
|
|
1102
|
+
// O texto que o MOTOR põe na tela (descrição do nó para o leitor de tela, aviso de "movido", nome das
|
|
1103
|
+
// alças e do minimapa) sai na língua da Aurea — o padrão dele é inglês.
|
|
1104
|
+
const rotulosDoMotor = React.useMemo(() => ({
|
|
1105
|
+
"node.a11yDescription.default": s.graphNodeHelp, "node.a11yDescription.keyboardDisabled": s.graphNodeHelp,
|
|
1106
|
+
"node.a11yDescription.ariaLiveMessage": ({ x, y }) => `${s.graphMoved}: x ${x}, y ${y}`,
|
|
1107
|
+
"edge.a11yDescription.default": s.graphEdgeHelp, "controls.ariaLabel": s.graphLabel, "controls.zoomIn.ariaLabel": s.graphZoomIn,
|
|
1108
|
+
"controls.zoomOut.ariaLabel": s.graphZoomOut, "controls.fitView.ariaLabel": s.graphFit, "controls.interactive.ariaLabel": s.graphArrange,
|
|
1109
|
+
"minimap.ariaLabel": s.graphOverview, "handle.ariaLabel": s.graphHandle
|
|
1110
|
+
}), [s]);
|
|
1111
|
+
const linhas = React.useMemo(() => desenhadas.map(({ item }, i) => ({ item, rota: tracados.mapa.get(ident[i].id) })), [desenhadas, tracados, ident]);
|
|
1112
|
+
const raiz = React.useRef(null);
|
|
233
1113
|
// `initialWidth`/`initialHeight` no provider é o par do de cima, um nível acima: é o viewport
|
|
234
1114
|
// que o `fitView` usa ENQUANTO ninguém mediu. Com o prefixo `initial`, o navegador sobrescreve
|
|
235
1115
|
// na primeira medição — passar `width`/`height` direto no ReactFlow fixaria o tamanho e mataria
|
|
236
1116
|
// a fluidez. O valor é a extensão do próprio layout, então no servidor o grafo cabe exato.
|
|
237
|
-
|
|
238
|
-
|
|
1117
|
+
const estilo = { ...style, ...(height ? { blockSize: height } : null), "--graph-node-w": `${m.larg}px` };
|
|
1118
|
+
return _jsx("div", { ref: raiz, className: cx("dependency-graph", className), style: estilo, "data-text": textSize === "md" ? undefined : textSize, "data-contrast": highContrast ? "high" : undefined, "data-editable": editavel || undefined, role: "group", "aria-label": label ?? s.graphLabel, ...props, children: _jsx(ReactFlowProvider, { initialNodes: nosDoMotor, initialEdges: arestasDoMotor, fitView: true, initialWidth: automatico.extensao.larg, initialHeight: automatico.extensao.alt, children: _jsxs(ReactFlow, { nodes: nosVivos, onNodesChange: aoMudarNos, edges: arestasDoMotor, nodeTypes: TIPOS, edgeTypes: TIPOS_DE_ARESTA, fitView: true, proOptions: { hideAttribution: false }, minZoom: ESCALA_MINIMA, nodesDraggable: editavel || !!onNodeMove, nodesConnectable: !!connectable, elementsSelectable: editavel, nodesFocusable: editavel, edgesFocusable: !!onEdgeSelect, disableKeyboardA11y: !editavel, selectionOnDrag: editavel, panOnDrag: editavel ? BOTOES_DE_ARRASTAR : true, selectionMode: SelectionMode.Partial, multiSelectionKeyCode: TECLAS_DE_SOMAR, deleteKeyCode: null, snapToGrid: editavel && grid, snapGrid: [GRADE, GRADE], onlyRenderVisibleElements: !!visibleOnly, ariaLabelConfig: rotulosDoMotor, onSelectionChange: editavel ? aoMudarEscolha : undefined, onNodeMouseEnter: onNodeHover ? (_, no) => onNodeHover(no.id) : undefined, onNodeMouseLeave: onNodeHover ? () => onNodeHover(null) : undefined, onNodeContextMenu: onNodeContextMenu ? (ev, no) => { ev.preventDefault(); onNodeContextMenu(no.id, { x: ev.clientX, y: ev.clientY }); } : undefined, onEdgeClick: onEdgeSelect ? (_, a) => onEdgeSelect(a.id) : undefined, onConnect: onConnect ? c => { if (c.source && c.target)
|
|
1119
|
+
onConnect({ from: c.source, to: c.target, fromPort: c.sourceHandle ?? undefined, toPort: c.targetHandle ?? undefined }); } : undefined, children: [_jsx(Background, { gap: editavel && grid ? GRADE : undefined }), _jsx(Comandos, { raiz: raiz, teclado: teclado, focusId: focusId, pedidoDeCaber: pedidoDeCaber, controls: !!controls, apiRef: apiRef, linhas: linhas, forma: edgeShape, titulo: label ?? s.graphLabel, desenho: tracados, editavel: editavel, barra: barra, acoes: acoes }), minimap && _jsx(MiniMap, { pannable: true, zoomable: true, ariaLabel: s.graphOverview, className: "graph-minimap" }), legend && legenda.length > 0 && _jsx(Panel, { position: "bottom-left", children: _jsx("ul", { className: "graph-legend", "aria-label": s.graphLegend, children: legenda.map(([nome, e]) => _jsxs("li", { children: [_jsxs("svg", { className: "graph-legend-sample", "aria-hidden": "true", viewBox: "0 0 32 8", children: [_jsx("path", { d: "M0 4H32", className: cx("graph-edge", e.pattern && e.pattern !== "solid" && `graph-edge-${e.pattern}`, e.weight && e.weight !== "regular" && `graph-edge-${e.weight}`) }), e.pattern === "double" && _jsx("path", { d: "M0 4H32", className: cx("graph-edge-double-core", e.weight && e.weight !== "regular" && `graph-edge-${e.weight}`) })] }), e.mark && _jsx("span", { className: `graph-edge-mark graph-edge-mark-${e.mark}`, "aria-hidden": "true", children: e.mark === "lock" && _jsx(Icon, { name: "lock-key", size: "sm" }) }), _jsx("span", { children: nome })] }, nome)) }) })] }) }) });
|
|
1120
|
+
}
|
|
1121
|
+
// `CSS.escape` existe no navegador; fora dele (o teste em jsdom), as aspas bastam.
|
|
1122
|
+
const escapar = (id) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/[\x22\\]/g, "\\$&");
|
|
1123
|
+
/**
|
|
1124
|
+
* OS RÓTULOS QUE SE ENCOSTAM (MNT-12.5): mede cada rótulo na tela e esconde, pela prioridade, o que
|
|
1125
|
+
* cobre outro rótulo ou um nó. Medir na tela vale para qualquer escala — o rótulo e o nó crescem
|
|
1126
|
+
* juntos com o zoom. O atributo é posto à mão, fora do React, e tirado antes de cada nova conta.
|
|
1127
|
+
*/
|
|
1128
|
+
function esconderRotulos(el) {
|
|
1129
|
+
if (!el)
|
|
1130
|
+
return;
|
|
1131
|
+
const caixa = (e) => { const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, larg: b.width, alt: b.height }; };
|
|
1132
|
+
const rotulos = [...el.querySelectorAll("[data-edge-label]")];
|
|
1133
|
+
for (const r of rotulos)
|
|
1134
|
+
r.removeAttribute("data-crowded");
|
|
1135
|
+
const medidos = rotulos.map(r => ({ id: r.getAttribute("data-edge-label"), caixa: caixa(r), prioridade: Number(r.getAttribute("data-priority")) || 0,
|
|
1136
|
+
ordem: Number(r.getAttribute("data-order")) || 0, el: r })).filter(r => r.caixa.larg > 0).sort((a, b) => a.ordem - b.ordem);
|
|
1137
|
+
const somem = rotulosQueSomem(medidos, [...el.querySelectorAll(".graph-node")].map(caixa));
|
|
1138
|
+
for (const r of medidos)
|
|
1139
|
+
if (somem.has(r.id))
|
|
1140
|
+
r.el.setAttribute("data-crowded", "");
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* O lado de DENTRO do provedor: o que precisa do motor — caber na tela depois da arrumação, o "ir
|
|
1144
|
+
* até", as setas de nó em nó, os botões de zoom, a barra do modo de arrumar, os rótulos que somem e
|
|
1145
|
+
* o `apiRef`.
|
|
1146
|
+
*/
|
|
1147
|
+
function Comandos({ raiz, teclado, focusId, pedidoDeCaber, controls, apiRef, linhas, forma, titulo, desenho, editavel, barra, acoes }) {
|
|
1148
|
+
const rf = useReactFlow();
|
|
1149
|
+
const s = useAureaStrings();
|
|
1150
|
+
// Quem pediu menos movimento não vê a tela deslizar: o "ir até" e o caber na tela pulam direto.
|
|
1151
|
+
const duracao = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
|
|
1152
|
+
// O centro de um nó NO MAPA: o filho de um contêiner tem posição RELATIVA ao pai, e o motor guarda a absoluta.
|
|
1153
|
+
const centro = React.useCallback((id) => {
|
|
1154
|
+
const n = rf.getInternalNode(id);
|
|
1155
|
+
if (!n)
|
|
1156
|
+
return null;
|
|
1157
|
+
const w = n.measured?.width ?? n.width ?? n.initialWidth ?? LARGURA, h = n.measured?.height ?? n.height ?? n.initialHeight ?? ALTURA;
|
|
1158
|
+
return { x: n.internals.positionAbsolute.x + w / 2, y: n.internals.positionAbsolute.y + h / 2 };
|
|
1159
|
+
}, [rf]);
|
|
1160
|
+
const centralizar = React.useCallback((id) => {
|
|
1161
|
+
const c = centro(id);
|
|
1162
|
+
if (c)
|
|
1163
|
+
rf.setCenter(c.x, c.y, { zoom: Math.max(rf.getZoom(), 1), duration: duracao() });
|
|
1164
|
+
}, [rf, centro]);
|
|
1165
|
+
// A arrumação chegou (ou a pessoa pediu "Reorganizar"): o mapa cabe de novo na tela.
|
|
1166
|
+
React.useEffect(() => { if (pedidoDeCaber)
|
|
1167
|
+
requestAnimationFrame(() => rf.fitView({ duration: duracao() })); }, [pedidoDeCaber, rf]);
|
|
1168
|
+
React.useEffect(() => { if (focusId)
|
|
1169
|
+
centralizar(focusId); }, [focusId, centralizar]);
|
|
1170
|
+
// AS SETAS (MNT-19.4): o nó mais perto na direção da seta, dentro de um cone de 45°. O foco vai
|
|
1171
|
+
// para o botão dele; se ele estiver fora da tela, a tela vai até ele. (Rodada 2) Com `visibleOnly`
|
|
1172
|
+
// o nó de fora da tela nem existe ainda: a tela vai até ele primeiro, e o foco vem no quadro seguinte.
|
|
1173
|
+
React.useEffect(() => {
|
|
1174
|
+
teclado.current = (id, tecla) => {
|
|
1175
|
+
const a = centro(id);
|
|
1176
|
+
if (!a)
|
|
1177
|
+
return;
|
|
1178
|
+
let melhor = null;
|
|
1179
|
+
for (const n of rf.getNodes()) {
|
|
1180
|
+
if (n.id === id || n.hidden || n.type !== "aurea")
|
|
1181
|
+
continue;
|
|
1182
|
+
const b = centro(n.id);
|
|
1183
|
+
if (!b)
|
|
1184
|
+
continue;
|
|
1185
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
1186
|
+
const naDirecao = tecla === "ArrowRight" ? dx > 0 && Math.abs(dy) <= dx : tecla === "ArrowLeft" ? dx < 0 && Math.abs(dy) <= -dx
|
|
1187
|
+
: tecla === "ArrowDown" ? dy > 0 && Math.abs(dx) <= dy : dy < 0 && Math.abs(dx) <= -dy;
|
|
1188
|
+
if (!naDirecao)
|
|
1189
|
+
continue;
|
|
1190
|
+
const d = dx * dx + dy * dy;
|
|
1191
|
+
if (!melhor || d < melhor.d)
|
|
1192
|
+
melhor = { id: n.id, d };
|
|
1193
|
+
}
|
|
1194
|
+
if (!melhor)
|
|
1195
|
+
return;
|
|
1196
|
+
const alvo = () => raiz.current?.querySelector(`.react-flow__node[data-id="${escapar(melhor.id)}"] button.graph-node-body`);
|
|
1197
|
+
const botao = alvo();
|
|
1198
|
+
if (!botao) {
|
|
1199
|
+
centralizar(melhor.id);
|
|
1200
|
+
requestAnimationFrame(() => alvo()?.focus({ preventScroll: true }));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
botao.focus({ preventScroll: true });
|
|
1204
|
+
const caixa = raiz.current?.getBoundingClientRect(), noCaixa = botao.getBoundingClientRect();
|
|
1205
|
+
if (caixa && (noCaixa.left < caixa.left || noCaixa.right > caixa.right || noCaixa.top < caixa.top || noCaixa.bottom > caixa.bottom))
|
|
1206
|
+
centralizar(melhor.id);
|
|
1207
|
+
};
|
|
1208
|
+
}, [rf, raiz, teclado, centralizar, centro]);
|
|
1209
|
+
// MNT-12.5 e 19.1: longe, o texto das linhas some (o atributo é da raiz, e a regra mora no core).
|
|
1210
|
+
const longe = useStore(st => st.transform[2] < ESCALA_DOS_ROTULOS);
|
|
1211
|
+
React.useEffect(() => { const el = raiz.current; if (!el)
|
|
1212
|
+
return; if (longe)
|
|
1213
|
+
el.setAttribute("data-far", "");
|
|
1214
|
+
else
|
|
1215
|
+
el.removeAttribute("data-far"); }, [longe, raiz]);
|
|
1216
|
+
// E o rótulo que encosta noutro some — depois de cada desenho novo e de cada parada da câmera
|
|
1217
|
+
// (com `visibleOnly`, o rótulo que entra na tela só existe depois de ela andar).
|
|
1218
|
+
const [vista, setVista] = React.useState(0);
|
|
1219
|
+
useOnViewportChange({ onEnd: () => setVista(v => v + 1) });
|
|
1220
|
+
React.useEffect(() => { const q = requestAnimationFrame(() => esconderRotulos(raiz.current)); return () => cancelAnimationFrame(q); }, [desenho, vista, raiz]);
|
|
1221
|
+
// OS ATALHOS do modo de arrumar, como no draw.io: Ctrl+Z desfaz; Ctrl+Shift+Z ou Ctrl+Y refaz; Ctrl+A
|
|
1222
|
+
// escolhe todos. (Cmd no Mac.) Só com o foco dentro do mapa.
|
|
1223
|
+
React.useEffect(() => {
|
|
1224
|
+
const el = raiz.current;
|
|
1225
|
+
if (!editavel || !el)
|
|
1226
|
+
return;
|
|
1227
|
+
const atalho = (ev) => {
|
|
1228
|
+
if (!(ev.ctrlKey || ev.metaKey) || ev.altKey)
|
|
1229
|
+
return;
|
|
1230
|
+
const t = ev.target;
|
|
1231
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))
|
|
1232
|
+
return;
|
|
1233
|
+
const k = ev.key.toLowerCase();
|
|
1234
|
+
if (k === "z" && !ev.shiftKey) {
|
|
1235
|
+
ev.preventDefault();
|
|
1236
|
+
acoes.current.desfazer();
|
|
1237
|
+
}
|
|
1238
|
+
else if ((k === "z" && ev.shiftKey) || k === "y") {
|
|
1239
|
+
ev.preventDefault();
|
|
1240
|
+
acoes.current.refazer();
|
|
1241
|
+
}
|
|
1242
|
+
else if (k === "a") {
|
|
1243
|
+
ev.preventDefault();
|
|
1244
|
+
acoes.current.escolherTodos();
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
el.addEventListener("keydown", atalho);
|
|
1248
|
+
return () => el.removeEventListener("keydown", atalho);
|
|
1249
|
+
}, [editavel, raiz, acoes]);
|
|
1250
|
+
React.useImperativeHandle(apiRef, () => ({
|
|
1251
|
+
fitView: () => { void rf.fitView({ duration: duracao() }); },
|
|
1252
|
+
zoomIn: () => { void rf.zoomIn({ duration: duracao() }); },
|
|
1253
|
+
zoomOut: () => { void rf.zoomOut({ duration: duracao() }); },
|
|
1254
|
+
focus: centralizar,
|
|
1255
|
+
toDrawio: () => paraDrawio(rf.getNodes(), linhas, forma, titulo),
|
|
1256
|
+
toPng: () => foto(raiz.current, "png"),
|
|
1257
|
+
toSvg: () => foto(raiz.current, "svg"),
|
|
1258
|
+
undo: () => acoes.current.desfazer(), redo: () => acoes.current.refazer(),
|
|
1259
|
+
align: b => acoes.current.alinhar(b), distribute: e => acoes.current.distribuir(e),
|
|
1260
|
+
relayout: () => acoes.current.reorganizar(), getLayout: () => acoes.current.arrumacao(),
|
|
1261
|
+
}), [rf, centralizar, linhas, forma, titulo, raiz, acoes]);
|
|
1262
|
+
const a = acoes.current;
|
|
1263
|
+
return _jsxs(_Fragment, { children: [editavel && _jsx(Panel, { position: "top-left", className: "graph-toolbar", children: _jsxs(Toolbar, { label: s.graphArrange, children: [_jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphUndo, disabled: !barra.podeDesfazer, onClick: () => a.desfazer(), children: _jsx(Icon, { name: "arrow-u-up-left" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphRedo, disabled: !barra.podeRefazer, onClick: () => a.refazer(), children: _jsx(Icon, { name: "arrow-u-up-right" }) }), _jsx(ToolbarSeparator, {}), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignLeft, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("left"), children: _jsx(Icon, { name: "align-left" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignCenter, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("center"), children: _jsx(Icon, { name: "align-center-horizontal" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignRight, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("right"), children: _jsx(Icon, { name: "align-right" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignTop, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("top"), children: _jsx(Icon, { name: "align-top" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignMiddle, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("middle"), children: _jsx(Icon, { name: "align-center-vertical" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphAlignBottom, disabled: barra.alinhaveis < 2, onClick: () => a.alinhar("bottom"), children: _jsx(Icon, { name: "align-bottom" }) }), _jsx(ToolbarSeparator, {}), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphDistributeH, disabled: barra.alinhaveis < 3, onClick: () => a.distribuir("horizontal"), children: _jsx(Icon, { name: "arrows-out-line-horizontal" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphDistributeV, disabled: barra.alinhaveis < 3, onClick: () => a.distribuir("vertical"), children: _jsx(Icon, { name: "arrows-out-line-vertical" }) }), _jsx(ToolbarSeparator, {}), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphPin, disabled: !barra.escolhidos, onClick: () => a.fixar(), "aria-pressed": barra.todosFixos, children: _jsx(Icon, { name: "push-pin" }) }), _jsx(ToolbarButton, { className: "btn-icon", "aria-label": s.graphRelayout, disabled: false, onClick: () => a.reorganizar(), children: _jsx(Icon, { name: "tree-structure" }) })] }) }), controls && _jsxs(Panel, { position: "top-right", className: "graph-controls", children: [_jsx(IconButton, { icon: "magnifying-glass-plus", label: s.graphZoomIn, onClick: () => void rf.zoomIn({ duration: duracao() }) }), _jsx(IconButton, { icon: "magnifying-glass-minus", label: s.graphZoomOut, onClick: () => void rf.zoomOut({ duration: duracao() }) }), _jsx(IconButton, { icon: "corners-out", label: s.graphFit, onClick: () => void rf.fitView({ duration: duracao() }) })] })] });
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* A FOTO do mapa (MNT-19.2), pelo `modern-screenshot` (peer OPCIONAL, MIT), carregado só aqui. Sai
|
|
1267
|
+
* o desenho, sem os botões, o minimapa e a legenda flutuante — que são da tela, não do mapa —, no
|
|
1268
|
+
* fundo do próprio cartão.
|
|
1269
|
+
*/
|
|
1270
|
+
async function foto(el, tipo) {
|
|
1271
|
+
if (!el)
|
|
1272
|
+
throw new Error("DependencyGraph: o mapa ainda não está na tela.");
|
|
1273
|
+
let ms;
|
|
1274
|
+
try {
|
|
1275
|
+
ms = await import("modern-screenshot");
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
throw new Error("DependencyGraph: toPng/toSvg precisam do pacote `modern-screenshot` (peer opcional).");
|
|
1279
|
+
}
|
|
1280
|
+
const opcoes = { backgroundColor: getComputedStyle(el).backgroundColor, scale: tipo === "png" ? 2 : 1,
|
|
1281
|
+
filter: (n) => !(n instanceof HTMLElement && n.classList.contains("react-flow__panel")) };
|
|
1282
|
+
return tipo === "png" ? ms.domToPng(el, opcoes) : ms.domToSvg(el, opcoes);
|
|
239
1283
|
}
|