334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
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 += `<div class="cat">${cat}</div>`;
|
||
types.forEach(t => {
|
||
const info = NODE_TYPES[t];
|
||
p.innerHTML += `<div class="p-item" draggable="true" data-type="${t}">
|
||
<span class="p-icon" style="background:${info.color}33;color:${info.color}">${t.split('-')[0].substring(0,2).toUpperCase()}</span>
|
||
${info.label}</div>`;
|
||
});
|
||
});
|
||
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 = `<div class="rm" onclick="rmNode(${nid})">×</div>
|
||
<div class="port port-in" data-n="${nid}"></div>
|
||
<div class="nl">${label||def.label}</div>
|
||
<div class="ns">${def.label}</div>
|
||
<div class="port port-out" data-n="${nid}"></div>`;
|
||
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 += `<option value="${c.id}">${c.name}</option>`; });
|
||
});
|
||
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();
|