/* 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 {M[name]||null}; }; // ─── 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 */} {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 */} {/* Connections */} {connections.map(({n, p}, i) => { const dy = (n.y - p.y) / 2; const d = `M ${p.x} ${p.y+H/2} C ${p.x} ${p.y+H/2+dy}, ${n.x} ${n.y-H/2-dy}, ${n.x} ${n.y-H/2}`; const isSel = n.id === selId || p.id === selId; return ( ); })} {/* Nodes */} {nodes.map(n => ( ))} {/* Empty state */} {!nodes.length && ( Adicione um bloco da paleta Comece pela CONCESSIONÁRIA ou clique em "Carregar exemplo" → )} {/* 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}
IDENTIFICAÇÃO
onChange({label:e.target.value})}/>
up("descricao",e.target.value)}/>
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 (
{GLYPHS[t]} {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 }) => (
onChange(e.target.value)}/> {unit && {unit}}
); const TF = ({ label, val, onChange }) => (
onChange(e.target.value)}/>
); // ─── Exportar ───────────────────────────────────────────────────────────────── window.VL_Unifilar = Unifilar;