// ============================================================================ // H1 · Tervezés modul — kábelezési terv // ---------------------------------------------------------------------------- // A különálló Synapse wiring planner (02-wiring-plan.html) beépítve a H1-be: // ugyanaz a felület, ugyanaz a funkcionalitás, ugyanaz az alaprajz-háttér és // ugyanaz az elemkészlet — de a terv nem a böngészőben, hanem a szerveren, egy // közös dokumentumban él (plan_wiring tábla), így mindenki ugyanazt látja. // // Asztali gép (≥900px) + planner.edit jog → teljes SZERKESZTŐ // Mobil, vagy csak planner.view jog → NÉZEGETŐ (csak olvasható) // // A modul szerepenként kapcsolható be: Beállítások → Jogosultságok → // „Tervezés megtekintése" / „Tervezés szerkesztése". // // Az eszközkatalógus (típusok, színek, csoportok) a planner-data.js-ben van, // szó szerint a forrás tervezőből átemelve. // ============================================================================ const PLAN_GEOM = (typeof window !== 'undefined' && window.PLANNER_GEOM) || { w: 1170, h: 1654, bg: 'assets/plan/floorplan.png' }; const PLAN_CATS = ['infra', 'cam', 'sen', 'pow', 'lig', 'av', 'ann']; // A rétegkapcsolók színmintái — a kategória „vezérszíne". const PLAN_CAT_COLORS = { infra: '#1d4ed8', cam: '#7c3aed', sen: '#16a34a', pow: '#dc2626', lig: '#f59e0b', av: '#c026d3', ann: '#eab308', }; const planTypes = () => window.PLANNER_TYPES || {}; const planCatNames = () => window.PLANNER_CATNAMES || {}; const planType = (t) => planTypes()[t] || null; // Egy elem megjelenített neve: a saját neve, vagy a típus alapértelmezése. const planLabel = (el) => { const t = planType(el.type); return el.label || (t ? t.label : el.type); }; // ═══ KÁBELEZÉS ══════════════════════════════════════════════════════════════ // Egy kábel két lerakott eszközt köt össze, közte tetszőleges töréspontokkal // (`pts`). A két végpont NEM tárolódik: mindig az eszközök aktuális helyéből // jön, így egy eszköz elhúzásakor a rá kötött nyomvonalak maguktól követik. const planCableTypes = () => window.PLANNER_CABLES || {}; const cableType = (k) => planCableTypes()[k] || null; const PLAN_SCALE_DEFAULT = 0.0254; // m / tervezési egység (A3 @100dpi, M=1:100) // Melyik kábel és melyik gyűjtőpont tartozik egy eszközhöz. Először a // típus-specifikus kivétel (hangszóró → erősítő), utána a réteg alapértelmezése. const cablePlanFor = (el) => { const ov = (window.PLANNER_CABLE_OVERRIDE || {})[el.type]; if (ov) return ov; const t = planType(el.type); const cat = t ? t.cat : 'infra'; return { cable: (window.PLANNER_CABLE_DEFAULT || {})[cat] || 'cat6a', hubs: (window.PLANNER_CABLE_HUBS || {})[cat] || ['core'], }; }; // Egy nyomvonal MEGÁLLÓK rendezett sora. Egy megálló vagy egy lerakott eszköz // ({ el: id }), vagy egy szabad töréspont ({ x, y }). Így ugyanaz a szerkezet // írja le a két eszköz közti egyszerű bekötést és a több eszközt fűző kört is // (pl. egy világítási kör több lámpatesttel, egy hangszórókör több hangszóróval). // // A régi, kétvégpontos formátum (from/to/pts) visszafelé olvasható marad. const cableNodes = (cable) => { if (Array.isArray(cable.nodes) && cable.nodes.length) return cable.nodes; const out = []; if (cable.from) out.push({ el: cable.from }); (cable.pts || []).forEach(p => out.push({ x: p[0], y: p[1] })); if (cable.to) out.push({ el: cable.to }); return out; }; // A nyomvonalon érintett eszközök — ez adja a „mely eszközök lógnak rajta" // kérdésre a választ (törléskori takarítás, eszköz-lap nyomvonallistája). const cableDeviceIds = (cable) => cableNodes(cable).filter(n => n.el).map(n => n.el); // Megállók → képernyőpontok. A hiányzó eszközöket kihagyja; a rajzolás alatt // álló lánchoz kell, ahol egyetlen megálló is érvényes állapot. const resolveNodes = (nodes, byId) => { const pts = []; for (const n of nodes || []) { if (n.el) { const e = byId[n.el]; if (e) pts.push([e.x, e.y]); } else if (n.x != null && n.y != null) { pts.push([n.x, n.y]); } } return pts; }; // A kirajzolandó pontsor egy KÉSZ nyomvonalhoz. Ha bármelyik eszköz-megálló // hiányzik, a nyomvonal nem rajzolható (ilyet a takarítás úgyis eltávolít). const cablePoints = (cable, byId) => { const nodes = cableNodes(cable); for (const n of nodes) if (n.el && !byId[n.el]) return null; const pts = resolveNodes(nodes, byId); return pts.length >= 2 ? pts : null; }; const polylineLength = (pts) => { let n = 0; for (let i = 1; i < pts.length; i++) n += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return n; }; // Nyomvonalhossz méterben, a tartalékkal növelve (a tartalék a behúzási // ráhagyás — a 02-es terv „min. 1 tartalék + behúzózsinór" elve szerint). const cableMeters = (cable, byId, scale, slack) => { const pts = cablePoints(cable, byId); if (!pts) return 0; return polylineLength(pts) * (scale || PLAN_SCALE_DEFAULT) * (1 + (slack || 0) / 100); }; const fmtM = (m) => (m >= 100 ? Math.round(m) : Math.round(m * 10) / 10).toLocaleString('hu-HU') + ' m'; // A legközelebbi gyűjtőpont a megadott típusok közül. A lista sorrendje // prioritás: ha van switch, arra megyünk, ha nincs, a magra. const nearestHub = (el, elements, hubTypes) => { for (const ht of hubTypes) { let best = null, bestD = Infinity; for (const cand of elements) { if (cand.type !== ht || cand.id === el.id) continue; const d = Math.hypot(cand.x - el.x, cand.y - el.y); if (d < bestD) { bestD = d; best = cand; } } if (best) return best; } return null; }; // ─── tapadás (snap) ───────────────────────────────────────────────────────── // Rajzoláskor és töréspont-húzáskor a pont a közeli nyomvonalakra ugrik: előbb // a meglévő töréspontokra, aztán a szakaszokra merőlegesen. Így a nyomvonalak // pontosan ugyanazokon a koordinátákon futnak — ettől lesz a kötegelés pontos // és olcsó (nem kell „körülbelül egy vonalban" heurisztika). const SNAP_R = 9; // tervezési egység (~23 cm a valóságban) const snapPoint = (p, cables, byId, excludeId) => { let best = null, bestD = SNAP_R; for (const c of cables) { if (c.id === excludeId) continue; const pts = cablePoints(c, byId); if (!pts) continue; // 1) meglévő pontokra (töréspont vagy eszköz-megálló) for (const q of pts) { const d = Math.hypot(p.x - q[0], p.y - q[1]); if (d < bestD) { bestD = d; best = { x: q[0], y: q[1], kind: 'point' }; } } } if (best) return best; // 2) szakaszra merőlegesen for (const c of cables) { if (c.id === excludeId) continue; const pts = cablePoints(c, byId); if (!pts) continue; for (let i = 1; i < pts.length; i++) { const a = pts[i - 1], b = pts[i]; const dx = b[0] - a[0], dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; if (!len2) continue; let t = ((p.x - a[0]) * dx + (p.y - a[1]) * dy) / len2; t = Math.max(0, Math.min(1, t)); const qx = a[0] + t * dx, qy = a[1] + t * dy; const d = Math.hypot(p.x - qx, p.y - qy); if (d < bestD) { bestD = d; best = { x: qx, y: qy, kind: 'segment' }; } } } return best; }; // ─── kötődoboz-képzés ─────────────────────────────────────────────────────── // Két áramnem: erősáram (230 V — erősáram + világítás rétegek) és gyengeáram // (adat, érzékelők, audió). Egy dobozba csak azonos áramnemű kábel mehet, // ezért a találkozási pontokat áramnemenként külön vizsgáljuk. const catFamily = (cat) => (cat === 'pow' || cat === 'lig') ? 'eros' : (cat === 'ann' ? null : 'gyenge'); const FAMILY_LABEL = { eros: 'Erősáram', gyenge: 'Gyengeáram' }; // Ahol két vagy több AZONOS ÁRAMNEMŰ kábel ugyanabban a töréspontban találkozik, // ott a valóságban kötődoboz van — a terv ezt magától felveszi, és a // töréspontokat a dobozra köti át. A tapadás miatt a „ugyanaz a pont" pontos // egyezés, nem közelítés. const promoteJunctions = (elements, cables, newId) => { const meet = new Map(); cables.forEach(c => { const fam = catFamily(c.cat); if (!fam) return; cableNodes(c).forEach((n, i) => { if (n.el || n.keep) return; // a megőrzött pontot békén hagyjuk const k = fam + '@' + (Math.round(n.x * 2) / 2) + ',' + (Math.round(n.y * 2) / 2); let e = meet.get(k); if (!e) { e = { fam, x: n.x, y: n.y, refs: [] }; meet.set(k, e); } e.refs.push({ id: c.id, index: i }); }); }); const nextEls = elements.slice(); const created = []; const rewire = {}; // kábel id → { megálló index: doboz id } // Egy meglévő doboz áramneme a rá kötött kábelekből derül ki. Erre azért van // szükség, hogy egy erősáramú dobozra soha ne kössünk gyengeáramú kábelt // (és fordítva) — akkor sem, ha véletlenül ugyanoda esne. const famOf = {}; cables.forEach(c => { const fam = catFamily(c.cat); if (!fam) return; cableNodes(c).forEach(n => { if (n.el && !famOf[n.el]) famOf[n.el] = fam; }); }); meet.forEach(e => { if (e.refs.length < 2) return; // Ha már van ott AZONOS ÁRAMNEMŰ (vagy még üres) kötődoboz, arra kötünk rá. const existing = nextEls.find(el => el.type === 'junction' && Math.hypot(el.x - e.x, el.y - e.y) < 7 && (!famOf[el.id] || famOf[el.id] === e.fam)); let jid; if (existing) { jid = existing.id; famOf[jid] = e.fam; } else { // Ha ugyanazon a ponton a másik áramnem doboza már megszületett, ezt // odébb tesszük — két áramnem nem kerülhet egy dobozba. let x = e.x, y = e.y; while (nextEls.some(el => el.type === 'junction' && Math.hypot(el.x - x, el.y - y) < 7)) { x += 16; y += 10; } const el = { id: newId(), type: 'junction', x, y, label: 'Kötődoboz · ' + FAMILY_LABEL[e.fam], wired: true, }; nextEls.push(el); created.push(el); jid = el.id; famOf[jid] = e.fam; } e.refs.forEach(r => { (rewire[r.id] = rewire[r.id] || {})[r.index] = jid; }); }); if (!Object.keys(rewire).length) return { elements, cables, created: [] }; const nextCables = cables.map(c => { const m = rewire[c.id]; if (!m) return c; return { ...c, nodes: cableNodes(c).map((n, i) => m[i] ? { el: m[i] } : n) }; }); return { elements: nextEls, cables: nextCables, created }; }; // ─── kötegek ──────────────────────────────────────────────────────────────── // Két nyomvonal akkor fut együtt, ha ugyanazt a szakaszt használja. A tapadás // miatt ez pontos egyezés, így a kötegelés egy egyszerű csoportosítás: minden // szakaszt a két végpontja azonosít (rendezetten, hogy az irány ne számítson). const segKey = (a, b) => { const r = v => Math.round(v * 2) / 2; // fél egység pontosság const p = [r(a[0]), r(a[1])], q = [r(b[0]), r(b[1])]; return (p[0] < q[0] || (p[0] === q[0] && p[1] <= q[1])) ? p[0] + ',' + p[1] + '|' + q[0] + ',' + q[1] : q[0] + ',' + q[1] + '|' + p[0] + ',' + p[1]; }; const computeBundles = (cables, byId, isVisible) => { const segs = new Map(); for (const c of cables) { if (isVisible && !isVisible(c)) continue; const pts = cablePoints(c, byId); if (!pts) continue; for (let i = 1; i < pts.length; i++) { const k = segKey(pts[i - 1], pts[i]); let e = segs.get(k); if (!e) { e = { a: pts[i - 1], b: pts[i], ids: [], types: {} }; segs.set(k, e); } e.ids.push(c.id); e.types[c.type] = (e.types[c.type] || 0) + 1; } } return segs; }; // Egy köteg védőcső-igénye: a kábelek külső keresztmetszetének összege osztva a // megengedett kitöltéssel adja a cső belső keresztmetszetét; ebből átmérő, majd // felkerekítés a legközelebbi szabvány méretre. const conduitFor = (typeCounts) => { const C = planCableTypes(); let area = 0, n = 0; Object.keys(typeCounts).forEach(k => { const od = (C[k] && C[k].od) || 6; area += typeCounts[k] * Math.PI * (od / 2) * (od / 2); n += typeCounts[k]; }); const fill = (typeof window !== 'undefined' && window.PLANNER_CONDUIT_FILL) || 0.4; const need = Math.sqrt((4 * area / fill) / Math.PI); const sizes = (typeof window !== 'undefined' && window.PLANNER_CONDUITS) || [16, 20, 25, 32, 40, 50, 63]; const pick = sizes.find(d => d >= need) || sizes[sizes.length - 1]; return { n, area, need, size: pick }; }; // Merőleges távolság egy ponttól a szakaszig — a nyomvonalra kattintáshoz // (töréspont beszúrása) és a kábel kijelöléséhez kell. const distToSegment = (p, a, b) => { const dx = b[0] - a[0], dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; if (!len2) return Math.hypot(p[0] - a[0], p[1] - a[1]); let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; t = Math.max(0, Math.min(1, t)); return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); }; // ─── betöltés / mentés ────────────────────────────────────────────────────── // A terv közös dokumentum: minden mentés viszi a betöltéskori verziószámot, és // ha közben más írt bele, a szerver 409-cel elutasítja — ilyenkor újratöltünk, // és szólunk, hogy ne írjuk felül csendben a másik szerkesztő munkáját. const usePlan = (canEdit, onToast) => { // A terv egy dokumentum: eszközök + kábelnyomvonalak + terv-szintű // beállítások (méretarány, tartalék). Egyben mentődik, egyben verziózódik. const [elements, setElements] = React.useState(null); // null = még töltünk const [cables, setCables] = React.useState([]); const [settings, setSettings] = React.useState({ scale: PLAN_SCALE_DEFAULT, slack: 10 }); const [error, setError] = React.useState(null); const [status, setStatus] = React.useState('idle'); // idle | dirty | saving | saved | error const [savedAt, setSavedAt] = React.useState(null); const versionRef = React.useRef(0); const timerRef = React.useRef(null); const pendingRef = React.useRef(null); // a legutóbbi, még ki nem írt állapot const inFlightRef = React.useRef(false); const aliveRef = React.useRef(true); // A legutóbbi állapot ref-ben is: a commit részleges módosítást (patch) kap, // és két gyors egymás utáni hívás között a state még nem frissült volna. const docRef = React.useRef({ elements: [], cables: [], settings: { scale: PLAN_SCALE_DEFAULT, slack: 10 } }); const applyPlan = React.useCallback((plan) => { versionRef.current = plan.version || 0; const doc = { elements: Array.isArray(plan.elements) ? plan.elements : [], cables: Array.isArray(plan.cables) ? plan.cables : [], settings: { scale: (plan.settings && +plan.settings.scale) || PLAN_SCALE_DEFAULT, slack: plan.settings && plan.settings.slack != null ? +plan.settings.slack : 10, }, }; docRef.current = doc; setElements(doc.elements); setCables(doc.cables); setSettings(doc.settings); }, []); React.useEffect(() => { aliveRef.current = true; (async () => { try { const data = await window.api.plan.get(); if (!aliveRef.current) return; applyPlan(data.plan || { elements: [], version: 0 }); } catch (err) { if (!aliveRef.current) return; setError(window.apiErrorMessage(err)); } })(); return () => { aliveRef.current = false; }; }, [applyPlan]); const flush = React.useCallback(async (reason) => { if (!canEdit) return; const payload = pendingRef.current; if (!payload || inFlightRef.current) return; pendingRef.current = null; inFlightRef.current = true; setStatus('saving'); let failed = false; try { const data = await window.api.plan.save({ elements: payload.elements, cables: payload.cables, settings: payload.settings, version: versionRef.current, reason: reason || undefined, }); versionRef.current = (data.plan && data.plan.version) || versionRef.current + 1; if (aliveRef.current) { setSavedAt(new Date()); setStatus(pendingRef.current ? 'dirty' : 'saved'); } } catch (err) { if (err && err.code === 'plan_conflict') { // Valaki más mentett közben — az ő állapota nyer, mi újratöltünk. const serverPlan = err.data && err.data.plan; if (serverPlan && aliveRef.current) applyPlan(serverPlan); pendingRef.current = null; if (aliveRef.current) setStatus('saved'); if (onToast) onToast('A tervet közben más módosította — újratöltve'); } else { // Hálózati/szerverhiba: a ki nem írt állapot maradjon a sorban, hogy a // következő módosítás (vagy kilépéskor a beacon) újra megpróbálja. Magától // nem próbálkozunk újra — az végtelen körbe vinne egy tartós hibánál. failed = true; if (!pendingRef.current) pendingRef.current = payload; if (aliveRef.current) { setStatus('error'); if (onToast) onToast('Mentési hiba: ' + window.apiErrorMessage(err)); } } } finally { inFlightRef.current = false; // Amíg mentettünk, érkezhetett újabb módosítás — azt is ki kell írni. if (!failed && pendingRef.current && aliveRef.current) { timerRef.current = setTimeout(() => flush(), 200); } } }, [canEdit, applyPlan, onToast]); // Minden módosítás ide fut be: azonnal frissíti a felületet, a szerverre // pedig késleltetve (húzás közben nem verjük az API-t). // // `next` lehet sima elemtömb (a régi hívási forma) vagy részleges patch // ({ elements?, cables?, settings? }) — így a kábelezés úgy fér bele, hogy a // meglévő eszköz-műveletek hívásai változatlanok maradnak. const commit = React.useCallback((next, opts) => { const options = opts || {}; const patch = Array.isArray(next) ? { elements: next } : (next || {}); const doc = { ...docRef.current, ...patch }; // Ha eszköz tűnt el, a rá kötött nyomvonalak is elmennek — lógó kábel nem // maradhat a terven (a szerver is ezt teszi, de a felület se villantsa fel). if (patch.elements) { const live = {}; doc.elements.forEach(e => { live[e.id] = true; }); const kept = doc.cables.filter(c => { const ids = cableDeviceIds(c); // Egy nyomvonalhoz elég EGY eszköz, ha szabad csatlakozási pontig fut. return ids.length >= 1 && ids.every(id => live[id]) && cableNodes(c).length >= 2; }); if (kept.length !== doc.cables.length) doc.cables = kept; } docRef.current = doc; setElements(doc.elements); setCables(doc.cables); setSettings(doc.settings); if (!canEdit) return; pendingRef.current = doc; setStatus('dirty'); clearTimeout(timerRef.current); if (options.immediate) flush(options.reason); else timerRef.current = setTimeout(() => flush(options.reason), 700); }, [canEdit, flush]); // Kilépéskor (nézetváltás, lapzárás) ne vesszen el a legutolsó módosítás. React.useEffect(() => { const onUnload = () => { if (!pendingRef.current || !navigator.sendBeacon) return; const body = new Blob([JSON.stringify({ elements: pendingRef.current.elements, cables: pendingRef.current.cables, settings: pendingRef.current.settings, version: versionRef.current, })], { type: 'application/json' }); navigator.sendBeacon('/api/plan/save.php', body); pendingRef.current = null; }; window.addEventListener('beforeunload', onUnload); return () => { window.removeEventListener('beforeunload', onUnload); clearTimeout(timerRef.current); onUnload(); }; }, []); // Import / alaphelyzet: a szerver állapotát szándékosan felülírjuk. const replaceAll = React.useCallback((next, reason) => { versionRef.current = 0; // 0 = kényszerített felülírás // Import / alaphelyzet / ürítés: az elemek kicserélődnek, a régi // nyomvonalak értelmüket vesztik (kivéve ha az import hoz sajátot). const patch = Array.isArray(next) ? { elements: next, cables: [] } : { cables: [], ...next }; commit(patch, { immediate: true, reason }); }, [commit]); return { elements, cables, settings, commit, replaceAll, status, savedAt, error, version: versionRef }; }; // ─── SVG jelölő ───────────────────────────────────────────────────────────── // Az elem az origóban rajzolódik, a helyét a adja — így húzás // közben elég a transform attribútumot írni, nem kell újrarenderelni a 390 // elemes tervet minden egérmozgásra. // Lapos tetejű hatszög csúcsai az origó körül — a kötődoboz jele. const HEX_R = 12.5; const hexPoints = (r) => { const pts = []; for (let i = 0; i < 6; i++) { const a = (Math.PI / 180) * (60 * i); pts.push((r * Math.cos(a)).toFixed(2) + ',' + (r * Math.sin(a)).toFixed(2)); } return pts.join(' '); }; const PlanMarker = React.forwardRef(({ el, selected, onPointerDown, interactive, inDraft }, ref) => { const t = planType(el.type); if (!t) return null; const cls = 'pl-el' + (selected ? ' sel' : '') + (interactive ? ' live' : '') + (inDraft ? ' in-draft' : ''); if (el.type === 'note') { const txt = el.label || '…'; const w = Math.max(46, Math.min(340, txt.length * 5.6 + 16)); const h = 20; return ( {txt.length > 60 ? txt.slice(0, 58) + '…' : txt} {txt} ); } return ( {t.hex ? : t.rect ? : } {t.ab} {planLabel(el) + (el.wired ? ' (vezetékes)' : ' (vezeték nélküli)')} ); }); // ─── kábelnyomvonalak rajza ───────────────────────────────────────────────── // A nyomvonalak a jelölők ALATT rajzolódnak, hogy az eszközök mindig // olvashatók maradjanak. Minden kábel két vonalból áll: egy vastag, átlátszó // „találati sáv" (könnyű ráklikkelni) és maga a látható vonal. const PlanCable = ({ cable, pts, selected, onPointerDown, showCode, bundles }) => { const t = cableType(cable.type); const color = t ? t.color : '#64748b'; // A kötegelt szakaszokat a köteg-réteg rajzolja; itt csak a saját, egyedi // szakaszok maradnak. A KIJELÖLT kábel viszont mindig teljes hosszában // látszik, hogy követni lehessen a nyomvonalát a kötegen át is. const pieces = []; if (bundles && !selected) { let run = [pts[0]]; for (let i = 1; i < pts.length; i++) { const e = bundles.get(segKey(pts[i - 1], pts[i])); if (e && e.ids.length >= 2) { if (run.length > 1) pieces.push(run); run = [pts[i]]; } else { run.push(pts[i]); } } if (run.length > 1) pieces.push(run); } else { pieces.push(pts); } const d = pts.map(p => p[0] + ',' + p[1]).join(' '); const mid = pts[Math.floor((pts.length - 1) / 2)]; const mid2 = pts[Math.floor((pts.length - 1) / 2) + 1] || mid; return ( {selected && } {pieces.map((seg, i) => ( p[0] + ',' + p[1]).join(' ')} fill="none" stroke={color} strokeWidth="2.1" strokeDasharray={t && t.dash ? t.dash : undefined} strokeLinejoin="round" strokeLinecap="round"/> ))} {/* Szabad végpont = csatlakozási pont: nyitott karika a vonal színében. */} {(() => { const ns = cableNodes(cable); const ends = []; if (ns.length && !ns[0].el) ends.push(pts[0]); if (ns.length > 1 && !ns[ns.length - 1].el) ends.push(pts[pts.length - 1]); return ends.map((q, i) => ( )); })()} {showCode && cable.code && ( {cable.code} )} {(cable.code ? cable.code + ' · ' : '') + (t ? t.label : cable.type) + (cable.label ? ' · ' + cable.label : '')} ); }; // A kijelölt nyomvonal fogantyúi. A szabad töréspontok húzhatók és dupla // kattintásra kivehetők; az eszköz-megállók (ahol a kábel bekötésre kerül) // négyzettel jelöltek, és az eszközzel együtt mozognak. const PlanWaypoints = ({ cable, byId, onHandleDown }) => ( {cableNodes(cable).map((n, i) => { if (n.el) { const e = byId[n.el]; if (!e) return null; return ; } return onHandleDown(ev, i)}/>; })} ); // Együtt futó szakaszok: egyetlen vastag vonal a darabszámmal. A kötegelt // szakaszokat az egyes kábelek már nem rajzolják újra — ettől lesz olvasható a // terv ott, ahol 20 kábel megy ugyanabban a csőben. const PlanBundles = ({ bundles, selBundleKey, onPointerDown }) => ( {[...bundles.entries()].map(([k, e]) => { if (e.ids.length < 2) return null; const types = Object.keys(e.types); const C = planCableTypes(); const color = types.length === 1 && C[types[0]] ? C[types[0]].color : '#475569'; const mx = (e.a[0] + e.b[0]) / 2, my = (e.a[1] + e.b[1]) / 2; const len = Math.hypot(e.b[0] - e.a[0], e.b[1] - e.a[1]); return ( onPointerDown(ev, k, e)) : undefined}> {k === selBundleKey && ( )} {len > 26 && ( ×{e.ids.length} )} {e.ids.length + ' kábel egy nyomvonalon'} ); })} ); // ─── a terv vászna (háttérkép + jelölők) ──────────────────────────────────── // A `zoom` a vászon szélességét skálázza; az SVG viewBox miatt a jelölők // automatikusan együtt nőnek a képpel, a koordináták pedig változatlanok. const PlanStage = React.forwardRef(({ elements, visible, selId, zoom, placing, onBoardPointerDown, onElPointerDown, nodeRefs, interactive, cables, cableVisible, selCableId, onCablePointerDown, onWaypointDown, draft, wireMode, showCodes, byId, bundles, selBundleKey, onBundlePointerDown, snap, }, boardRef) => { // Az épp rajzolt láncban szereplő eszközök — ezek végig kiemelve maradnak, // hogy látszódjon, mit fűztünk már fel. const draftIds = React.useMemo(() => { const m = {}; if (draft) draft.nodes.forEach(n => { if (n.el) m[n.el] = true; }); return m; }, [draft]); const selCable = cables && selCableId ? cables.find(c => c.id === selCableId) : null; // Az épp rajzolt lánc pontsora (az eszköz-megállók helyét az index adja). // Egyetlen megállónál is kirajzoljuk — a gumiszalag ebbe az elembe ír. const draftPts = draft ? resolveNodes(draft.nodes, byId || {}) : []; return (
Alaprajz {/* nyomvonalak — a jelölők alatt */} {(cables || []).map(c => { if (cableVisible && !cableVisible(c)) return null; const pts = cablePoints(c, byId); if (!pts) return null; return onCablePointerDown(e, c)) : undefined}/>; })} {/* együtt futó szakaszok egyetlen vonalként */} {bundles && } {/* épp rajzolt nyomvonal — gumiszalag a kurzorig */} {draft && draftPts.length > 0 && ( p[0] + ',' + p[1]).join(' ')} fill="none" stroke="#0ea5e9" strokeWidth="2.2" strokeDasharray="6 4" strokeLinejoin="round" strokeLinecap="round" pointerEvents="none"/> )} {selCable && onWaypointDown && } {/* tapadási visszajelzés — ide ugrik a pont */} {snap && ( )} {elements.map(el => visible(el) ? ( { if (nodeRefs) { if (n) nodeRefs.current[el.id] = n; else delete nodeRefs.current[el.id]; } }} onPointerDown={onElPointerDown ? (e => onElPointerDown(e, el)) : undefined}/> ) : null)}
); }); // ─── jelmagyarázat ────────────────────────────────────────────────────────── const PlanLegend = ({ elements, cables }) => { const T = planTypes(); // Csak a terven ténylegesen szereplő kábeltípusok kerülnek a jelmagyarázatba. const usedCables = React.useMemo(() => { const c = {}; (cables || []).forEach(k => { c[k.type] = (c[k.type] || 0) + 1; }); return c; }, [cables]); const used = React.useMemo(() => { const c = {}; elements.forEach(e => { c[e.type] = (c[e.type] || 0) + 1; }); return c; }, [elements]); return (
Jelmagyarázat: {Object.keys(T).filter(k => used[k]).map(k => ( {T[k].ab} = {T[k].label} ))} · szaggatott körvonal = vezeték nélküli {Object.keys(usedCables).length > 0 && ( <> Kábelek: {Object.keys(usedCables).map(k => { const ct = cableType(k); if (!ct) return null; return ( {ct.label} ×{usedCables[k]} ); })} )}
); }; // ─── lefedettségi jelentés ────────────────────────────────────────────────── // Melyik eszköztípus nincs még lerakva — kategóriánként. A forrás tervező // „Lefedettség" gombjának pontos mása. const PlanCoverage = ({ elements, onClose }) => { const T = planTypes(); const CAT = planCatNames(); const counts = {}; elements.forEach(e => { counts[e.type] = (counts[e.type] || 0) + 1; }); const byCat = {}; Object.keys(T).forEach(k => { const t = T[k]; if (t.cat === 'ann') return; const b = byCat[t.cat] || (byCat[t.cat] = { placed: 0, types: 0, missing: [], have: [] }); b.types++; if (counts[k]) { b.placed++; b.have.push({ t, n: counts[k] }); } else b.missing.push({ t }); }); const placedCount = elements.filter(e => T[e.type] && T[e.type].cat !== 'ann').length; return (
{placedCount + ' lerakott eszköz · a tervről még hiányzó eszköztípusok kategóriánként:'}
{PLAN_CATS.filter(c => c !== 'ann' && byCat[c]).map(c => { const b = byCat[c]; return (

{CAT[c] + ' — ' + b.placed + '/' + b.types + ' típus lerakva'}

{b.missing.length ? ( <>
{'✗ Nincs lerakva (' + b.missing.length + ')'}
{b.missing.map((m, i) => (
{m.t.label} {m.t.g || ''}
))} ) :
✓ ebben a kategóriában minden típus lerakva
} {b.have.length > 0 && ( <>
✓ Lerakva
{b.have.map((m, i) => (
{m.t.label} ×{m.n} {m.t.g || ''}
))} )}
); })}
); }; // ─── automatikus bekábelezés ──────────────────────────────────────────────── // Rétegenként végigmegy a lerakott eszközökön, mindegyikhez megkeresi a // legközelebbi gyűjtőpontot (switch / ESP32 csomópont / elosztó / erősítő), és // egyenes nyomvonalat húz oda a réteghez tartozó kábeltípussal. A csillag- // topológia elve szerint minden nyomvonal a saját gyűjtőpontjába fut. // // Csak javaslatot ad: a nyomvonalak utólag szabadon áthúzhatók, töréspontokkal // a falak mentén vezethetők, a kábeltípus kábelenként átállítható. const planAutoWire = (elements, cables, cats, opts) => { const options = opts || {}; const byId = {}; elements.forEach(e => { byId[e.id] = e; }); // Már bekábelezett eszközök — alapesetben ezeket kihagyjuk, hogy a művelet // ismételhető legyen anélkül, hogy duplikálná a meglévő nyomvonalakat. // Egy eszköz akkor számít bekábeltnek, ha bármelyik nyomvonal érinti. const wired = {}; cables.forEach(c => { cableDeviceIds(c).forEach(id => { wired[id] = true; }); }); const created = []; const skipped = { hub: 0, wired: 0, ann: 0 }; elements.forEach(el => { const t = planType(el.type); if (!t || t.cat === 'ann') { skipped.ann++; return; } if (!cats[t.cat]) return; // ez a réteg nincs kijelölve if (wired[el.id] && !options.rewire) { skipped.wired++; return; } const plan = cablePlanFor(el); // Maga a gyűjtőpont nem kap saját bekötést ebben a körben (a switch a magra // megy — azt az infra réteg külön lépése húzza be). if (plan.hubs.indexOf(el.type) === 0) { skipped.hub++; return; } // Kötődoboz: ha van közelebb, mint a réteg gyűjtőpontja, oda megyünk — ez a // dolga. Maga a kötődoboz viszont a rétege gyűjtőpontjára fut tovább, hogy // ne kössünk dobozt dobozra. let hub = nearestHub(el, elements, plan.hubs); if (options.viaJunction !== false && el.type !== 'junction') { const jb = nearestHub(el, elements, ['junction']); if (jb && (!hub || Math.hypot(jb.x - el.x, jb.y - el.y) < Math.hypot(hub.x - el.x, hub.y - el.y))) { hub = jb; } } if (!hub) { skipped.hub++; return; } created.push({ nodes: [{ el: el.id }, { el: hub.id }], type: plan.cable, cat: t.cat, label: '', code: '', }); }); return { created, skipped }; }; // Nyomvonal-azonosítók (C001, C002 …) — rétegsorrendben újraosztva, hogy a // kábellista és a terven lévő feliratok egy logika szerint fussanak. const renumberCables = (cables, byId) => { const order = {}; PLAN_CATS.forEach((c, i) => { order[c] = i; }); const sorted = cables.slice().sort((a, b) => { const d = (order[a.cat] ?? 99) - (order[b.cat] ?? 99); if (d) return d; if (a.type !== b.type) return a.type < b.type ? -1 : 1; return (a.id < b.id ? -1 : 1); }); const codeById = {}; sorted.forEach((c, i) => { codeById[c.id] = 'C' + String(i + 1).padStart(3, '0'); }); return cables.map(c => ({ ...c, code: codeById[c.id] || c.code })); }; const PlanAutoWire = ({ elements, cables, onApply, onClose }) => { const CAT = planCatNames(); const [cats, setCats] = React.useState(() => PLAN_CATS.reduce((a, c) => (a[c] = c !== 'ann', a), {})); const [rewire, setRewire] = React.useState(false); const [viaJunction, setViaJunction] = React.useState(true); const hasJunctions = elements.some(e => e.type === 'junction'); const preview = React.useMemo( () => planAutoWire(elements, cables, cats, { rewire, viaJunction }), [elements, cables, cats, rewire, viaJunction] ); // Rétegenkénti bontás, hogy látszódjon, melyik réteg mit kapna. const perCat = {}; preview.created.forEach(c => { perCat[c.cat] = (perCat[c.cat] || 0) + 1; }); const perType = {}; preview.created.forEach(c => { perType[c.type] = (perType[c.type] || 0) + 1; }); return (
Minden kijelölt réteg eszközéhez nyomvonalat húz a legközelebbi gyűjtőpontig (switch · ESP32 csomópont · elosztó · erősítő), a réteghez tartozó kábeltípussal. A nyomvonalak utólag szabadon átrajzolhatók.
{PLAN_CATS.filter(c => c !== 'ann').map(c => ( ))}
{hasJunctions && ( )}
Új nyomvonal{preview.created.length} db
{!rewire &&
Kihagyva (már van kábele){preview.skipped.wired} db
}
Kihagyva (nincs gyűjtőpont){preview.skipped.hub} db
{Object.keys(perType).length > 0 && (
{Object.keys(perType).map(k => { const t = cableType(k); return (
{t ? t.label : k}{perType[k]} db
); })}
)}
); }; // ─── kábellista (kábelkiosztás) ───────────────────────────────────────────── // A nyomvonalakból készült kimutatás: kábeltípusonként darabszám és összhossz, // alatta a tételes lista. CSV-be exportálható, hogy anyagjegyzékbe kerülhessen. const PlanSchedule = ({ elements, cables, settings, onClose, onSetSlack, onRenumber }) => { const byId = React.useMemo(() => { const m = {}; elements.forEach(e => { m[e.id] = e; }); return m; }, [elements]); const CAT = planCatNames(); const scale = settings.scale || PLAN_SCALE_DEFAULT; const slack = settings.slack || 0; const rows = React.useMemo(() => cables.map(c => { const pts = cablePoints(c, byId); const raw = pts ? polylineLength(pts) * scale : 0; return { c, chain: cableDeviceIds(c).map(id => byId[id] ? planLabel(byId[id]) : '?'), raw, len: raw * (1 + slack / 100), }; }).sort((a, b) => (a.c.code || '').localeCompare(b.c.code || '')), [cables, byId, scale, slack]); const byType = {}; rows.forEach(r => { const b = byType[r.c.type] || (byType[r.c.type] = { n: 0, raw: 0, len: 0 }); b.n++; b.raw += r.raw; b.len += r.len; }); const total = rows.reduce((a, r) => a + r.len, 0); const exportCsv = () => { const head = ['Azonosító', 'Réteg', 'Kábeltípus', 'Erek', 'Honnan', 'Hová', 'Érintett eszközök', 'Nyomvonal (m)', 'Tartalékkal (m)', 'Megjegyzés']; const esc = v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; const lines = [head.map(esc).join(';')]; rows.forEach(r => { const t = cableType(r.c.type); lines.push([ r.c.code || '', CAT[r.c.cat] || r.c.cat, t ? t.label : r.c.type, t ? t.cores : '', r.chain[0] || '?', r.chain[r.chain.length - 1] || '?', r.chain.join(' → '), r.raw.toFixed(1).replace('.', ','), r.len.toFixed(1).replace('.', ','), r.c.label || '', ].map(esc).join(';')); }); // BOM, hogy az Excel felismerje az UTF-8-at const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'kabellista.csv'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }; return ( {!cables.length ? (
Még nincs nyomvonal a terven. Rajzolj egyet a „Kábel" gombbal, vagy használd az automatikus bekábelezést.
) : ( <>
Összes nyomvonal{cables.length} db
Összhossz (tartalékkal){fmtM(total)}

Kábeltípusonként

{Object.keys(byType).map(k => { const t = cableType(k); return (
{t ? t.label : k} {byType[k].n} db {fmtM(byType[k].len)}
); })} {(() => { // Együtt futó szakaszok → védőcső-igény. Csak a 2+ kábeles kötegek // érdekesek; méret szerint csökkenő sorrendben. const bundles = [...computeBundles(cables, byId).values()].filter(e => e.ids.length >= 2); if (!bundles.length) return null; const rows = bundles.map(e => ({ e, cd: conduitFor(e.types), len: Math.hypot(e.b[0] - e.a[0], e.b[1] - e.a[1]) * scale })) .sort((a, b) => b.cd.size - a.cd.size || b.e.ids.length - a.e.ids.length); const perSize = {}; rows.forEach(r => { perSize[r.cd.size] = (perSize[r.cd.size] || 0) + r.len; }); return ( <>

Védőcsövek (együtt futó szakaszok)

{rows.length} közös szakasz · a méret {Math.round( (typeof window !== 'undefined' && window.PLANNER_CONDUIT_FILL ? window.PLANNER_CONDUIT_FILL : 0.4) * 100 )}% kitöltésre számolva
{Object.keys(perSize).sort((a, b) => b - a).map(sz => (
Ø{sz} mm védőcső {fmtM(perSize[sz])}
))} ); })()}

Tételesen

{rows.map(r => { const t = cableType(r.c.type); return (
{r.c.code || '—'} {r.chain.join(' → ')} {fmtM(r.len)}
); })}
)}
); }; // ─── rétegkapcsolók ───────────────────────────────────────────────────────── const PlanLayers = ({ catVisible, setCatVisible, compact, children }) => { const CAT = planCatNames(); const visibleCount = PLAN_CATS.filter(c => catVisible[c]).length; const only = (c) => setCatVisible(PLAN_CATS.reduce((a, k) => (a[k] = k === c, a), {})); const all = () => setCatVisible(PLAN_CATS.reduce((a, k) => (a[k] = true, a), {})); if (compact) { // Mobil: koppintható chipek — koppintás = ki/be, hosszú lista helyett. return (
{PLAN_CATS.map(c => ( ))}
); } return (
Rétegek: {PLAN_CATS.map(c => ( ))} {children &&
{children}
}
); }; // ─── nagyítás-vezérlő ─────────────────────────────────────────────────────── // Két rögzített nagyítás: 100% (1:1 — ezen olvashatók legjobban a jelölők és az // alaprajz feliratai) és 150% (részletmunkához). Mindkettő kapcsoló: ha már az // adott nagyításon állunk, visszavált illesztett nézetre, így egy gombbal lehet // oda-vissza váltani a teljes terv és a részletes nézet között. const PlanZoom = ({ zoom, setZoom, fitWidth }) => { const atFull = Math.abs(zoom - 1) < 0.005; const at150 = Math.abs(zoom - 1.5) < 0.005; return (
{Math.round(zoom * 100)}%
); }; // ════════════════════════════════════════════════════════════════════════════ // SZERKESZTŐ (asztali gép) // ════════════════════════════════════════════════════════════════════════════ const PlannerEditor = ({ plan, onToast, compact, setCompact }) => { const { elements, cables, settings, commit, replaceAll, status, savedAt } = plan; const T = planTypes(); const [selId, setSelId] = React.useState(null); const [placing, setPlacing] = React.useState(null); const [typeQuery, setTypeQuery] = React.useState(''); const [placedQuery, setPlacedQuery] = React.useState(''); const [catVisible, setCatVisible] = React.useState(() => PLAN_CATS.reduce((a, c) => (a[c] = true, a), {})); const [zoom, setZoom] = React.useState(1); const [coverage, setCoverage] = React.useState(false); const [menu, setMenu] = React.useState(null); // { el, x, y } — dupla koppintás gyorsmenü const [paletteOpen, setPaletteOpen] = React.useState(true); // ── kábelezés ───────────────────────────────────────────────────────────── const [wireMode, setWireMode] = React.useState(false); const [wireType, setWireType] = React.useState(''); // '' = a rétegtől függ const [draft, setDraft] = React.useState(null); // { fromId, pts:[[x,y]…] } const [selCableId, setSelCableId] = React.useState(null); const [showCodes, setShowCodes] = React.useState(false); const [autoWire, setAutoWire] = React.useState(false); const [schedule, setSchedule] = React.useState(false); const [snapOn, setSnapOn] = React.useState(true); const [autoJunction, setAutoJunction] = React.useState(true); const [snap, setSnap] = React.useState(null); // aktuális tapadási pont const [selBundleKey, setSelBundleKey] = React.useState(null); const [askJunction, setAskJunction] = React.useState(null); // { cableId, index } const boardRef = React.useRef(null); const canvasRef = React.useRef(null); const nodeRefs = React.useRef({}); const dragRef = React.useRef(null); const lastTapRef = React.useRef({ id: null, t: 0 }); const importRef = React.useRef(null); const nextIdRef = React.useRef(2000); const nextCableRef = React.useRef(1); const commitCablesRef = React.useRef(null); const wpDragRef = React.useRef(null); // CSAK a folyamatban lévő töréspont-húzás const wpTapRef = React.useRef({ index: null, t: 0 }); // dupla koppintás figyelése // Az azonosító-számláló mindig a legnagyobb meglévő fölött folytatódik. React.useEffect(() => { let max = 2000; (elements || []).forEach(e => { const m = /(\d+)$/.exec(String(e.id)); if (m) max = Math.max(max, parseInt(m[1], 10) + 1); }); if (max > nextIdRef.current) nextIdRef.current = max; }, [elements]); const newId = () => 'n' + (nextIdRef.current++); // Kábel-azonosítók a meglévők fölött folytatódnak. React.useEffect(() => { let max = 1; (cables || []).forEach(c => { const m = /(\d+)$/.exec(String(c.id)); if (m) max = Math.max(max, parseInt(m[1], 10) + 1); }); if (max > nextCableRef.current) nextCableRef.current = max; }, [cables]); const newCableId = () => 'k' + (nextCableRef.current++); const byId = React.useMemo(() => { const m = {}; (elements || []).forEach(e => { m[e.id] = e; }); return m; }, [elements]); const selCable = React.useMemo( () => (cables || []).find(c => c.id === selCableId) || null, [cables, selCableId]); // Kötegek: csak a látható rétegek nyomvonalait vesszük figyelembe, hogy a // rétegszűrés a darabszámokon is látszódjon. const bundles = React.useMemo( () => computeBundles(cables || [], byId, cableVisible), [cables, byId, cableVisible]); const selBundle = selBundleKey ? bundles.get(selBundleKey) : null; // A tapadás mindig a NEM épp szerkesztett nyomvonalakra néz. const snapAt = React.useCallback((p, excludeId) => { if (!snapOn) return null; return snapPoint(p, cables || [], byId, excludeId); }, [snapOn, cables, byId]); const sel = React.useMemo(() => (elements || []).find(e => e.id === selId) || null, [elements, selId]); // ── szűrés ──────────────────────────────────────────────────────────────── const matchPlaced = React.useCallback((el) => { const q = placedQuery.trim().toLowerCase(); if (!q) return true; const t = planType(el.type); if (!t) return false; return (String(el.label || '') + ' ' + t.search + ' ' + (planCatNames()[t.cat] || '')).toLowerCase().indexOf(q) >= 0; }, [placedQuery]); const visible = React.useCallback((el) => { const t = planType(el.type); return !!t && !!catVisible[t.cat] && matchPlaced(el); }, [catVisible, matchPlaced]); // A nyomvonal a saját rétegével együtt látszik; keresés közben csak azok, // amelyeknek valamelyik vége illeszkedik a keresésre. const cableVisible = React.useCallback((c) => { if (!catVisible[c.cat]) return false; if (!placedQuery.trim()) return true; const a = byId[c.from], b = byId[c.to]; return (a && matchPlaced(a)) || (b && matchPlaced(b)); }, [catVisible, placedQuery, byId, matchPlaced]); // ── paletta ─────────────────────────────────────────────────────────────── const paletteGroups = React.useMemo(() => { const q = typeQuery.trim().toLowerCase(); const groups = []; const index = {}; const push = (name, key, vis) => { if (!catVisible[vis]) return; if (q && T[key].search.indexOf(q) < 0) return; if (index[name] === undefined) { index[name] = groups.length; groups.push({ name, keys: [] }); } groups[index[name]].keys.push(key); }; Object.keys(T).forEach(key => { const t = T[key]; push(t.g || planCatNames()[t.cat], key, t.cat); if (t.g2) push(t.g2, key, t.g2cat || t.cat); }); return groups; }, [typeQuery, catVisible, T]); const paletteHits = paletteGroups.reduce((n, g) => n + g.keys.length, 0); // ── vászonhoz igazítás ──────────────────────────────────────────────────── const fitWidth = React.useCallback(() => { const box = canvasRef.current; if (!box || !box.clientWidth) return false; setZoom(Math.max(0.15, (box.clientWidth - 24) / PLAN_GEOM.w)); return true; }, []); // A terv vízszintesen középen marad. Ha kisebb a vászonnál, a .pl-stage // `margin: 0 auto` igazítja; ha nagyobb (100% / 150%), a görgetést állítjuk a // közepére, hogy ne a bal széle látszódjon. Csak nagyításváltáskor fut, így a // kézi görgetést nem írja felül. React.useLayoutEffect(() => { const box = canvasRef.current; if (!box) return; box.scrollLeft = Math.max(0, (box.scrollWidth - box.clientWidth) / 2); }, [zoom]); // Egyszer illesztünk — amint a vászon megkapta a végleges szélességét. Utána // a nagyítás a felhasználóé, ablakátméretezés sem írja felül. const fittedRef = React.useRef(false); React.useEffect(() => { if (fittedRef.current) return; if (fitWidth()) { fittedRef.current = true; return; } const ro = new ResizeObserver(() => { if (!fittedRef.current && fitWidth()) { fittedRef.current = true; ro.disconnect(); } }); if (canvasRef.current) ro.observe(canvasRef.current); return () => ro.disconnect(); }, [fitWidth]); // ── helyezés / húzás ────────────────────────────────────────────────────── const svgPoint = (evt) => { const board = boardRef.current; if (!board) return { x: 0, y: 0 }; const pt = board.createSVGPoint(); pt.x = evt.clientX; pt.y = evt.clientY; return pt.matrixTransform(board.getScreenCTM().inverse()); }; const clampX = (x) => Math.max(5, Math.min(PLAN_GEOM.w - 5, x)); const clampY = (y) => Math.max(5, Math.min(PLAN_GEOM.h - 5, y)); const onBoardPointerDown = (e) => { if (placing) { const p = svgPoint(e); const t = T[placing]; const el = { id: newId(), type: placing, x: clampX(p.x), y: clampY(p.y), label: '', wired: !!t.wired }; commit([...(elements || []), el]); setSelId(el.id); if (!catVisible[t.cat]) setCatVisible({ ...catVisible, [t.cat]: true }); return; } // Kábelrajzolás közben az üres területre kattintás töréspontot tesz le — // így lehet a falak mentén, derékszögben vezetni a nyomvonalat. if (wireMode && draft) { addDraftPoint(e); return; } // Kijelölt nyomvonalra kattintva új töréspont kerül a legközelebbi szakaszba. if (!wireMode && selCableId) { const p = svgPoint(e); const pts = selCable && cablePoints(selCable, byId); if (pts) { let d = Infinity; for (let i = 1; i < pts.length; i++) d = Math.min(d, distToSegment([p.x, p.y], pts[i - 1], pts[i])); if (d <= 6) { addWaypointAt(p); return; } } } setSelId(null); setSelCableId(null); setSelBundleKey(null); }; // ── kábelrajzolás ───────────────────────────────────────────────────────── // Első eszköz → (töréspontok) → második eszköz. Esc megszakít, Backspace az // utolsó töréspontot veszi vissza. // Egy nyomvonal tetszőleges számú eszközt fűzhet fel: minden eszközre // kattintás új megállót tesz a láncba (pl. egy világítási kör több // lámpatesttel), az üres területre kattintás töréspontot. A lánc az Enterrel // / a „Kész" gombbal / a legutóbbi eszközre dupla kattintással zárul. const startWire = (el) => { setSelId(el.id); setSelCableId(null); setDraft({ nodes: [{ el: el.id }] }); }; const addWireStop = (el) => { if (!draft) return; const last = draft.nodes[draft.nodes.length - 1]; // Ugyanarra az eszközre másodszor koppintva lezárjuk a láncot. if (last && last.el === el.id) { commitWire(draft); return; } setDraft({ nodes: [...draft.nodes, { el: el.id }] }); }; const draftStops = draft ? draft.nodes.filter(n => n.el).length : 0; const commitWire = (d) => { const use = d || draft; if (!use) return; const devIds = use.nodes.filter(n => n.el).map(n => n.el); // Elég EGY eszköz, ha a nyomvonal egy szabad csatlakozási pontig fut — // ez a leágazás (pl. egy érzékelő beköt egy közös pontba), amiből utána // kötődoboz lehet. if (!devIds.length) { onToast('A nyomvonalnak legalább egy eszközből kell indulnia'); return; } if (use.nodes.length < 2) { onToast('A nyomvonalhoz legalább két pont kell'); return; } // Ha két eszköz között fut, a lezáró szabad pontok fölöslegesek. Ha viszont // csak egy eszköz van, a záró pont MAGA a végpont — azt megtartjuk. const nodes = use.nodes.slice(); if (devIds.length >= 2) { while (nodes.length && !nodes[nodes.length - 1].el) nodes.pop(); } const src = byId[devIds[0]]; const t = src ? planType(src.type) : null; const cable = { id: newCableId(), nodes, type: wireType || (src ? cablePlanFor(src).cable : 'cat6a'), cat: t ? t.cat : 'infra', label: '', code: '', }; const next = renumberCables([...(cables || []), cable], byId); commitCables(next); setDraft(null); setSelCableId(cable.id); // Szabad végpont? Megkérdezzük, legyen-e belőle kötődoboz. Csak a VÉGÉN // lévő pontra kérdezünk — a közbenső törések nyilván nem dobozok. const lastFree = !nodes[nodes.length - 1].el; if (lastFree) { setAskJunction({ cableId: cable.id, index: nodes.length - 1 }); } else { onToast('✓ Nyomvonal · ' + devIds.length + ' eszköz · ' + fmtM(cableMeters(cable, byId, settings.scale, settings.slack))); } }; // A szabad végpont sorsa: kötődoboz lesz belőle, vagy marad csatlakozási pont. const resolveEndpoint = (makeJunction) => { const ask = askJunction; setAskJunction(null); if (!ask) return; const c = (cables || []).find(x => x.id === ask.cableId); if (!c) return; const nodes = cableNodes(c); const n = nodes[ask.index]; if (!n || n.el) return; if (!makeJunction) { // Megjelöljük, hogy szándékosan szabad pont — így a későbbi találkozások // sem alakítják magától kötődobozzá. commit({ cables: (cables || []).map(x => x.id !== c.id ? x : { ...x, nodes: nodes.map((q, i) => i === ask.index ? { ...q, keep: true } : q) }) }); onToast('✓ Csatlakozási pont megtartva'); return; } // Kimondott felhasználói döntés: MINDEN nyomvonal rákerül, ami ezen a // ponton végződik vagy fordul — áramnemtől függetlenül. (Az AUTOMATIKUS // kötődoboz-képzés ettől eltérően továbbra is szigorúan áramnemenként // dolgozik; ott nincs kimondott szándék, amire hivatkozhatnánk.) const jbId = newId(); let adopted = 0; const fams = {}; const nextCables = (cables || []).map(x => { const ns = cableNodes(x); let touched = false; const mapped = ns.map(q => { if (q.el || Math.hypot(q.x - n.x, q.y - n.y) > 7) return q; touched = true; return { el: jbId }; }); if (!touched) return x; adopted++; const f = catFamily(x.cat); if (f) fams[f] = true; return { ...x, nodes: mapped }; }); const famKeys = Object.keys(fams); const mixed = famKeys.length > 1; const jb = { id: jbId, type: 'junction', x: n.x, y: n.y, label: 'Kötődoboz · ' + (mixed ? 'Vegyes' : (FAMILY_LABEL[famKeys[0]] || '')), wired: true, }; commit({ elements: [...(elements || []), jb], cables: nextCables }); if (mixed) { // Nem tiltjuk — a felhasználó kérte —, de szólunk: a kivitelezésben // erősáram és gyengeáram nem mehet közös dobozba osztás nélkül. onToast('⚠ Kötődoboz · ' + adopted + ' nyomvonal — erősáram és gyengeáram is van benne'); } else { onToast(adopted > 1 ? '✓ Kötődoboz · ' + adopted + ' nyomvonal rákötve' : '✓ Kötődoboz létrehozva'); } }; // Rajzolás közben a vászonra, egy meglévő nyomvonalra vagy egy kötegre // kattintás egyaránt töréspontot tesz le — a tapadás miatt pont RÁ a másik // nyomvonalra, így onnantól közösen futnak (ez ad kötegelést). // Shift lenyomva: derékszögű rajzolás — a pont az előző ponthoz képest csak // vízszintesen vagy függőlegesen mozdulhat (a nagyobb elmozdulású tengely // marad szabad). Ilyenkor a mágneses tapadás nem rántja le a tengelyről. const axisLock = (p, ax, ay) => Math.abs(p.x - ax) >= Math.abs(p.y - ay) ? { x: p.x, y: ay } : { x: ax, y: p.y }; const addDraftPoint = (e) => { const p = svgPoint(e); if (e.shiftKey) { setDraft(d => { if (!d) return d; const base = resolveNodes(d.nodes, byId); const a = base[base.length - 1]; const q = a ? axisLock(p, a[0], a[1]) : p; return { nodes: [...d.nodes, { x: clampX(q.x), y: clampY(q.y) }] }; }); setSnap(null); return; } const sp = snapAt(p, null); const q = sp || p; setDraft(d => d ? { nodes: [...d.nodes, { x: clampX(q.x), y: clampY(q.y) }] } : d); setSnap(null); }; const onBundlePointerDown = (e, key) => { e.stopPropagation(); if (wireMode && draft) { addDraftPoint(e); return; } setSelBundleKey(key); setSelCableId(null); setSelId(null); }; const onCablePointerDown = (e, c) => { e.stopPropagation(); // Rajzolás közben a meglévő nyomvonal nem „nyeli el" a kattintást: rátesszük // a töréspontot, hogy a két kábel innentől közös nyomvonalon fusson. if (wireMode && draft) { addDraftPoint(e); return; } // Az első kattintás kijelöl; a MÁR kijelölt nyomvonalra kattintva a // kattintás helyén új töréspont kerül be (a vonal itt fogja el a kattintást, // ezért a vászon kezelője nem futna le). if (selCableId === c.id) { addWaypointAt(svgPoint(e)); return; } setSelCableId(c.id); setSelId(null); setSelBundleKey(null); }; // Töréspont húzása. Ugyanaz az imperatív minta, mint az elemeknél: húzás // közben nem renderelünk újra, csak elengedéskor mentünk. const onWaypointDown = (e, index) => { e.stopPropagation(); if (!selCable) return; const now = Date.now(); const nodes = cableNodes(selCable); // Dupla kattintás a töréspontra: kiveszi a nyomvonalból. if (wpTapRef.current.index === index && now - wpTapRef.current.t < 400) { wpTapRef.current = { index: null, t: 0 }; wpDragRef.current = null; commitCables(cables.map(c => c.id === selCable.id ? { ...c, nodes: nodes.filter((_, i) => i !== index) } : c)); return; } wpTapRef.current = { index, t: now }; wpDragRef.current = { id: selCable.id, index, moved: false, x: nodes[index].x, y: nodes[index].y, }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* nem támogatott */ } }; // Kattintás a nyomvonalra új töréspontot szúr be a legközelebbi szakaszba. const addWaypointAt = (p) => { if (!selCable) return; const pts = cablePoints(selCable, byId); if (!pts) return; let best = 1, bestD = Infinity; for (let i = 1; i < pts.length; i++) { const d = distToSegment([p.x, p.y], pts[i - 1], pts[i]); if (d < bestD) { bestD = d; best = i; } } const nodes = cableNodes(selCable).slice(); nodes.splice(best, 0, { x: clampX(p.x), y: clampY(p.y) }); commitCables(cables.map(c => c.id === selCable.id ? { ...c, nodes } : c)); }; const onElPointerDown = (e, el) => { // Lerakás közben a jelölő NE fogja el a kattintást — hadd fusson tovább a // vászonra, hogy egy meglévő elem tetejére is lehessen újat tenni. if (placing) return; // Kábel módban az eszközre kattintás indítja, illetve zárja a nyomvonalat. if (wireMode) { e.stopPropagation(); if (!draft) startWire(el); else addWireStop(el); return; } e.stopPropagation(); const now = Date.now(); if (lastTapRef.current.id === el.id && now - lastTapRef.current.t < 400) { e.preventDefault(); lastTapRef.current = { id: null, t: 0 }; setSelId(el.id); setMenu({ id: el.id, x: e.clientX, y: e.clientY }); return; } lastTapRef.current = { id: el.id, t: now }; const node = nodeRefs.current[el.id]; dragRef.current = { id: el.id, moved: false, x: el.x, y: el.y, node }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* nem támogatott */ } }; // Húzás: a DOM-ot közvetlenül írjuk (nincs re-render), a state csak // elengedéskor frissül — így 390 elemnél is végig sima marad. React.useEffect(() => { const onMove = (e) => { // töréspont húzása const w = wpDragRef.current; if (w) { const p = svgPoint(e); let q; if (e.shiftKey) { // Shift: a töréspont a szomszédos ponthoz igazítva, derékszögben mozog. const cbl0 = (cables || []).find(c => c.id === w.id); const res = cbl0 ? resolveNodes(cableNodes(cbl0), byId) : []; const a = res[w.index - 1] || res[w.index + 1]; q = a ? (Math.abs(p.x - a[0]) >= Math.abs(p.y - a[1]) ? { x: p.x, y: a[1] } : { x: a[0], y: p.y }) : p; } else { q = snapAt(p, w.id) || p; } w.x = clampX(q.x); w.y = clampY(q.y); w.moved = true; // A kábelvonal és a fogantyú közvetlen frissítése — nincs re-render. const cbl = (cables || []).find(c => c.id === w.id); if (cbl) { const pts = cablePoints({ ...cbl, nodes: cableNodes(cbl).map((n, i) => i === w.index ? { x: w.x, y: w.y } : n) }, byId); const g = boardRef.current && boardRef.current.querySelector('.pl-cable.sel'); if (g && pts) g.querySelectorAll('polyline').forEach(pl => pl.setAttribute('points', pts.map(q => q[0] + ',' + q[1]).join(' '))); const handles = boardRef.current ? boardRef.current.querySelectorAll('.pl-wp > *') : []; const h = handles[w.index]; if (h) { h.setAttribute('cx', w.x); h.setAttribute('cy', w.y); } } return; } const d = dragRef.current; if (!d) return; const p = svgPoint(e); d.x = clampX(p.x); d.y = clampY(p.y); d.moved = true; if (d.node) d.node.setAttribute('transform', `translate(${d.x},${d.y})`); // A rákötött nyomvonalak végpontja az eszközzel együtt mozog. if (boardRef.current) { (cables || []).forEach(c => { if (cableDeviceIds(c).indexOf(d.id) < 0) return; const moved = { ...byId[d.id], x: d.x, y: d.y }; const idx = { ...byId, [d.id]: moved }; const pts = cablePoints(c, idx); if (!pts) return; const g = boardRef.current.querySelector('[data-cable="' + c.id + '"]'); if (g) g.querySelectorAll('polyline').forEach(pl => pl.setAttribute('points', pts.map(q => q[0] + ',' + q[1]).join(' '))); }); } }; const onUp = () => { const w = wpDragRef.current; if (w) { // Fontos: mindig NULLÁZZUK. Ha itt bármi bent marad, a következő // egérmozgás is töréspont-húzásnak látszik, és az eszközök húzása néma // módon leáll. wpDragRef.current = null; if (w.moved) { commitCablesRef.current((cables || []).map(c => c.id === w.id ? { ...c, nodes: cableNodes(c).map((n, i) => i === w.index ? { x: w.x, y: w.y } : n) } : c)); } return; } const d = dragRef.current; if (!d) return; dragRef.current = null; if (!d.moved) { setSelId(d.id); setSelCableId(null); return; } commit((elements || []).map(e => e.id === d.id ? { ...e, x: d.x, y: d.y } : e)); setSelId(d.id); setSelCableId(null); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); document.addEventListener('pointercancel', onUp); return () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.removeEventListener('pointercancel', onUp); }; }, [elements, cables, byId, commit]); // Gumiszalag: rajzolás közben az utolsó pont és a kurzor közti szakasz. React.useEffect(() => { if (!draft) return; const onMove = (e) => { const p = svgPoint(e); const g = boardRef.current && boardRef.current.querySelector('.pl-draft'); if (!g) return; const base = resolveNodes(draft.nodes, byId); // Shift: a gumiszalag is a tengelyre zárva fut, hogy azt lásd, ami // kattintáskor létrejön. let sp = null, tip; if (e.shiftKey && base.length) { const a = base[base.length - 1]; tip = Math.abs(p.x - a[0]) >= Math.abs(p.y - a[1]) ? { x: p.x, y: a[1] } : { x: a[0], y: p.y }; } else { sp = snapAt(p, null); tip = sp || p; } const pts = [...base, [clampX(tip.x), clampY(tip.y)]]; g.setAttribute('points', pts.map(q => q[0] + ',' + q[1]).join(' ')); setSnap(sp); }; document.addEventListener('pointermove', onMove); return () => { document.removeEventListener('pointermove', onMove); setSnap(null); }; }, [draft, snapAt, byId]); // ── műveletek ───────────────────────────────────────────────────────────── const patch = (id, fields, opts) => commit((elements || []).map(e => e.id === id ? { ...e, ...fields } : e), opts); const remove = (id) => { const el = byId[id]; // Kötődoboz törlésekor az átmenő nyomvonalak megmaradnak: a megálló sima // töréspontra alakul vissza. (Ami CSAK a dobozba futott be, az így egy // végpont nélküli szakasz lenne — azt a takarítás elviszi, ahogy bármely // más gyűjtőpont törlésekor is.) if (el && el.type === 'junction') { const nextCables = (cables || []).map(c => cableDeviceIds(c).indexOf(id) < 0 ? c : { ...c, nodes: cableNodes(c).map(n => n.el === id ? { x: el.x, y: el.y } : n) }); commit({ elements: (elements || []).filter(e => e.id !== id), cables: nextCables }); setSelId(null); setMenu(null); return; } commit((elements || []).filter(e => e.id !== id)); setSelId(null); setMenu(null); }; // ── kábel-műveletek ─────────────────────────────────────────────────────── // Minden kábel-írás ezen megy át: ha közben azonos áramnemű nyomvonalak // találkoznak egy töréspontban, ott magától kötődoboz keletkezik. const commitCables = (nextCables, opts) => { if (!autoJunction) { commit({ cables: nextCables }, opts); return; } const res = promoteJunctions(elements || [], nextCables, newId); if (!res.created.length) { commit({ cables: res.cables }, opts); return; } commit({ elements: res.elements, cables: res.cables }, opts); onToast('✓ ' + res.created.length + ' kötődoboz a találkozási ponton'); }; commitCablesRef.current = commitCables; const patchCable = (id, fields) => commitCables((cables || []).map(c => c.id === id ? { ...c, ...fields } : c)); const removeCable = (id) => { commit({ cables: (cables || []).filter(c => c.id !== id) }); setSelCableId(null); }; // Egy eszköz összes nyomvonala — a tulajdonságok panelen jelenik meg. const cablesOf = (id) => (cables || []).filter(c => cableDeviceIds(c).indexOf(id) >= 0); const applyAutoWire = (created, rewire) => { // Újrahúzáskor a régi nyomvonalak közül azok esnek ki, amelyeket most // pótolunk — így nem duplázódnak. const replacing = {}; created.forEach(c => { cableDeviceIds(c).forEach(id => { replacing[id] = true; }); }); const kept = rewire ? (cables || []).filter(c => !cableDeviceIds(c).some(id => replacing[id])) : (cables || []); const next = renumberCables( [...kept, ...created.map(c => ({ ...c, id: newCableId() }))], byId); commitCables(next, { immediate: true, reason: 'autowire' }); setAutoWire(false); onToast('✓ ' + created.length + ' nyomvonal létrehozva'); }; const duplicate = (id) => { const el = (elements || []).find(e => e.id === id); if (!el) return; const cp = { ...el, id: newId(), x: clampX(el.x + 28), y: clampY(el.y + 28) }; commit([...(elements || []), cp]); setSelId(cp.id); setMenu(null); }; // A lista „+" gombja: ugyanolyan típusú új elem a meglévő mellé. const addBeside = (el) => { const cp = { id: newId(), type: el.type, x: clampX(el.x + 28), y: clampY(el.y + 28), label: '', wired: el.wired }; commit([...(elements || []), cp]); setSelId(cp.id); }; // ── billentyűk ──────────────────────────────────────────────────────────── React.useEffect(() => { const onKey = (e) => { const tag = (document.activeElement && document.activeElement.tagName) || ''; const typing = tag === 'INPUT' || tag === 'TEXTAREA'; if (e.key === 'Escape') { setPlacing(null); setMenu(null); if (draft) setDraft(null); else if (wireMode) setWireMode(false); } // Enter lezárja a láncot (legalább két eszközzel). if (e.key === 'Enter' && draft && !typing) { e.preventDefault(); commitWire(draft); return; } // Rajzolás közben a Backspace az utolsó megállót veszi vissza. if (e.key === 'Backspace' && draft && !typing) { e.preventDefault(); if (draft.nodes.length > 1) setDraft({ nodes: draft.nodes.slice(0, -1) }); else setDraft(null); return; } if ((e.key === 'Delete' || e.key === 'Backspace') && !typing) { if (selCableId) { removeCable(selCableId); return; } if (selId) remove(selId); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }); // Gyorsmenü zárása külső kattintásra. React.useEffect(() => { if (!menu) return; const close = (e) => { if (!e.target.closest('.pl-qmenu')) setMenu(null); }; document.addEventListener('pointerdown', close, true); return () => document.removeEventListener('pointerdown', close, true); }, [menu]); // ── export / import / alaphelyzet ───────────────────────────────────────── const exportJson = () => { const blob = new Blob([JSON.stringify({ v: 3, project: 'Synapse H1', updated: new Date().toISOString(), elements: elements || [], }, null, 1)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'synapse-wiring.json'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }; const importJson = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => { try { const d = JSON.parse(r.result); if (!Array.isArray(d.elements)) throw new Error('bad'); if (!window.confirm(window.t('Az importált terv felülírja a jelenlegit mindenkinek. Folytatod?'))) return; replaceAll(d.elements, 'import'); setSelId(null); onToast('✓ Terv importálva (' + d.elements.length + ' elem)'); } catch (err) { onToast('Hibás JSON fájl'); } }; r.readAsText(file); }; const resetToBase = async () => { if (!window.confirm(window.t('Elveted a jelenlegi elrendezést, és visszaállítod az alapot?'))) return; try { const data = await window.api.plan.seed(); replaceAll(data.elements || [], 'reset'); setSelId(null); onToast('✓ Alap elrendezés visszaállítva'); } catch (err) { onToast('Hiba: ' + window.apiErrorMessage(err)); } }; const clearAll = () => { const n = (elements || []).length; if (!window.confirm(window.t('Biztosan törlöd MIND a(z) ' + n + ' elemet a tervről?'))) return; if (!window.confirm(window.t('Egészen biztos? Ez minden lerakott elemet töröl. (Tipp: előtte exportáld JSON-be.)'))) return; replaceAll([], 'clear'); setSelId(null); setPlacing(null); }; const printPlan = () => { document.body.classList.add('pl-printing'); const done = () => { document.body.classList.remove('pl-printing'); window.removeEventListener('afterprint', done); }; window.addEventListener('afterprint', done); window.print(); }; // ── mentés-állapot felirat ──────────────────────────────────────────────── const statusText = status === 'saving' ? 'Mentés…' : status === 'dirty' ? 'Nem mentett módosítás…' : status === 'error' ? '⚠ A mentés nem sikerült' : savedAt ? 'Mentve ✓ ' + savedAt.toLocaleTimeString(window.LANG === 'en' ? 'en-GB' : 'hu-HU') : 'Automatikus mentés'; // rövid — a vezérlősávban minden képpont számít const placedList = (elements || []).filter(visible); const menuEl = menu ? (elements || []).find(e => e.id === menu.id) : null; // ── mentés-állapot · vászon mód ─────────────────────────────────────────── // A három vezérlő korábban egy külön sort vitt el a rétegsáv jobb szélén. // Mostantól: a mentés-állapot a fájlműveletek sorának bal szélén, a vászon // mód a rétegsáv jobb szélén, a nagyítás pedig a tulajdonság-oszlop tetején. // Vászon módban a fájlműveletek sora rejtve van, ezért az állapot ilyenkor a // rétegsávba költözik — különben eltűnne a mentés visszajelzése. const statusEl = ( {statusText} ); const compactBtn = ( ); return (
{/* ── paletta ── */}
Eszköz hozzáadása: { setTypeQuery(e.target.value); if (e.target.value) setPaletteOpen(true); }} onKeyDown={e => { if (e.key === 'Enter' && paletteGroups.length) setPlacing(paletteGroups[0].keys[0]); }} placeholder="Eszközkeresés… (pl. kamera, víz, lámpa) — Enter az első találatot lerakja"/> {placing ? 'Kattints a tervre a lerakáshoz — ' + T[placing].label : typeQuery ? paletteHits + ' eszköztípus' : ''}
{paletteGroups.map(g => (
{g.name} {g.keys.map(k => ( ))}
))}
{/* ── vezérlősáv: rétegek + vászon mód ───────────────────────────────── Ez a sor vászon módban is látszik: a rétegszűrés és a visszakapcsoló gomb a terv nézegetése közben is kell. */} {compact ? statusEl : null} {compactBtn} {/* ── fájlműveletek + ritka, roncsoló műveletek (vászon módban rejtve) ── */}
{statusEl} { importJson(e.target.files[0]); e.target.value = ''; }}/>
Hogyan használd
  • Keress vagy kattints egy eszköztípusra, majd kattints a tervre a lerakáshoz (Esc megszakítja).
  • A jelölők húzással mozgathatók.
  • Kattints egy jelölőre — vagy a lista egy sorára — a kijelöléshez; a sor + gombja új példányt tesz le mellé.
  • Dupla kattintás egy jelölőn: gyorsmenü (átnevezés / másolat / törlés).
  • A kijelölt elem a Delete billentyűvel is törölhető.
  • Szaggatott körvonal = vezeték nélküli.
  • Minden változás automatikusan mentődik a szerverre — a terv mindenkinek közös.
  • Kábel gomb: kattints a kiinduló eszközre, utána a célra — közben az üres területre kattintva töréspontot teszel le, hogy a nyomvonal a falak mentén fusson.
  • Shift nyomva tartva a rajzolás derékszögben fut: a töréspont csak vízszintesen vagy függőlegesen mozdul az előző ponthoz képest — a töréspontok húzására is érvényes.
  • Egy nyomvonalra kattintva kijelölöd; a kijelölt vonalra újra kattintva töréspontot szúrsz be, a töréspontok húzhatók, dupla kattintás kiveszi őket.
  • Egy nyomvonalra több eszköz is felfűzhető (pl. világítási kör): kábel módban kattints sorra az eszközökre, majd Enter vagy a „Kész" gomb zárja le.
  • Automatikus bekábelezés: rétegenként minden eszközt bekábelez a legközelebbi gyűjtőpontba (switch / csomópont / elosztó / erősítő).
  • A Kábellista mutatja a kábeltípusonkénti darabszámot és hosszt, és CSV-be exportál.
{wireMode && (
{draft ? 'Kattints a következő eszközre — több eszköz is felfűzhető egy nyomvonalra · üres területre kattintva töréspont · Shift: derékszögben · Backspace visszavon · Esc megszakít' : 'Kattints a kiinduló eszközre — a nyomvonal a réteg alapértelmezett kábeltípusával készül'} {draft && ( <> {draftStops} eszköz )}
)} {/* ── vászon + tulajdonságok ── */}
{/* A nagyítás az oszlop tetején, egy sorban — a vászon fölötti sávok helyett itt van, és vászon módban is elérhető marad. */}

Tulajdonságok

{/* Kijelölt KÖTEG — az együtt futó kábelek és a védőcső-igény. */} {selBundle ? (
{selBundle.ids.length} kábel egy nyomvonalon
{(() => { const cd = conduitFor(selBundle.types); return (
javasolt védőcső: Ø{cd.size} mm ({Math.round( (typeof window !== 'undefined' && window.PLANNER_CONDUIT_FILL ? window.PLANNER_CONDUIT_FILL : 0.4) * 100 )}% kitöltésnél, szükséges Ø{cd.need.toFixed(1)} mm)
); })()}
{selBundle.ids.map(id => { const c = (cables || []).find(x => x.id === id); if (!c) return null; const ct = cableType(c.type); return (
{ setSelCableId(id); setSelBundleKey(null); }}> {(c.code ? c.code + ' · ' : '') + (ct ? ct.label : c.type)}
); })}
) : selCable ? (
{selCable.code || '—'} · {fmtM(cableMeters(selCable, byId, settings.scale, settings.slack))}
{(() => { const ns = cableNodes(selCable); const names = cableDeviceIds(selCable).map(id => byId[id] ? planLabel(byId[id]) : '?'); if (ns.length && !ns[ns.length - 1].el) names.push('csatlakozási pont'); if (ns.length && !ns[0].el) names.unshift('csatlakozási pont'); return names.join(' → '); })()} {settings.slack ? ' · ' + settings.slack + '% tartalékkal' : ''}
patchCable(selCable.id, { label: e.target.value })} placeholder="pl. meglévő védőcsőben"/>
{cableDeviceIds(selCable).length} eszköz · {cableNodes(selCable).filter(n => !n.el).length} töréspont — húzd a kör alakú fogantyúkat, dupla kattintás kiveszi; a már kijelölt vonalra kattintva új töréspont kerül be
{(() => { // Szabad végpont → kötődoboz utólag is. A nyomvonal elején és // a végén lévő pontra egyaránt. const ns = cableNodes(selCable); const idxs = []; if (ns.length && !ns[0].el) idxs.push(0); if (ns.length > 1 && !ns[ns.length - 1].el) idxs.push(ns.length - 1); if (!idxs.length) return null; return (
{idxs.map(i => ( ))}
); })()}
) : !sel ?
Nincs kijelölve semmi.
: (
{T[sel.type] ? T[sel.type].label + ' [' + T[sel.type].ab + ']' : sel.type}
patch(sel.id, { label: e.target.value })} placeholder={T[sel.type] ? T[sel.type].label : ''}/>
x {Math.round(sel.x)} · y {Math.round(sel.y)}
{/* Az eszközre kötött nyomvonalak — innen egy kattintással átugorhatunk a kábel lapjára. */} {/* Kötődoboz vegyes tartalommal: a kivitelezésben ez osztást (leválasztott rekesz) vagy két külön dobozt igényel. */} {sel.type === 'junction' && (() => { const fams = {}; cablesOf(sel.id).forEach(c => { const f = catFamily(c.cat); if (f) fams[f] = true; }); if (Object.keys(fams).length < 2) return null; return (
Erősáramú és gyengeáramú nyomvonal is fut ebbe a dobozba — a kivitelezésnél osztott doboz vagy két külön doboz kell.
); })()} {cablesOf(sel.id).length === 0 ? (
Nincs bekötve. A „Kábel" gombbal húzhatsz nyomvonalat, vagy használd az automatikus bekábelezést.
) : (
{cablesOf(sel.id).map(c => { const ct = cableType(c.type); // A nyomvonal többi eszköze (a mostani nélkül) — több megállós // láncnál mind, szabad végpontnál a „csatlakozási pont". const others = cableDeviceIds(c).filter(id => id !== sel.id) .map(id => byId[id] ? planLabel(byId[id]) : '?'); if (cableNodes(c).some(n => !n.el)) others.push('csatlakozási pont'); const otherTxt = others.join(' → ') || '—'; return (
{ setSelCableId(c.id); setSelId(null); }}> {(c.code ? c.code + ' · ' : '') + otherTxt} {fmtM(cableMeters(c, byId, settings.scale, settings.slack))}
); })}
)}
)}

Lerakott elemek {'(' + (elements || []).length + (placedQuery ? ', ' + placedList.length + ' találat' : '') + ')'}

setPlacedQuery(e.target.value)} placeholder="Keresés a lerakottak közt… (név, csoport, kategória)"/>
{placedList.map(e => (
{ setSelId(e.id); setSelCableId(null); }}> {planLabel(e)}
))}
{/* ── gyorsmenü (dupla kattintás) ── */} {menuEl && (

{T[menuEl.type] ? T[menuEl.type].label.split(' (')[0].split(' — ')[0] : menuEl.type}

patch(menuEl.id, { label: e.target.value })}/>
)} {askJunction && ( resolveEndpoint(false)} title="Csatlakozási pont">
A nyomvonal egy szabad ponton végződik. Legyen ebből kötődoboz, vagy maradjon egyszerű csatlakozási pont, amihez később más nyomvonal is hozzáköthető?
)} {coverage && setCoverage(false)}/>} {autoWire && ( setAutoWire(false)}/> )} {schedule && ( setSchedule(false)} onSetSlack={v => commit({ settings: { ...settings, slack: v } })} onRenumber={() => { commit({ cables: renumberCables(cables || [], byId) }); onToast('✓ Nyomvonalak újraszámozva'); }}/> )}
); }; // ════════════════════════════════════════════════════════════════════════════ // NÉZEGETŐ (mobil, illetve csak megtekintési joggal) // ════════════════════════════════════════════════════════════════════════════ // Csak olvasható: csippentéssel nagyítható/húzható alaprajz, rétegszűrés, // kereshető elemlista, és koppintásra elem-adatlap. Semmit nem módosít. const PlannerViewer = ({ plan, readOnlyNote }) => { const { elements, cables, settings } = plan; const T = planTypes(); const byId = React.useMemo(() => { const m = {}; (elements || []).forEach(e => { m[e.id] = e; }); return m; }, [elements]); const [catVisible, setCatVisible] = React.useState(() => PLAN_CATS.reduce((a, c) => (a[c] = true, a), {})); const [query, setQuery] = React.useState(''); // A kijelölés (glória a terven) és az adatlap két külön állapot: a listából // ráugrás csak kiemeli és középre hozza az elemet — az adatlap eltakarná a // tervet, amit a felhasználó épp meg akart nézni. Az adatlap a terven lévő // jelölőre koppintva nyílik. const [selId, setSelId] = React.useState(null); const [detailId, setDetailId] = React.useState(null); const [showList, setShowList] = React.useState(false); const [view, setView] = React.useState({ k: 1, x: 0, y: 0 }); const viewportRef = React.useRef(null); const stageRef = React.useRef(null); const gestureRef = React.useRef(null); const matches = React.useCallback((el) => { const q = query.trim().toLowerCase(); if (!q) return true; const t = planType(el.type); if (!t) return false; return (String(el.label || '') + ' ' + t.search).toLowerCase().indexOf(q) >= 0; }, [query]); const visible = React.useCallback((el) => { const t = planType(el.type); return !!t && !!catVisible[t.cat] && matches(el); }, [catVisible, matches]); // A nyomvonal a rétegével együtt látszik; kereséskor csak az illeszkedők. const cableVisible = React.useCallback((c) => { if (!catVisible[c.cat]) return false; if (!query.trim()) return true; const a = byId[c.from], b = byId[c.to]; return (a && matches(a)) || (b && matches(b)); }, [catVisible, query, byId, matches]); // Induláskor a TELJES terv látszik (szélesség és magasság szerint is // beillesztve), vízszintesen középre igazítva — innen lehet belenagyítani. const fit = React.useCallback(() => { const vp = viewportRef.current; if (!vp || !vp.clientWidth || !vp.clientHeight) return; const k = Math.min(vp.clientWidth / PLAN_GEOM.w, vp.clientHeight / PLAN_GEOM.h); setView({ k, x: Math.max(0, (vp.clientWidth - PLAN_GEOM.w * k) / 2), y: 0 }); }, []); // A vászon magassága a saját pozíciójából jön: pontosan a fejléc alatti és a // navigáció fölötti sávot tölti ki. Fix vh-számolás itt nem elég — a fölötte // lévő elemek (szűrők, kereső) magassága nyelvenként és eszközönként más, a // mobilböngészők vh-ja pedig görgetés közben változik. const sizeViewport = React.useCallback(() => { const el = viewportRef.current; if (!el) return; const nav = document.querySelector('.bottomnav'); // A navigáció mobilon alul ül (elfedné a vásznat), asztali gépen oldalt — // ott nem kell levonni a magasságát. const navH = nav && nav.getBoundingClientRect().top > el.getBoundingClientRect().top ? nav.offsetHeight : 0; const top = el.getBoundingClientRect().top; el.style.height = Math.max(280, Math.round(window.innerHeight - top - navH - 10)) + 'px'; }, []); // A vászon végleges mérete csak a rugalmas elrendezés leülepedése után áll be // (és forgatáskor újra változik), ezért méretfigyelővel illesztünk. Amint a // felhasználó megfogja vagy nagyítja a tervet, a nézet az övé — onnantól már // nem igazítunk bele; az „Illeszt" gomb adja vissza a teljes nézetet. const userMovedRef = React.useRef(false); React.useEffect(() => { const relayout = () => { sizeViewport(); if (!userMovedRef.current) fit(); }; relayout(); // A méretfigyelő csak illeszt — a magasságot nem ő állítja, különben // önmagát etetné vissza. const ro = new ResizeObserver(() => { if (!userMovedRef.current) fit(); }); if (viewportRef.current) ro.observe(viewportRef.current); window.addEventListener('resize', relayout); window.addEventListener('orientationchange', relayout); // Mobil böngészőben a cím-/eszközsáv be-kihúzása nem mindig ad window // resize-t — a visualViewport igen. Nélküle a vászon alatt holt sáv marad. const vv = window.visualViewport; if (vv) vv.addEventListener('resize', relayout); return () => { ro.disconnect(); window.removeEventListener('resize', relayout); window.removeEventListener('orientationchange', relayout); if (vv) vv.removeEventListener('resize', relayout); }; }, [fit, sizeViewport]); // A szűrők/olvasás-jelzés sormagassága a tartalomtól függ (pl. hosszabb angol // felirat két sorba törik) — ezért a méretezést a tartalom változásakor is // újrafuttatjuk. React.useEffect(() => { sizeViewport(); }, [sizeViewport, catVisible, query, readOnlyNote]); // Egy ujj = húzás, két ujj = csippentés. A pointer-eseményekkel egérrel is // működik (trackpad-görgetés helyett húzás). const pointers = React.useRef(new Map()); const onPointerDown = (e) => { // A vászon fölött lebegő gombokról indított lenyomás nem húzás. Fontos: // ha itt elkapnánk a pointert (setPointerCapture), a gomb sosem kapná meg // a pointerup-ot, így a rákattintás elveszne. if (e.target.closest && e.target.closest('.pl-viewer-zoom')) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* nem támogatott */ } if (pointers.current.size === 1) { gestureRef.current = { mode: 'pan', sx: e.clientX, sy: e.clientY, ox: view.x, oy: view.y, moved: false }; } else if (pointers.current.size === 2) { const [a, b] = Array.from(pointers.current.values()); gestureRef.current = { mode: 'pinch', d0: Math.hypot(a.x - b.x, a.y - b.y), cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, k0: view.k, ox: view.x, oy: view.y, }; } }; const onPointerMove = (e) => { if (!pointers.current.has(e.pointerId)) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const g = gestureRef.current; if (!g) return; if (g.mode === 'pan' && pointers.current.size === 1) { const dx = e.clientX - g.sx, dy = e.clientY - g.sy; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { g.moved = true; userMovedRef.current = true; } setView(v => ({ ...v, x: g.ox + dx, y: g.oy + dy })); } else if (g.mode === 'pinch' && pointers.current.size >= 2) { const [a, b] = Array.from(pointers.current.values()); const d = Math.hypot(a.x - b.x, a.y - b.y); if (!d || !g.d0) return; userMovedRef.current = true; const vp = viewportRef.current.getBoundingClientRect(); const k = Math.max(0.1, Math.min(8, g.k0 * (d / g.d0))); // A csippentés középpontja a helyén marad. const px = g.cx - vp.left, py = g.cy - vp.top; setView({ k, x: px - (px - g.ox) * (k / g.k0), y: py - (py - g.oy) * (k / g.k0) }); } }; const onPointerUp = (e) => { pointers.current.delete(e.pointerId); if (pointers.current.size === 0) gestureRef.current = null; }; // Abszolút nagyítás (pl. a 100%-os gomb): a nézet közepe marad a helyén. const zoomTo = (target) => { userMovedRef.current = true; return setView(v => { const vp = viewportRef.current; if (!vp) return v; const k = Math.max(0.1, Math.min(8, target)); const px = vp.clientWidth / 2, py = vp.clientHeight / 2; return { k, x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k) }; }); }; const zoomBy = (f) => { userMovedRef.current = true; return setView(v => { const vp = viewportRef.current; if (!vp) return v; const k = Math.max(0.1, Math.min(8, v.k * f)); const px = vp.clientWidth / 2, py = vp.clientHeight / 2; return { k, x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k) }; }); }; // Lista-koppintás: az elemet a nézet közepére hozza, és kijelöli. const focusOn = (el) => { const vp = viewportRef.current; userMovedRef.current = true; setSelId(el.id); setShowList(false); if (!vp) return; setView(v => { const k = Math.max(v.k, 1.2); return { k, x: vp.clientWidth / 2 - el.x * k, y: vp.clientHeight / 2 - el.y * k }; }); }; const detail = (elements || []).find(e => e.id === detailId) || null; const list = (elements || []).filter(visible); const atFullZoom = Math.abs(view.k - 1) < 0.005; return (
{readOnlyNote &&
{readOnlyNote}
}
setQuery(e.target.value)} placeholder="Keresés a terven…"/>
Alaprajz {/* nyomvonalak — csak megjelenítés, a jelölők alatt */} {(cables || []).map(c => { if (!cableVisible(c)) return null; const pts = cablePoints(c, byId); if (!pts) return null; return ; })} {(elements || []).map(el => visible(el) ? ( { // Csak koppintás jelöl ki — húzás közben ne ugorjon fel az adatlap. e.stopPropagation(); const sx = e.clientX, sy = e.clientY; const up = (ev) => { document.removeEventListener('pointerup', up); if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 6) { setSelId(el.id); setDetailId(el.id); } }; document.addEventListener('pointerup', up); }}/> ) : null)}
{/* koppintott elem adatlapja */} {detail && ( setDetailId(null)} title={planLabel(detail)}>
{T[detail.type] ? T[detail.type].label : detail.type}
{T[detail.type] ? (T[detail.type].g || '') + ' · ' + (planCatNames()[T[detail.type].cat] || '') : ''}
Jelölés{T[detail.type] ? T[detail.type].ab : '—'}
Kapcsolat{detail.wired ? 'Vezetékes' : 'Vezeték nélküli'}
Pozíció{'x ' + Math.round(detail.x) + ' · y ' + Math.round(detail.y)}
{(() => { const mine = (cables || []).filter(c => cableDeviceIds(c).indexOf(detail.id) >= 0); if (!mine.length) return
Nyomvonalnincs
; const total = mine.reduce((a, c) => a + cableMeters(c, byId, settings.scale, settings.slack), 0); return
Nyomvonal{mine.length + ' db · ' + fmtM(total)}
; })()}
)} {/* teljes elemlista */} {showList && ( setShowList(false)} title={'Lerakott elemek (' + list.length + ')'}>
{list.map(e => (
focusOn(e)}> {planLabel(e)}
))} {!list.length &&
Nincs találat
}
)}
); }; // ════════════════════════════════════════════════════════════════════════════ // Belépési pont — eldönti, hogy szerkesztő vagy nézegető kell // ════════════════════════════════════════════════════════════════════════════ const Planner = ({ user, onToast, isDesktop }) => { // Szerkesztés csak teljes ('yes') joggal — a 'részleges' itt megtekintést jelent. const canEdit = !!(window.hasPerm && window.hasPerm('planner.edit', user, true)); const editing = canEdit && isDesktop; const plan = usePlan(editing, onToast); // „Vászon mód": a terv fölötti minden fix sáv eltűnik (oldalcím, paletta, // rétegek, súgó, jelmagyarázat), csak a keskeny műveletsor marad — ott a // visszakapcsoló gombbal. A választás megmarad a következő megnyitásra. const [compact, setCompactState] = React.useState(() => { try { return localStorage.getItem('h1_plan_compact') === '1'; } catch (e) { return false; } }); const setCompact = (v) => { try { localStorage.setItem('h1_plan_compact', v ? '1' : '0'); } catch (e) { /* privát mód */ } setCompactState(v); }; if (plan.error) { return (

Tervezés

A terv betöltése nem sikerült: {plan.error}
); } if (plan.elements === null) { return (

Tervezés

Terv betöltése…
); } return (

Tervezés

{'Kábelezési terv · ' + (plan.elements || []).length + ' elem'}
{editing ? : }
); }; Object.assign(window, { Planner, PlannerEditor, PlannerViewer, PlanMarker, PlanStage, PlanLegend, PlanCoverage, PlanLayers });