let nodes = [], edges = [], selId = null, dragId = null, dOffX, dOffY; let isPan = false, pSX, pSY, pOffX = 0, pOffY = 0, zoom = 1; let connFrom = null, tmpLine = null, nIdCnt = 0; let undoStk = [], redoStk = []; const wrap = document.getElementById('wrap'), vp = document.getElementById('viewport'); const cv = document.getElementById('canvas'), svg = document.getElementById('svg'); const props = document.getElementById('props'); function sn(v) { return Math.round(v / 20) * 20; } function buildPalette() { const p = document.getElementById('palette'); CATS.forEach(([cat, types]) => { p.innerHTML += `
${cat}
`; types.forEach(t => { const info = NODE_TYPES[t]; p.innerHTML += `
${t.split('-')[0].substring(0,2).toUpperCase()} ${info.label}
`; }); }); document.querySelectorAll('.p-item').forEach(el => { el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', el.dataset.type); }); }); } function addNode(type, x, y, id, label) { const def = NODE_TYPES[type]; if (!def) return null; const nid = id || (++nIdCnt); const el = document.createElement('div'); el.className = 'node'; el.dataset.id = nid; el.style.cssText = `left:${sn(x)}px;top:${sn(y)}px;width:130px;border-color:${def.color}`; el.innerHTML = `
×
${label||def.label}
${def.label}
`; el.addEventListener('mousedown', e => { if (e.target.closest('.rm,.port')) return; e.stopPropagation(); selNode(nid); const r = el.getBoundingClientRect(); dragId = nid; dOffX = e.clientX - r.left; dOffY = e.clientY - r.top; el.style.zIndex = 20; el.style.cursor = 'grabbing'; saveSt(); }); el.querySelectorAll('.port').forEach(p => { p.addEventListener('mousedown', e => { e.stopPropagation(); e.preventDefault(); if (p.classList.contains('port-out')) { connFrom = { id: +p.dataset.n, el: p }; const r = p.getBoundingClientRect(), wr = vp.getBoundingClientRect(); tmpLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); tmpLine.setAttribute('stroke', '#58a6ff'); tmpLine.setAttribute('stroke-width', '2'); tmpLine.setAttribute('stroke-dasharray', '6,4'); tmpLine.setAttribute('x1', (r.left - wr.left) / zoom); tmpLine.setAttribute('y1', (r.top - wr.top + 4) / zoom); tmpLine.setAttribute('x2', (e.clientX - wr.left) / zoom); tmpLine.setAttribute('y2', (e.clientY - wr.top) / zoom); svg.appendChild(tmpLine); } }); }); cv.appendChild(el); nodes.push({ id: nid, type, x: sn(x), y: sn(y), label: label || def.label }); updStats(); return nid; } function rmNode(id) { const el = cv.querySelector(`.node[data-id="${id}"]`); if (el) el.remove(); nodes = nodes.filter(n => n.id !== id); edges = edges.filter(e => e.from !== id && e.to !== id); renEdges(); updStats(); if (selId === id) { selId = null; props.classList.remove('open'); } saveSt(); } function selNode(id) { cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected')); const el = cv.querySelector(`.node[data-id="${id}"]`); if (el) el.classList.add('selected'); selId = id; showProps(id); } function showProps(id) { const n = nodes.find(x => x.id === id); if (!n) return; props.classList.add('open'); document.getElementById('pLabel').value = n.label; document.getElementById('pSub').value = n.sub || ''; document.getElementById('pWidth').value = 130; const sel = document.getElementById('pType'); sel.innerHTML = ''; Object.keys(NODE_TYPES).forEach(k => { const o = document.createElement('option'); o.value = k; o.textContent = NODE_TYPES[k].label; if (k === n.type) o.selected = true; sel.appendChild(o); }); } function up(f, v) { const n = nodes.find(x => x.id === selId); if (!n) return; const el = cv.querySelector(`.node[data-id="${selId}"]`); if (!el) return; if (f === 'label') { n.label = v; el.querySelector('.nl').textContent = v; } else if (f === 'type') { const def = NODE_TYPES[v]; if (!def) return; n.type = v; n.label = def.label; el.style.borderColor = def.color; el.querySelector('.nl').textContent = def.label; el.querySelector('.ns').textContent = def.label; document.getElementById('pLabel').value = def.label; } else if (f === 'w') { el.style.width = v + 'px'; } renEdges(); saveSt(); } function delNode() { if (selId !== null) rmNode(selId); } function addEdge(from, to) { if (from === to || edges.some(e => e.from === from && e.to === to)) return; edges.push({ from, to }); renEdges(); updStats(); saveSt(); } function renEdges() { svg.querySelectorAll('.eg').forEach(g => g.remove()); const wr = vp.getBoundingClientRect(); edges.forEach((e, i) => { const fEl = cv.querySelector(`.node[data-id="${e.from}"]`); const tEl = cv.querySelector(`.node[data-id="${e.to}"]`); if (!fEl || !tEl) return; const fr = fEl.getBoundingClientRect(), tr = tEl.getBoundingClientRect(); const x1 = (fr.right - wr.left - 8) / zoom, y1 = (fr.top + fr.height/2 - wr.top) / zoom; const x2 = (tr.left - wr.left + 8) / zoom, y2 = (tr.top + tr.height/2 - wr.top) / zoom; const cp = Math.max(40, Math.abs(x2 - x1) * 0.4); const d = `M${x1} ${y1} C${x1+cp} ${y1},${x2-cp} ${y2},${x2} ${y2}`; const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.classList.add('eg'); // glow const gl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); gl.setAttribute('d', d); gl.setAttribute('stroke', '#58a6ff'); gl.setAttribute('stroke-width', '5'); gl.setAttribute('fill', 'none'); gl.setAttribute('opacity', '0.1'); g.appendChild(gl); // main const m = document.createElementNS('http://www.w3.org/2000/svg', 'path'); m.setAttribute('d', d); m.setAttribute('stroke', '#58a6ff'); m.setAttribute('stroke-width', '2'); m.setAttribute('fill', 'none'); m.setAttribute('stroke-linecap', 'round'); m.addEventListener('dblclick', () => { edges.splice(i,1); renEdges(); updStats(); saveSt(); }); g.appendChild(m); // pulse for (let j = 0; j < 2; j++) { const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', d); p.setAttribute('stroke', '#79c0ff'); p.setAttribute('stroke-width', '1.5'); p.setAttribute('fill', 'none'); p.setAttribute('stroke-dasharray', '6,14'); p.style.animation = `pulseF ${0.8 + j*0.1}s linear infinite`; p.style.animationDelay = `-${j * 0.4}s`; g.appendChild(p); } // arrow const ang = Math.atan2(y2 - y1, x2 - x1); const ax = x2 - 4, ay = y2 - 2; const ar = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); ar.setAttribute('points', `${ax},${ay} ${ax-8*Math.cos(ang-.4)},${ay-8*Math.sin(ang-.4)} ${ax-8*Math.cos(ang+.4)},${ay-8*Math.sin(ang+.4)}`); ar.setAttribute('fill', '#58a6ff'); g.appendChild(ar); svg.appendChild(g); }); } document.addEventListener('mousemove', e => { if (dragId !== null) { const el = cv.querySelector(`.node[data-id="${dragId}"]`); if (!el) return; const wr = vp.getBoundingClientRect(); let x = (e.clientX - wr.left - dOffX) / zoom, y = (e.clientY - wr.top - dOffY) / zoom; x = sn(Math.max(0,x)); y = sn(Math.max(0,y)); el.style.left = x + 'px'; el.style.top = y + 'px'; const n = nodes.find(nn => nn.id === dragId); if (n) { n.x = x; n.y = y; } renEdges(); } if (tmpLine) { const wr = vp.getBoundingClientRect(); tmpLine.setAttribute('x2', (e.clientX - wr.left)/zoom); tmpLine.setAttribute('y2', (e.clientY - wr.top)/zoom); } if (isPan) { pOffX += e.clientX - pSX; pOffY += e.clientY - pSY; pSX = e.clientX; pSY = e.clientY; vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; } }); document.addEventListener('mouseup', e => { if (dragId !== null) { const el = cv.querySelector(`.node[data-id="${dragId}"]`); if (el) el.style.cursor = 'move'; dragId = null; } if (tmpLine) { tmpLine.remove(); tmpLine = null; const t = e.target.closest('.port-in'); if (t && connFrom) addEdge(connFrom.id, +t.dataset.n); connFrom = null; } if (isPan) { isPan = false; wrap.style.cursor = 'grab'; } }); wrap.addEventListener('mousedown', e => { if (e.target.closest('.node')) return; cv.querySelectorAll('.node').forEach(n => n.classList.remove('selected')); selId = null; props.classList.remove('open'); isPan = true; pSX = e.clientX; pSY = e.clientY; wrap.style.cursor = 'grabbing'; }); wrap.addEventListener('wheel', e => { e.preventDefault(); const d = e.deltaY > 0 ? 0.9 : 1.1, nz = Math.max(0.2, Math.min(3, zoom * d)); const wr = wrap.getBoundingClientRect(); const vx = (e.clientX - wr.left - pOffX) / zoom, vy = (e.clientY - wr.top - pOffY) / zoom; zoom = nz; pOffX = e.clientX - wr.left - vx * zoom; pOffY = e.clientY - wr.top - vy * zoom; vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; document.getElementById('zoom').textContent = Math.round(zoom*100) + '%'; }, { passive: false }); cv.addEventListener('dragover', e => e.preventDefault()); cv.addEventListener('drop', e => { e.preventDefault(); const type = e.dataTransfer.getData('text/plain'); if (!type || !NODE_TYPES[type]) return; const wr = vp.getBoundingClientRect(); let x = (e.clientX - wr.left)/zoom - 65, y = (e.clientY - wr.top)/zoom - 20; addNode(type, sn(Math.max(0,x)), sn(Math.max(0,y))); saveSt(); }); function saveSt() { undoStk.push(JSON.stringify({ nodes: nodes.map(n => ({...n})), edges: [...edges] })); if (undoStk.length > 50) undoStk.shift(); redoStk = []; } function undo() { if (undoStk.length < 2) return; redoStk.push(undoStk.pop()); loadSt(JSON.parse(undoStk[undoStk.length-1])); } function redo() { if (!redoStk.length) return; undoStk.push(redoStk.pop()); loadSt(JSON.parse(undoStk[undoStk.length-1])); } function loadSt(st) { cv.querySelectorAll('.node').forEach(el => el.remove()); svg.querySelectorAll('.eg').forEach(g => g.remove()); nodes = []; edges = st.edges || []; (st.nodes || []).forEach(n => addNode(n.type, n.x, n.y, n.id, n.label)); renEdges(); updStats(); } function updStats() { document.getElementById('stats').textContent = `${nodes.length} componenten · ${edges.length} connecties`; } function fit() { if (!nodes.length) return; const mx = Math.min(...nodes.map(n => n.x)), my = Math.min(...nodes.map(n => n.y)); const Mx = Math.max(...nodes.map(n => n.x + 130)), My = Math.max(...nodes.map(n => n.y + 44)); const wr = wrap.getBoundingClientRect(); zoom = Math.max(0.2, Math.min(wr.width/(Mx-mx+150), (wr.height-60)/(My-my+80), 1.5)); pOffX = -mx * zoom + 50; pOffY = -my * zoom + 30; vp.style.transform = `translate(${pOffX}px,${pOffY}px) scale(${zoom})`; document.getElementById('zoom').textContent = Math.round(zoom*100)+'%'; } function filterPalette(q) { q = q.toLowerCase(); document.querySelectorAll('.p-item').forEach(el => el.style.display = el.textContent.toLowerCase().includes(q) ? 'flex' : 'none'); document.querySelectorAll('.cat').forEach(el => { let sib = el.nextElementSibling, has = false; while (sib && sib.classList.contains('p-item')) { if (sib.style.display !== 'none') has = true; sib = sib.nextElementSibling; } el.style.display = has ? 'block' : 'none'; }); } async function save() { const name = document.getElementById('diagramName').value; const client_id = document.getElementById('clientSelect').value; if (!name || !client_id) { setStatus('Naam en cliënt verplicht', 'err'); return; } const data = { id: DIAGRAM_ID || null, client_id: +client_id, name, nodes: nodes.map(n => ({...n})), edges: [...edges] }; try { const r = await fetch('/python-editor/api/save', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) }); const d = await r.json(); setStatus('Opgeslagen ✓', 'ok'); if (d.id && !DIAGRAM_ID) history.replaceState(null, '', '/python-editor/?id=' + d.id); } catch(e) { setStatus('Fout bij opslaan', 'err'); } } function setStatus(msg, t) { const el = document.getElementById('status'); el.textContent = msg; el.style.color = t === 'err' ? '#f85149' : '#3fb950'; setTimeout(() => { el.textContent = ''; }, 3000); } document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; if (e.key === 'Delete' || e.key === 'Backspace') { delNode(); e.preventDefault(); } if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.shiftKey ? redo() : undo(); e.preventDefault(); } if ((e.ctrlKey||e.metaKey) && e.key === 's') { save(); e.preventDefault(); } }); const style = document.createElement('style'); style.textContent = `@keyframes pulseF{0%{stroke-dashoffset:20}100%{stroke-dashoffset:0}}`; document.head.appendChild(style); buildPalette(); fetch('/python-editor/api/clients').then(r=>r.json()).then(clients => { const sel = document.getElementById('clientSelect'); clients.forEach(c => { sel.innerHTML += ``; }); }); if (NODES_JSON && NODES_JSON.length) { NODES_JSON.forEach(n => addNode(n.type, n.x, n.y, n.id, n.label)); edges = EDGES_JSON || []; renEdges(); updStats(); saveSt(); setTimeout(fit, 100); } nIdCnt = nodes.length; updStats(); saveSt();