/* global React */
/* ===========================================================
VOLTLAB · DIAGRAMAS UNIFILARES v2
Editor completo com estilos embutidos — não depende de app.css
=========================================================== */
const { useState, useRef, useEffect, useMemo, useCallback } = React;
// ─── Persistência ────────────────────────────────────────────────────────────
const usePersist = (key, initial) => {
if (window.useLocalStorage) return window.useLocalStorage(key, initial);
return React.useState(initial);
};
// ─── CSS embutido ─────────────────────────────────────────────────────────────
const UNI_CSS = `
.uni-shell {
display: grid;
grid-template-columns: 220px 1fr 260px;
height: calc(100vh - 64px);
background: var(--bg-0, #0a0e14);
color: var(--fg, #c8d8f0);
font-family: "IBM Plex Sans", sans-serif;
overflow: hidden;
}
/* ── Palette ── */
.uni-palette {
background: var(--bg-1, #0f1520);
border-right: 1px solid var(--border, rgba(100,160,255,0.1));
display: flex;
flex-direction: column;
overflow: hidden;
}
.uni-palette-head {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border, rgba(100,160,255,0.1));
}
.uni-palette-eyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: .12em;
color: var(--accent, #3d8bff);
margin-bottom: 2px;
}
.uni-palette-sub {
font-size: 11px;
color: var(--fg-dim, #6a8ab0);
}
.uni-palette-scroll {
overflow-y: auto;
flex: 1;
padding: 8px 0;
}
.uni-pgroup-title {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
letter-spacing: .15em;
color: var(--fg-mute, #3a5070);
padding: 10px 16px 4px;
text-transform: uppercase;
}
.uni-pitem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: none;
border: none;
padding: 7px 14px;
cursor: pointer;
color: var(--fg, #c8d8f0);
border-radius: 0;
transition: background .12s;
text-align: left;
}
.uni-pitem:hover { background: rgba(61,139,255,0.08); }
.uni-pitem.tone-warn { color: var(--warn, #f2b043); }
.uni-pitem.tone-ok { color: var(--ok, #5be38c); }
.uni-pitem-icon {
flex-shrink: 0;
opacity: .85;
}
.uni-pitem-meta b { font-size: 12px; display: block; }
.uni-pitem-meta em { font-size: 10px; color: var(--fg-dim, #6a8ab0); font-style: normal; }
/* ── Stage ── */
.uni-stage {
position: relative;
overflow: hidden;
background: var(--bg-0, #0a0e14);
cursor: grab;
}
.uni-stage:active { cursor: grabbing; }
.uni-topbar {
position: absolute;
top: 0; left: 0; right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: linear-gradient(to bottom, var(--bg-1,#0f1520) 60%, transparent);
gap: 8px;
}
.uni-topbar-l, .uni-topbar-r {
display: flex;
align-items: center;
gap: 8px;
}
.uni-pill {
display: flex;
align-items: center;
gap: 6px;
background: rgba(61,139,255,0.08);
border: 1px solid rgba(61,139,255,0.15);
border-radius: 20px;
padding: 3px 10px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: var(--fg-dim, #6a8ab0);
}
.uni-toggle {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
background: none;
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 4px;
padding: 4px 10px;
color: var(--fg-dim, #6a8ab0);
cursor: pointer;
transition: all .15s;
}
.uni-toggle.is-on {
background: rgba(61,139,255,0.12);
color: var(--accent, #3d8bff);
border-color: rgba(61,139,255,0.3);
}
.uni-coord {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: var(--fg-mute, #3a5070);
}
.uni-iconbtn {
background: none;
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 5px;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
color: var(--fg-dim, #6a8ab0);
transition: all .15s;
}
.uni-iconbtn:hover {
background: rgba(61,139,255,0.1);
color: var(--accent, #3d8bff);
border-color: rgba(61,139,255,0.25);
}
.uni-btn-ghost {
background: none;
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 5px;
padding: 5px 12px;
cursor: pointer;
color: var(--fg-dim, #6a8ab0);
font-family: "JetBrains Mono", monospace;
font-size: 11px;
display: flex; align-items: center; gap: 5px;
transition: all .15s;
}
.uni-btn-ghost:hover {
background: rgba(61,139,255,0.08);
color: var(--accent, #3d8bff);
}
.uni-svg {
width: 100%;
height: 100%;
display: block;
}
.uni-empty-hint text { font-family: "IBM Plex Sans", sans-serif; }
.uni-bottombar {
position: absolute;
bottom: 0; left: 0; right: 0;
background: linear-gradient(to top, var(--bg-1,#0f1520) 60%, transparent);
padding: 8px 16px;
display: flex;
gap: 20px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: var(--fg-mute, #3a5070);
align-items: center;
}
.uni-bottombar b { color: var(--fg, #c8d8f0); }
.uni-bottombar .readout { color: var(--accent, #3d8bff); }
.uni-help { margin-left: auto; color: var(--fg-mute, #3a5070); font-size: 9px; }
/* ── Nodes SVG ── */
.uni-node-card {
fill: var(--bg-1, #0f1520);
stroke: var(--border-2, rgba(100,160,255,0.2));
stroke-width: 1;
rx: 8;
transition: all .15s;
}
.uni-node.tone-warn .uni-node-card { stroke: rgba(242,176,67,0.35); }
.uni-node.tone-ok .uni-node-card { stroke: rgba(91,227,140,0.35); }
.uni-node.is-sel .uni-node-card {
stroke: var(--accent, #3d8bff);
stroke-width: 1.5;
filter: drop-shadow(0 0 8px var(--accent-glow, rgba(61,139,255,0.4)));
}
.uni-node.tone-warn.is-sel .uni-node-card { stroke: #f2b043; filter: drop-shadow(0 0 8px rgba(242,176,67,0.4)); }
.uni-node.tone-ok.is-sel .uni-node-card { stroke: #5be38c; filter: drop-shadow(0 0 8px rgba(91,227,140,0.4)); }
.uni-node-label { font-family: "JetBrains Mono", monospace; font-size: 9.5px; fill: var(--fg-dim, #6a8ab0); }
.uni-node.is-sel .uni-node-label { fill: var(--accent, #3d8bff); }
.uni-node-foot { font-family: "JetBrains Mono", monospace; font-size: 8.5px; fill: var(--fg-mute, #3a5070); }
.uni-node.tone-warn .uni-node-foot { fill: rgba(242,176,67,0.7); }
.uni-node.tone-ok .uni-node-foot { fill: rgba(91,227,140,0.6); }
.uni-glyph { color: var(--fg-dim, #6a8ab0); }
.uni-node.tone-warn .uni-glyph { color: #f2b043; }
.uni-node.tone-ok .uni-glyph { color: #5be38c; }
.uni-node.is-sel .uni-glyph { color: var(--accent, #3d8bff); }
/* connections */
.uni-conn {
fill: none;
stroke: var(--border-2, rgba(100,160,255,0.25));
stroke-width: 1.5;
}
/* ── Inspector ── */
.uni-insp {
background: var(--bg-1, #0f1520);
border-left: 1px solid var(--border, rgba(100,160,255,0.1));
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px;
gap: 0;
}
.uni-insp-eyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
letter-spacing: .14em;
color: var(--accent, #3d8bff);
margin-bottom: 4px;
}
.uni-insp h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 2px;
color: var(--fg, #c8d8f0);
}
.uni-insp-sub {
font-size: 10px;
color: var(--fg-mute, #3a5070);
font-family: "JetBrains Mono", monospace;
margin-bottom: 14px;
}
.uni-section {
margin-top: 14px;
border-top: 1px solid var(--border, rgba(100,160,255,0.08));
padding-top: 12px;
}
.uni-section-title {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
letter-spacing: .12em;
color: var(--fg-mute, #3a5070);
margin-bottom: 10px;
}
.uni-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.uni-field label {
font-size: 11px;
color: var(--fg-dim, #6a8ab0);
}
.uni-field input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 5px;
color: var(--fg, #c8d8f0);
padding: 6px 10px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
width: 100%;
box-sizing: border-box;
outline: none;
transition: border-color .15s;
}
.uni-field input:focus { border-color: var(--accent, #3d8bff); }
.uni-field-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.uni-field-row label { font-size: 11px; color: var(--fg-dim, #6a8ab0); }
.uni-field-row input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 5px;
color: var(--fg, #c8d8f0);
padding: 5px 8px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
width: 90px;
text-align: right;
outline: none;
}
.uni-field-unit { font-size: 10px; color: var(--fg-mute, #3a5070); font-family: "JetBrains Mono", monospace; }
.uni-stat-card {
background: rgba(0,0,0,0.2);
border: 1px solid var(--border, rgba(100,160,255,0.08));
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
}
.uni-stat-card span { font-size: 10px; color: var(--fg-mute, #3a5070); display: block; margin-bottom: 2px; }
.uni-stat-card b { font-family: "JetBrains Mono", monospace; font-size: 18px; color: var(--accent, #3d8bff); }
.uni-stat-card em { font-size: 11px; color: var(--fg-dim, #6a8ab0); font-style: normal; }
.uni-stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.uni-del-btn {
width: 100%;
background: rgba(248,100,100,0.06);
border: 1px solid rgba(248,100,100,0.2);
border-radius: 6px;
color: #f87171;
padding: 8px;
cursor: pointer;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
margin-top: 12px;
display: flex; align-items: center; justify-content: center; gap: 6px;
transition: all .15s;
}
.uni-del-btn:hover { background: rgba(248,100,100,0.12); }
.uni-parent-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 5px;
color: var(--fg, #c8d8f0);
padding: 6px 8px;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
width: 100%;
outline: none;
margin-bottom: 8px;
}
.uni-type-list { display: flex; flex-direction: column; gap: 4px; }
.uni-type-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 5px;
background: rgba(0,0,0,0.2);
font-size: 11px;
}
.uni-type-row.tone-warn { color: #f2b043; }
.uni-type-row.tone-ok { color: #5be38c; }
.uni-type-count { margin-left: auto; font-family: "JetBrains Mono", monospace; font-size: 12px; }
.uni-tip {
display: flex;
gap: 8px;
align-items: flex-start;
background: rgba(61,139,255,0.05);
border: 1px solid rgba(61,139,255,0.1);
border-radius: 6px;
padding: 10px;
margin-top: 14px;
font-size: 11px;
color: var(--fg-dim, #6a8ab0);
line-height: 1.5;
}
.uni-clear-btn {
width: 100%;
background: none;
border: 1px solid var(--border, rgba(100,160,255,0.12));
border-radius: 6px;
color: var(--fg-dim, #6a8ab0);
padding: 8px;
cursor: pointer;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
margin-top: 10px;
display: flex; align-items: center; justify-content: center; gap: 6px;
transition: all .15s;
}
.uni-clear-btn:hover { border-color: #f87171; color: #f87171; }
`;
// ─── Injetar CSS uma vez ──────────────────────────────────────────────────────
function injectCSS() {
if (document.getElementById("vl-uni-styles")) return;
const el = document.createElement("style");
el.id = "vl-uni-styles";
el.textContent = UNI_CSS;
document.head.appendChild(el);
}
// ─── Símbolos unifilares ──────────────────────────────────────────────────────
const GLYPHS = {
concessionaria: (
UTIL
),
medidor: (
Wh
),
transformador: (
Δ / Y
),
disjuntor: (
),
dr: (
IDR
),
dps: (
DPS
),
barramento: (
),
quadro: (
QD
),
motor: (
M
),
iluminacao: (
LUM
),
tomada: (
TUG/E
),
ar_cond: (
AR-COND
),
carga: (
Z
),
banco_cap: (
),
};
// ─── Paleta ──────────────────────────────────────────────────────────────────
const PALETTE = [
{ group: "Entrada", items: [
{ type: "concessionaria", label: "Concessionária", tone: "warn", defaults: { tensao: 13800, demanda: 100 } },
{ type: "medidor", label: "Medidor", tone: "warn", defaults: { descricao: "Medição de energia" } },
{ type: "transformador", label: "Transformador", tone: "accent", defaults: { primario: 13800, secundario: 380, potencia: 75 } },
]},
{ group: "Proteção", items: [
{ type: "disjuntor", label: "Disjuntor", tone: "warn", defaults: { corrente: 100, tipo: "Caixa Moldada", curva: "C" } },
{ type: "dr", label: "DR / IDR", tone: "warn", defaults: { corrente: 40, sensibilidade: 30 } },
{ type: "dps", label: "DPS", tone: "warn", defaults: { classe: "II", in: 20 } },
]},
{ group: "Distribuição", items: [
{ type: "barramento", label: "Barramento", tone: "accent", defaults: { tensao: 380, corrente: 200 } },
{ type: "quadro", label: "Quadro QD", tone: "accent", defaults: { circuitos: 12, padrao: "QD-01" } },
]},
{ group: "Cargas", items: [
{ type: "motor", label: "Motor", tone: "ok", defaults: { potencia: 5, tensao: 380, fp: 0.85, descricao: "Motor 3~" } },
{ type: "iluminacao", label: "Iluminação", tone: "ok", defaults: { potencia: 1.5, tensao: 220, fp: 0.95 } },
{ type: "tomada", label: "TUG / TUE", tone: "ok", defaults: { potencia: 2.5, tensao: 220, fp: 0.95 } },
{ type: "ar_cond", label: "Ar-Cond.", tone: "ok", defaults: { potencia: 3.5, tensao: 220, fp: 0.90 } },
{ type: "carga", label: "Carga genérica", tone: "ok", defaults: { potencia: 2.0, tensao: 380, fp: 0.90 } },
{ type: "banco_cap", label: "Banco Capacitor",tone: "ok", defaults: { potencia: 25, descricao: "Correção FP" } },
]},
];
const TYPE_INFO = {};
PALETTE.forEach(g => g.items.forEach(it => { TYPE_INFO[it.type] = it; }));
// ─── Demanda ──────────────────────────────────────────────────────────────────
function nodePower(n) {
const p = parseFloat(n.props?.potencia || 0);
if (n.type === "motor") return p * 0.736;
if (["iluminacao","tomada","ar_cond","carga"].includes(n.type)) return p;
return 0;
}
function subtreeDemand(node, nodes) {
let total = nodePower(node);
nodes.filter(n => n.parent === node.id).forEach(c => { total += subtreeDemand(c, nodes); });
return total;
}
// ─── Layout ───────────────────────────────────────────────────────────────────
function layoutTree(nodes) {
const HSP = 140, VSP = 130;
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const childrenOf = id => nodes.filter(n => n.parent === id);
const roots = nodes.filter(n => !n.parent || !byId[n.parent]);
const wCache = {};
function calcW(node) {
if (wCache[node.id]) return wCache[node.id];
const ch = childrenOf(node.id);
const w = ch.length === 0 ? 1 : ch.reduce((s, c) => s + calcW(c), 0);
return (wCache[node.id] = w);
}
roots.forEach(r => calcW(r));
function place(node, x, y) {
const w = wCache[node.id];
node.x = x + (w * HSP) / 2 - HSP / 2;
node.y = y;
let cx = x;
childrenOf(node.id).forEach(c => { place(c, cx, y + VSP); cx += wCache[c.id] * HSP; });
}
let cursor = 0;
roots.forEach(r => { place(r, cursor, 40); cursor += wCache[r.id] * HSP; });
return [...nodes];
}
// ─── Auto-label ───────────────────────────────────────────────────────────────
function autoLabel(type, nodes) {
const n = nodes.filter(x => x.type === type).length + 1;
const map = {
concessionaria: "CONCESSIONÁRIA",
medidor: `MED-${String(n).padStart(2,"0")}`,
transformador: `TR-${String(n).padStart(2,"0")}`,
disjuntor: `DJ-${String(n).padStart(2,"0")}`,
dr: `IDR-${String(n).padStart(2,"0")}`,
dps: `DPS-${String(n).padStart(2,"0")}`,
barramento: `BAR-${String(n).padStart(2,"0")}`,
quadro: `QD-${String(n).padStart(2,"0")}`,
motor: `M-${String(n).padStart(2,"0")}`,
iluminacao: `IL-${String(n).padStart(2,"0")}`,
tomada: `TUG-${String(n).padStart(2,"0")}`,
ar_cond: `AC-${String(n).padStart(2,"0")}`,
carga: `CG-${String(n).padStart(2,"0")}`,
banco_cap: `BC-${String(n).padStart(2,"0")}`,
};
return map[type] || type.toUpperCase();
}
// ─── Diagrama de exemplo ──────────────────────────────────────────────────────
function buildSample() {
const id = () => "n" + Math.random().toString(36).slice(2,8);
const c = { id:id(), type:"concessionaria", label:"CONCESSIONÁRIA", parent:null, props:{tensao:13800,demanda:100} };
const m = { id:id(), type:"medidor", label:"MED-01", parent:c.id, props:{} };
const t = { id:id(), type:"transformador", label:"TR-01", parent:m.id, props:{primario:13800,secundario:380,potencia:75} };
const dj = { id:id(), type:"disjuntor", label:"DJ-GERAL", parent:t.id, props:{corrente:125,tipo:"Caixa Moldada",curva:"C"} };
const dps = { id:id(), type:"dps", label:"DPS-01", parent:dj.id, props:{classe:"II",in:20} };
const bar = { id:id(), type:"barramento", label:"BAR-PRINCIPAL", parent:dps.id,props:{tensao:380,corrente:200} };
const q1 = { id:id(), type:"quadro", label:"QDLF-01", parent:bar.id,props:{padrao:"Iluminação",circuitos:12} };
const q2 = { id:id(), type:"quadro", label:"QDF-01", parent:bar.id,props:{padrao:"Força",circuitos:8} };
const d1 = { id:id(), type:"disjuntor", label:"DJ-01", parent:q1.id, props:{corrente:20,tipo:"Din",curva:"B"} };
const d2 = { id:id(), type:"disjuntor", label:"DJ-02", parent:q1.id, props:{corrente:16,tipo:"Din",curva:"B"} };
const d3 = { id:id(), type:"disjuntor", label:"DJ-03", parent:q2.id, props:{corrente:32,tipo:"Din",curva:"C"} };
const d4 = { id:id(), type:"disjuntor", label:"DJ-04", parent:q2.id, props:{corrente:16,tipo:"Din",curva:"B"} };
const il = { id:id(), type:"iluminacao", label:"IL-01", parent:d1.id, props:{potencia:2.5,tensao:220,fp:0.95} };
const tu = { id:id(), type:"tomada", label:"TUG-01", parent:d2.id, props:{potencia:3.0,tensao:220,fp:0.95} };
const mo = { id:id(), type:"motor", label:"M-01", parent:d3.id, props:{potencia:10,tensao:380,fp:0.85} };
const ac = { id:id(), type:"ar_cond", label:"AC-01", parent:d4.id, props:{potencia:3.5,tensao:220,fp:0.90} };
return [c,m,t,dj,dps,bar,q1,q2,d1,d2,d3,d4,il,tu,mo,ac];
}
// ─── Ícones ───────────────────────────────────────────────────────────────────
const SvgIcon = ({ name, size=16 }) => {
const p = { width:size, height:size, viewBox:"0 0 24 24", fill:"none",
stroke:"currentColor", strokeWidth:1.6, strokeLinecap:"round", strokeLinejoin:"round" };
const M = {
undo: <>>,
redo: <>>,
fit: <>>,
download:<>>,
trash: <>>,
reload: <>>,
info: <>>,
link: <>>,
};
return ;
};
// ─── Nó SVG ───────────────────────────────────────────────────────────────────
const W = 96, H = 96;
function footText(n, demand) {
switch (n.type) {
case "concessionaria": return `${n.props?.tensao ?? "—"} V`;
case "medidor": return "medição";
case "transformador": return `${n.props?.potencia ?? "—"} kVA`;
case "disjuntor": return `${n.props?.corrente ?? "—"} A · Cv ${n.props?.curva ?? "—"}`;
case "dr": return `${n.props?.corrente ?? "—"} A · ${n.props?.sensibilidade ?? "—"} mA`;
case "dps": return `Cl ${n.props?.classe ?? "—"} · ${n.props?.in ?? "—"} kA`;
case "barramento": return `${demand.toFixed(1)} kW`;
case "quadro": return `${demand.toFixed(1)} kW · ${n.props?.circuitos ?? "—"} circ`;
case "motor": return `${n.props?.potencia ?? "—"} cv`;
case "banco_cap": return `${n.props?.potencia ?? "—"} kVAr`;
default: return `${n.props?.potencia ?? "—"} kW`;
}
}
const NodeShape = ({ node, selected, demand, onClick }) => {
const info = TYPE_INFO[node.type];
const tone = info?.tone || "accent";
const glyph = GLYPHS[node.type];
const label = node.label || info?.label || node.type;
return (
{ e.stopPropagation(); onClick(node.id); }}
style={{ cursor: "pointer" }}>
{/* label tab */}
{label}
{/* glyph */}
{/* footer */}
{footText(node, demand)}
);
};
// ─── Componente principal ─────────────────────────────────────────────────────
const Unifilar = () => {
useEffect(() => { injectCSS(); }, []);
const [nodes, setNodes] = usePersist("vl_unifilar_v2", []);
const [selId, setSelId] = useState(null);
const [autoLayout, setAutoLayout] = useState(true);
const [history, setHistory] = useState([[]]);
const [hIdx, setHIdx] = useState(0);
const [zoom, setZoom] = useState(0.85);
const [pan, setPan] = useState({ x: 120, y: 80 });
const dragRef = useRef(null);
const stageRef = useRef(null);
const [openGroup, setOpenGroup] = useState("Entrada");
const selected = nodes.find(n => n.id === selId) || null;
const demands = useMemo(() => {
const m = {};
nodes.forEach(n => { m[n.id] = subtreeDemand(n, nodes); });
return m;
}, [nodes]);
const totalDemand = useMemo(() =>
nodes.filter(n => !n.parent).reduce((s, r) => s + demands[r.id], 0),
[nodes, demands]);
const commit = useCallback((next) => {
const out = autoLayout ? layoutTree(next.map(n => ({...n}))) : next;
setNodes(out);
setHistory(h => { const nh = h.slice(0, hIdx+1); nh.push(out); return nh; });
setHIdx(i => i + 1);
}, [autoLayout, hIdx]);
const doUndo = () => { if (hIdx <= 0) return; const i = hIdx-1; setHIdx(i); setNodes(history[i]); };
const doRedo = () => { if (hIdx >= history.length-1) return; const i = hIdx+1; setHIdx(i); setNodes(history[i]); };
// Zoom & pan
const onWheel = e => {
e.preventDefault();
const rect = stageRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const factor = e.deltaY > 0 ? 0.92 : 1.09;
const nz = Math.max(0.2, Math.min(3, zoom * factor));
const wx = (mx - pan.x) / zoom, wy = (my - pan.y) / zoom;
setPan({ x: mx - wx*nz, y: my - wy*nz });
setZoom(nz);
};
const onMDown = e => {
if (e.target.closest(".uni-node")) return;
dragRef.current = { sx: e.clientX, sy: e.clientY, pan: {...pan} };
setSelId(null);
};
const onMMove = e => {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.sx, dy = e.clientY - dragRef.current.sy;
setPan({ x: dragRef.current.pan.x + dx, y: dragRef.current.pan.y + dy });
};
const onMUp = () => { dragRef.current = null; };
const fitToScreen = useCallback(() => {
if (!nodes.length || !stageRef.current) return;
const rect = stageRef.current.getBoundingClientRect();
const xs = nodes.map(n=>n.x), ys = nodes.map(n=>n.y);
const minX = Math.min(...xs)-70, maxX = Math.max(...xs)+70;
const minY = Math.min(...ys)-70, maxY = Math.max(...ys)+70;
const z = Math.min((rect.width-40)/(maxX-minX), (rect.height-80)/(maxY-minY), 1.4);
setZoom(z);
setPan({ x: rect.width/2 - (minX+maxX)/2*z, y: 50 - minY*z });
}, [nodes]);
useEffect(() => { if (nodes.length) setTimeout(fitToScreen, 80); }, []);
// Adicionar nó
const addNode = type => {
const info = TYPE_INFO[type];
const id = "n" + Math.random().toString(36).slice(2,8);
// Escolher pai: se selecionado, usa ele; senão busca o último barramento/quadro/raiz
let parent = selId;
if (!parent && nodes.length) {
const pref = [...nodes].reverse().find(n => ["barramento","quadro"].includes(n.type));
parent = pref?.id || nodes[nodes.length-1].id;
}
const newNode = { id, type, label: autoLabel(type, nodes), parent: parent||null, x:0, y:0, props:{...info.defaults} };
commit([...nodes, newNode]);
setSelId(id);
};
// Deletar nó e seus filhos
const deleteNode = id => {
const toDelete = new Set([id]);
let changed = true;
while (changed) {
changed = false;
nodes.forEach(n => { if (n.parent && toDelete.has(n.parent) && !toDelete.has(n.id)) { toDelete.add(n.id); changed = true; } });
}
commit(nodes.filter(n => !toDelete.has(n.id)));
setSelId(null);
};
// Atualizar nó selecionado
const updateNode = patch => {
if (!selId) return;
commit(nodes.map(n => n.id === selId ? { ...n, ...patch, props: { ...n.props, ...(patch.props||{}) } } : n));
};
// Reparent
const reparent = newParent => {
if (!selId) return;
// Evitar ciclos
if (newParent === selId) return;
commit(nodes.map(n => n.id === selId ? { ...n, parent: newParent||null } : n));
};
const clearAll = () => { if (confirm("Limpar todo o diagrama?")) { commit([]); setSelId(null); } };
const loadSample = () => { const s = layoutTree(buildSample()); commit(s); setSelId(null); setTimeout(fitToScreen, 60); };
const exportSVG = () => {
const el = stageRef.current?.querySelector("svg");
if (!el) return;
const xml = new XMLSerializer().serializeToString(el);
const blob = new Blob([xml], { type:"image/svg+xml" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "voltlab-unifilar.svg"; a.click();
URL.revokeObjectURL(a.href);
};
// Keyboard
useEffect(() => {
const onKey = e => {
if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
if ((e.key === "Delete" || e.key === "Backspace") && selId) deleteNode(selId);
if (e.key === "0") fitToScreen();
if (e.ctrlKey && e.key === "z") { e.preventDefault(); doUndo(); }
if (e.ctrlKey && e.key === "y") { e.preventDefault(); doRedo(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [selId, nodes, hIdx]);
const connections = nodes.filter(n=>n.parent).map(n=>({ n, p: nodes.find(x=>x.id===n.parent) })).filter(c=>c.p);
const stats = useMemo(() => {
const m = {};
nodes.forEach(n => { m[n.type] = (m[n.type]||0)+1; });
return m;
}, [nodes]);
return (
{/* ── PALETTE ── */}
{/* ── CANVAS ── */}
e.preventDefault()}>
{/* Top bar */}
{nodes.length ? `${nodes.length} blocos · ${connections.length} conexões` : "Diagrama vazio"}
ZOOM {(zoom*100).toFixed(0)}%
{/* SVG */}
{/* Bottom bar */}
BLOCOS · {nodes.length}
QUADROS · {stats.quadro||0}
CARGAS · {(stats.motor||0)+(stats.iluminacao||0)+(stats.tomada||0)+(stats.ar_cond||0)+(stats.carga||0)}
DEMANDA · {totalDemand.toFixed(2)} kW
DEL=remover · 0=ajustar · Ctrl+Z=desfazer · Scroll=zoom
{/* ── INSPECTOR ── */}
);
};
// ─── Inspector ────────────────────────────────────────────────────────────────
const Inspector = ({ node, nodes, demands, onChange, onDelete, onReparent }) => {
const info = TYPE_INFO[node.type];
const up = (key, val) => onChange({ props: { [key]: val } });
const demand = demands[node.id] || 0;
// Nós que podem ser pais (exceto ele mesmo e seus filhos)
const forbidden = new Set([node.id]);
let changed = true;
while (changed) {
changed = false;
nodes.forEach(n => { if (n.parent && forbidden.has(n.parent) && !forbidden.has(n.id)) { forbidden.add(n.id); changed = true; } });
}
const possibleParents = nodes.filter(n => !forbidden.has(n.id));
return (
SELECIONADO
{node.label}
{info?.label} · {node.id}
CONEXÃO
PARÂMETROS TÉCNICOS
{/* Concessionária */}
{node.type === "concessionaria" && <>
up("tensao",v)}/>
up("demanda",v)}/>
>}
{/* Transformador */}
{node.type === "transformador" && <>
up("primario",v)}/>
up("secundario",v)}/>
up("potencia",v)}/>
up("zcc",v)}/>
>}
{/* Disjuntor */}
{node.type === "disjuntor" && <>
up("corrente",v)}/>
up("tipo",v)}/>
up("curva",v)}/>
up("pdc",v)}/>
>}
{/* DR */}
{node.type === "dr" && <>
up("corrente",v)}/>
up("sensibilidade",v)}/>
>}
{/* DPS */}
{node.type === "dps" && <>
up("classe",v)}/>
up("in",v)}/>
up("up",v)}/>
>}
{/* Barramento */}
{node.type === "barramento" && <>
up("tensao",v)}/>
up("corrente",v)}/>
>}
{/* Quadro */}
{node.type === "quadro" && <>
up("padrao",v)}/>
up("circuitos",v)}/>
>}
{/* Motor */}
{node.type === "motor" && <>
up("potencia",v)}/>
up("tensao",v)}/>
up("fp",v)}/>
up("rend",v)}/>
>}
{/* Banco Capacitor */}
{node.type === "banco_cap" && <>
up("potencia",v)}/>
>}
{/* Cargas genéricas */}
{["iluminacao","tomada","ar_cond","carga"].includes(node.type) && <>
up("potencia",v)}/>
up("tensao",v)}/>
up("fp",v)}/>
>}
{/* Medidor — sem campos extras */}
{node.type === "medidor" && Sem parâmetros editáveis.
}
{/* Concessionária já tratada */}
{/* Demanda da subárvore */}
{["barramento","quadro","disjuntor"].includes(node.type) && (
DEMANDA DA SUBÁRVORE
POTÊNCIA
{demand.toFixed(2)} kW
CORRENTE 3F·380V
{(demand*1000/(Math.sqrt(3)*380*0.92)).toFixed(1)} A
)}
);
};
// ─── Overview (sem seleção) ───────────────────────────────────────────────────
const Overview = ({ totalDemand, stats, onClear }) => (
DIAGRAMA
Visão geral
DEMANDA TOTAL
{totalDemand.toFixed(2)} kW
POTÊNCIA APARENTE
{(totalDemand/0.92).toFixed(2)} kVA
CORRENTE NOMINAL (380V · 3Φ · FP 0,92)
{(totalDemand*1000/(Math.sqrt(3)*380*0.92)).toFixed(1)} A
CONTAGEM POR TIPO
{Object.entries(stats).map(([t, c]) => {
const info = TYPE_INFO[t];
return (
{info?.label||t}
{String(c).padStart(2,"0")}
);
})}
{Object.keys(stats).length === 0 && (
Nenhum bloco adicionado.
)}
Selecione um bloco para editar. Ao adicionar da paleta, o novo bloco conecta-se ao selecionado.
{Object.keys(stats).length > 0 && (
)}
);
// ─── Campos de formulário ─────────────────────────────────────────────────────
const NF = ({ label, unit, val, onChange }) => (
);
const TF = ({ label, val, onChange }) => (
onChange(e.target.value)}/>
);
// ─── Exportar ─────────────────────────────────────────────────────────────────
window.VL_Unifilar = Unifilar;