@aurea-uds/react 0.30.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 +61 -3
- package/dist/graph.js +856 -158
- package/dist/pure.d.ts +22 -0
- package/dist/pure.js +2 -2
- package/package.json +6 -2
package/dist/graph.js
CHANGED
|
@@ -39,11 +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, EdgeLabelRenderer, Handle, MiniMap, Panel, Position, applyNodeChanges, getBezierPath, getSmoothStepPath, useReactFlow } 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
44
|
import { Icon } from "./system.js";
|
|
45
45
|
import { Badge } from "./markup.js";
|
|
46
|
-
import { IconButton } from "./actions.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";
|
|
47
48
|
// ── O layout ─────────────────────────────────────────────────────────────────────────────
|
|
48
49
|
// O motor recebe `x`/`y` PRONTOS: ele não posiciona nada. Sem isto, um grafo sem coordenadas
|
|
49
50
|
// empilha tudo em (0,0) — que foi a medição que quase derrubou a escolha do motor.
|
|
@@ -56,6 +57,23 @@ import { IconButton } from "./actions.js";
|
|
|
56
57
|
// subida é uma biblioteca de layout automático de grafos, a que a referência usa, e ela é DEPENDÊNCIA NOVA: entra com
|
|
57
58
|
// autorização, não de contrabando.
|
|
58
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;
|
|
59
77
|
// O VÃO ENTRE COLUNAS CRESCE COM O RÓTULO (A-09, 23/09/2026). Com o rótulo legível, a primeira
|
|
60
78
|
// imagem mostrou o que o preto escondia: "Te1/1/1 ↔ Te1/0/1" é mais largo que 90px e entrava por
|
|
61
79
|
// baixo dos nós. O layout roda também no servidor, onde não há texto para medir, então a largura
|
|
@@ -142,53 +160,132 @@ function dispor(nodes, edges, orientacao = "horizontal", m = { larg: LARGURA, al
|
|
|
142
160
|
// roda depois do primeiro desenho: até a resposta, e no servidor, vale a arrumação simples acima.
|
|
143
161
|
// Sem o pacote instalado, o mapa avisa uma vez no console e fica na simples: não quebra a tela.
|
|
144
162
|
const LADO_ELK = { top: "NORTH", right: "EAST", bottom: "SOUTH", left: "WEST" };
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const volta = [...do_("top"), ...do_("right"), ...do_("bottom").reverse(), ...do_("left").reverse()];
|
|
152
|
-
return volta.map((p, i) => [p, i]);
|
|
153
|
-
}
|
|
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).
|
|
154
169
|
let avisouSemElk = false;
|
|
155
|
-
|
|
156
|
-
|
|
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) {
|
|
157
176
|
try {
|
|
158
|
-
|
|
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;
|
|
159
183
|
}
|
|
160
184
|
catch {
|
|
185
|
+
elkNaTela = null;
|
|
161
186
|
if (!avisouSemElk) {
|
|
162
187
|
avisouSemElk = true;
|
|
163
|
-
console.warn("DependencyGraph: layout=\"layered\"
|
|
188
|
+
console.warn("DependencyGraph: layout=\"layered\", \"tree\" e \"radial\" precisam do pacote `elkjs` (peer opcional). Ficou a arrumação simples.");
|
|
164
189
|
}
|
|
165
190
|
return null;
|
|
166
191
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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",
|
|
175
262
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(VAO_X),
|
|
176
|
-
"elk.
|
|
177
|
-
"elk.
|
|
178
|
-
"elk.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 };
|
|
192
289
|
}
|
|
193
290
|
// ── AS ALÇAS: o lado de sempre, e as portas (MNT-06 e MNT-14) ─────────────────────────────────
|
|
194
291
|
// Sem porta, a linha entra por um lado e sai pelo oposto: esquerda→direita no horizontal, cima→
|
|
@@ -214,8 +311,20 @@ function alcasDoNo(n, orientacao, m) {
|
|
|
214
311
|
});
|
|
215
312
|
return [...base, ...portas];
|
|
216
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 };
|
|
217
317
|
const SETAS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
|
218
|
-
|
|
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" }) });
|
|
326
|
+
}
|
|
327
|
+
const NoAurea = React.memo(function NoAurea({ data, selected, width, height }) {
|
|
219
328
|
const d = data;
|
|
220
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 })] })] });
|
|
221
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:
|
|
@@ -224,15 +333,21 @@ function NoAurea({ data }) {
|
|
|
224
333
|
e.preventDefault();
|
|
225
334
|
d.aoTeclar(e.key);
|
|
226
335
|
} };
|
|
227
|
-
|
|
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 => {
|
|
228
339
|
// A alça de porta fica no ponto dela ao longo do lado, em porcentagem: o nó pode crescer.
|
|
229
340
|
const lugar = a.position === Position.Left || a.position === Position.Right ? { top: `${a.frac * 100}%` } : { left: `${a.frac * 100}%` };
|
|
230
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"}`);
|
|
231
|
-
}), d.aoSelecionar
|
|
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
|
|
232
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 })
|
|
233
|
-
: _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 }) })] });
|
|
234
|
-
}
|
|
235
|
-
const
|
|
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 };
|
|
236
351
|
// ── A ARESTA, e por que ela também é nossa (A-09 e A-10, 23/09/2026) ─────────────────────────
|
|
237
352
|
// A-09 · O RÓTULO SAÍA PRETO SOBRE PRETO no tema escuro. O core declara `--xy-edge-label-color` e
|
|
238
353
|
// `--xy-edge-label-background-color` desde o H14, e NINGUÉM AS LIA: quem consome essas duas é o
|
|
@@ -261,20 +376,41 @@ const pontaDoRotulo = (x, y, pos) => {
|
|
|
261
376
|
return `translate(${x + f}px,${y - f}px) translateY(-100%)`;
|
|
262
377
|
return `translate(${x - f}px,${y - f}px) translate(-100%,-100%)`;
|
|
263
378
|
};
|
|
264
|
-
function ArestaAurea({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, label, data, markerEnd, style }) {
|
|
379
|
+
const ArestaAurea = React.memo(function ArestaAurea({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, label, data, markerEnd, style }) {
|
|
265
380
|
const d = (data ?? { deslocamento: 0, vertical: false, forma: "curve" });
|
|
266
381
|
// A-10 · as paralelas se afastam na direção ATRAVESSADA à linha: na vertical, para os lados.
|
|
267
382
|
const dx = d.vertical ? d.deslocamento : 0, dy = d.vertical ? 0 : d.deslocamento;
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|
|
270
404
|
// O rótulo de texto é SVG (sai também no servidor). Quando a linha tem marca, número ou grupo, o
|
|
271
405
|
// rótulo vai para a pastilha de HTML do meio, junto deles — dois desenhos no mesmo ponto se cobririam.
|
|
272
406
|
// O traço DUPLO desenha o miolo por cima da linha — e por cima do rótulo SVG, que sairia riscado
|
|
273
407
|
// (medido na bancada). Com rótulo, ele também vai para a pastilha.
|
|
274
408
|
const pastilha = !!(d.marca || d.contagem || d.grupo || (d.padrao === "double" && label != null));
|
|
275
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");
|
|
276
|
-
|
|
277
|
-
}
|
|
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
|
+
});
|
|
278
414
|
const TIPOS_DE_ARESTA = { aurea: ArestaAurea };
|
|
279
415
|
/** A chave de um PAR sem sentido: o cabo entre A e B é o mesmo que entre B e A. */
|
|
280
416
|
const parSemSentido = (e) => e.from < e.to ? `${e.from}|${e.to}` : `${e.to}|${e.from}`;
|
|
@@ -343,22 +479,29 @@ const EM_PAR = (a) => a ? new Set(a) : null;
|
|
|
343
479
|
const XML = (t) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\x22/g, """).replace(/\x27/g, "'");
|
|
344
480
|
const TRACO_DRAWIO = { solid: "", dashed: "dashed=1;dashPattern=8 4;", dotted: "dashed=1;dashPattern=1 4;", double: "shape=link;" };
|
|
345
481
|
const PESO_DRAWIO = { regular: 1, thick: 2, heavy: 3 };
|
|
346
|
-
|
|
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) {
|
|
347
486
|
const visiveis = nos.filter(n => !n.hidden);
|
|
348
487
|
const ids = new Set(visiveis.map(n => n.id));
|
|
349
488
|
const celulas = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
|
|
350
489
|
for (const n of visiveis) {
|
|
351
490
|
const d = n.data;
|
|
352
491
|
const texto = [d.label, d.kind, typeof d.detail === "string" ? d.detail : undefined].filter(Boolean).join("\n");
|
|
353
|
-
const w = n.measured?.width ?? n.initialWidth ?? LARGURA, h = n.measured?.height ?? n.initialHeight ?? ALTURA;
|
|
354
|
-
|
|
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>`);
|
|
355
497
|
}
|
|
356
|
-
|
|
498
|
+
linhas.forEach(({ item: e, rota }, i) => {
|
|
357
499
|
if (!ids.has(e.from) || !ids.has(e.to))
|
|
358
500
|
return;
|
|
359
501
|
const id = `e-${i}`;
|
|
360
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"]};`;
|
|
361
|
-
|
|
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>`);
|
|
362
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).
|
|
363
506
|
for (const [txt, x, suf] of [[e.sourceLabel, -0.8, "s"], [e.targetLabel, 0.8, "t"]])
|
|
364
507
|
if (txt)
|
|
@@ -366,29 +509,210 @@ function paraDrawio(nos, itens, forma, titulo) {
|
|
|
366
509
|
});
|
|
367
510
|
return `<mxfile host="Aurea"><diagram name="${XML(titulo)}" id="aurea"><mxGraphModel><root>${celulas.join("")}</root></mxGraphModel></diagram></mxfile>`;
|
|
368
511
|
}
|
|
369
|
-
|
|
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 }) {
|
|
370
556
|
const s = useAureaStrings();
|
|
371
557
|
const m = React.useMemo(() => medidasDoNo(textSize), [textSize]);
|
|
372
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 ─────────────────────────────────────────────────────────────────
|
|
373
639
|
// O layout só roda para quem não trouxe coordenada. Quem traz manda — é o que deixa a Parte I
|
|
374
640
|
// guardar a posição que a pessoa arrastou sem brigar com a disposição automática.
|
|
375
|
-
const automatico = React.useMemo(() => dispor(
|
|
376
|
-
|
|
377
|
-
const
|
|
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);
|
|
378
658
|
React.useEffect(() => {
|
|
379
|
-
if (
|
|
380
|
-
|
|
659
|
+
if (!entradaElk) {
|
|
660
|
+
setElk(null);
|
|
381
661
|
return;
|
|
382
662
|
}
|
|
383
663
|
let vivo = true;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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]);
|
|
392
716
|
// ── O que fica aceso: o caminho pedido, ou o nó escolhido e os vizinhos ────────────────────
|
|
393
717
|
const ocultos = React.useMemo(() => new Set(hiddenIds ?? []), [hiddenIds]);
|
|
394
718
|
const aceso = React.useMemo(() => {
|
|
@@ -396,70 +720,72 @@ export function DependencyGraph({ nodes, edges, label, selectedId, onSelect, con
|
|
|
396
720
|
return { nos: EM_PAR(highlight.nodes), arestas: EM_PAR(highlight.edges) };
|
|
397
721
|
if (highlightNeighbors && selectedId) {
|
|
398
722
|
const nos = new Set([selectedId]), arestas = new Set();
|
|
399
|
-
const
|
|
723
|
+
const ident0 = arestasParalelas(edges, vertical ? m.larg : m.alt);
|
|
400
724
|
edges.forEach((e, i) => { if (e.from === selectedId || e.to === selectedId) {
|
|
401
725
|
nos.add(e.from);
|
|
402
726
|
nos.add(e.to);
|
|
403
|
-
arestas.add(
|
|
727
|
+
arestas.add(ident0[i].id);
|
|
404
728
|
} });
|
|
405
729
|
return { nos, arestas };
|
|
406
730
|
}
|
|
407
731
|
return null;
|
|
408
732
|
}, [highlight, highlightNeighbors, selectedId, edges, vertical, m]);
|
|
409
733
|
// As setas e o "ir até" moram no lado de DENTRO do provedor (eles precisam do motor); o nó chama
|
|
410
|
-
// por esta referência estável, sem refazer a lista de nós a cada render.
|
|
734
|
+
// por esta referência estável, sem refazer a lista de nós a cada render. O botão de fechar também.
|
|
411
735
|
const teclado = React.useRef(undefined);
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}, [
|
|
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 ────────────────────────────────────────────────────────────────────
|
|
463
789
|
// ── A MEDIDA TEM DE PODER VOLTAR, e isto foi achado NO NAVEGADOR ───────────────────────
|
|
464
790
|
// A primeira versão passava `nodes` e nenhum `onNodesChange`. O motor aceita calado e o
|
|
465
791
|
// resultado é um grafo INVISÍVEL: ele só tira o `visibility:hidden` do nó depois de gravar
|
|
@@ -469,17 +795,302 @@ export function DependencyGraph({ nodes, edges, label, selectedId, onSelect, con
|
|
|
469
795
|
// não há layout para medir.
|
|
470
796
|
//
|
|
471
797
|
// A lista interna preserva `measured` quando os dados de fora mudam: sem isso, trocar a
|
|
472
|
-
// 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.
|
|
473
801
|
const [nosVivos, setNosVivos] = React.useState(nosDoMotor);
|
|
474
802
|
React.useEffect(() => {
|
|
475
803
|
setNosVivos(anteriores => {
|
|
476
|
-
const
|
|
477
|
-
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
|
+
});
|
|
478
809
|
});
|
|
479
|
-
}, [nosDoMotor]);
|
|
810
|
+
}, [nosDoMotor, editavel]);
|
|
811
|
+
const confirmar = React.useRef(() => { });
|
|
812
|
+
const abertosAgora = React.useRef(abertos);
|
|
813
|
+
abertosAgora.current = abertos;
|
|
480
814
|
const aoMudarNos = React.useCallback((mudancas) => {
|
|
481
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);
|
|
482
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]);
|
|
483
1094
|
// A LEGENDA se monta com o que está na tela: uma linha por nome de `legend`, com o desenho dela.
|
|
484
1095
|
const legenda = React.useMemo(() => {
|
|
485
1096
|
const vistas = new Map();
|
|
@@ -488,52 +1099,90 @@ export function DependencyGraph({ nodes, edges, label, selectedId, onSelect, con
|
|
|
488
1099
|
vistas.set(e.legend, e);
|
|
489
1100
|
return [...vistas.entries()];
|
|
490
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]);
|
|
491
1112
|
const raiz = React.useRef(null);
|
|
492
1113
|
// `initialWidth`/`initialHeight` no provider é o par do de cima, um nível acima: é o viewport
|
|
493
1114
|
// que o `fitView` usa ENQUANTO ninguém mediu. Com o prefixo `initial`, o navegador sobrescreve
|
|
494
1115
|
// na primeira medição — passar `width`/`height` direto no ReactFlow fixaria o tamanho e mataria
|
|
495
1116
|
// a fluidez. O valor é a extensão do próprio layout, então no servidor o grafo cabe exato.
|
|
496
1117
|
const estilo = { ...style, ...(height ? { blockSize: height } : null), "--graph-node-w": `${m.larg}px` };
|
|
497
|
-
return _jsx("div", { ref: raiz, className: cx("dependency-graph", className), style: estilo, "data-text": textSize === "md" ? undefined : textSize, "data-contrast": highContrast ? "high" : 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 }, nodesDraggable: !!onNodeMove, nodesConnectable: !!connectable, elementsSelectable:
|
|
498
|
-
onConnect({ from: c.source, to: c.target, fromPort: c.sourceHandle ?? undefined, toPort: c.targetHandle ?? undefined }); } : undefined, children: [_jsx(Background, {}), _jsx(Comandos, { raiz: raiz, teclado: teclado, focusId: focusId,
|
|
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)) }) })] }) }) });
|
|
499
1120
|
}
|
|
500
1121
|
// `CSS.escape` existe no navegador; fora dele (o teste em jsdom), as aspas bastam.
|
|
501
1122
|
const escapar = (id) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/[\x22\\]/g, "\\$&");
|
|
502
1123
|
/**
|
|
503
|
-
*
|
|
504
|
-
*
|
|
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`.
|
|
505
1146
|
*/
|
|
506
|
-
function Comandos({ raiz, teclado, focusId,
|
|
1147
|
+
function Comandos({ raiz, teclado, focusId, pedidoDeCaber, controls, apiRef, linhas, forma, titulo, desenho, editavel, barra, acoes }) {
|
|
507
1148
|
const rf = useReactFlow();
|
|
508
1149
|
const s = useAureaStrings();
|
|
509
1150
|
// Quem pediu menos movimento não vê a tela deslizar: o "ir até" e o caber na tela pulam direto.
|
|
510
1151
|
const duracao = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 };
|
|
517
1159
|
}, [rf]);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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]);
|
|
521
1168
|
React.useEffect(() => { if (focusId)
|
|
522
1169
|
centralizar(focusId); }, [focusId, centralizar]);
|
|
523
1170
|
// AS SETAS (MNT-19.4): o nó mais perto na direção da seta, dentro de um cone de 45°. O foco vai
|
|
524
|
-
// para o botão dele; se ele estiver fora da tela, a tela vai até ele.
|
|
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.
|
|
525
1173
|
React.useEffect(() => {
|
|
526
1174
|
teclado.current = (id, tecla) => {
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
if (!atual)
|
|
1175
|
+
const a = centro(id);
|
|
1176
|
+
if (!a)
|
|
530
1177
|
return;
|
|
531
|
-
const a = centro(atual);
|
|
532
1178
|
let melhor = null;
|
|
533
1179
|
for (const n of rf.getNodes()) {
|
|
534
|
-
if (n.id === id || n.hidden)
|
|
1180
|
+
if (n.id === id || n.hidden || n.type !== "aurea")
|
|
1181
|
+
continue;
|
|
1182
|
+
const b = centro(n.id);
|
|
1183
|
+
if (!b)
|
|
535
1184
|
continue;
|
|
536
|
-
const
|
|
1185
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
537
1186
|
const naDirecao = tecla === "ArrowRight" ? dx > 0 && Math.abs(dy) <= dx : tecla === "ArrowLeft" ? dx < 0 && Math.abs(dy) <= -dx
|
|
538
1187
|
: tecla === "ArrowDown" ? dy > 0 && Math.abs(dx) <= dy : dy < 0 && Math.abs(dx) <= -dy;
|
|
539
1188
|
if (!naDirecao)
|
|
@@ -544,25 +1193,74 @@ function Comandos({ raiz, teclado, focusId, posicoesProntas, controls, apiRef, i
|
|
|
544
1193
|
}
|
|
545
1194
|
if (!melhor)
|
|
546
1195
|
return;
|
|
547
|
-
const alvo = raiz.current?.querySelector(`.react-flow__node[data-id="${escapar(melhor.id)}"] button.graph-node-body`);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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))
|
|
551
1206
|
centralizar(melhor.id);
|
|
552
1207
|
};
|
|
553
|
-
}, [rf, raiz, teclado, centralizar]);
|
|
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]);
|
|
554
1250
|
React.useImperativeHandle(apiRef, () => ({
|
|
555
1251
|
fitView: () => { void rf.fitView({ duration: duracao() }); },
|
|
556
1252
|
zoomIn: () => { void rf.zoomIn({ duration: duracao() }); },
|
|
557
1253
|
zoomOut: () => { void rf.zoomOut({ duration: duracao() }); },
|
|
558
1254
|
focus: centralizar,
|
|
559
|
-
toDrawio: () => paraDrawio(rf.getNodes(),
|
|
1255
|
+
toDrawio: () => paraDrawio(rf.getNodes(), linhas, forma, titulo),
|
|
560
1256
|
toPng: () => foto(raiz.current, "png"),
|
|
561
1257
|
toSvg: () => foto(raiz.current, "svg"),
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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() }) })] })] });
|
|
566
1264
|
}
|
|
567
1265
|
/**
|
|
568
1266
|
* A FOTO do mapa (MNT-19.2), pelo `modern-screenshot` (peer OPCIONAL, MIT), carregado só aqui. Sai
|