Initial commit — Mek-Tech AI Consultancy Framework v2.0

This commit is contained in:
mo
2026-07-12 21:54:22 +00:00
commit 18921c021b
180 changed files with 28518 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DB_PATH=/app/data/consulting.db
ENV PY_PORT=3001
EXPOSE 3001
CMD ["python3", "app.py"]
+183
View File
@@ -0,0 +1,183 @@
import os
import sqlite3
import json
from flask import Flask, render_template_string, request, jsonify, send_from_directory
DB_PATH = os.environ.get('DB_PATH', '/app/data/consulting.db')
app = Flask(__name__, static_folder='static', static_url_path='/python-editor/static')
HTML = '''<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Architectuur Designer — Mek-Tech</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#0d1117;--bg-card:#161b22;--bg-nav:#161b22;--border:#30363d;--text:#e6edf3;--text-muted:#8b949e;--accent:#58a6ff;--accent2:#3fb950;--danger:#f85149;--warning:#d29922;--purple:#bc8cff}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;overflow:hidden;height:100vh}
.nav{display:flex;align-items:center;gap:.75rem;padding:.5rem 1rem;background:var(--bg-nav);border-bottom:1px solid var(--border);height:48px}
.nav-brand{font-weight:700;font-size:1rem;color:var(--text);display:flex;align-items:center;gap:.4rem}
.nav-brand svg{width:20px;height:20px}
.nav-sep{width:1px;height:20px;background:var(--border)}
.nav input,.nav select{padding:.3rem .5rem;font-size:.75rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:inherit}
.nav input{width:180px}
.nav .btn{padding:.3rem .7rem;font-size:.7rem;border-radius:4px;border:none;cursor:pointer;font-weight:500;color:#fff}
.btn-primary{background:#238636}.btn-primary:hover{background:#2ea043}
.btn-accent{background:var(--accent)}.btn-accent:hover{background:#79c0ff}
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)!important}
.btn-ghost:hover{color:var(--text);background:var(--bg-card)}
.nav-status{font-size:.7rem;color:var(--text-muted);margin-left:auto}
.layout{display:flex;height:calc(100vh - 48px)}
.sidebar{width:200px;background:var(--bg-nav);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar input{margin:.5rem;padding:.35rem .5rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
.palette{flex:1;overflow-y:auto;padding:0 .5rem .5rem}
.cat{font-size:.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin:.6rem .25rem .25rem;padding:0 .25rem}
.p-item{display:flex;align-items:center;gap:.35rem;padding:.3rem .45rem;margin-bottom:1px;border-radius:4px;cursor:grab;font-size:.7rem;color:var(--text-muted);transition:all .1s;user-select:none}
.p-item:hover{background:var(--bg-card);color:var(--text)}
.p-icon{width:18px;height:18px;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:.5rem;font-weight:700;flex-shrink:0}
.canvas-wrap{flex:1;position:relative;overflow:hidden;background:var(--bg);background-image:linear-gradient(rgba(88,166,255,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(88,166,255,.04) 1px,transparent 1px);background-size:20px 20px;cursor:grab}
.canvas-wrap:active{cursor:grabbing}
.viewport{position:absolute;top:0;left:0;transform-origin:0 0;width:6000px;height:4000px}
.node{position:absolute;min-width:120px;min-height:40px;background:var(--bg-card);border:2px solid var(--border);border-radius:6px;padding:.35rem .5rem;cursor:move;user-select:none;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:.7rem;color:var(--text-muted);z-index:10;transition:box-shadow .15s,border-color .15s}
.node:hover{box-shadow:0 0 20px rgba(88,166,255,.15);z-index:11}
.node.selected{border-color:var(--accent)!important;box-shadow:0 0 25px rgba(88,166,255,.3);z-index:12}
.node .nl{font-weight:600;font-size:.7rem;color:var(--text);text-align:center;line-height:1.2}
.node .ns{font-size:.6rem;color:var(--text-muted);line-height:1.2}
.node .rm{position:absolute;top:-6px;right:-6px;width:14px;height:14px;border-radius:50%;background:#3d1214;border:1px solid var(--danger);color:var(--danger);display:none;align-items:center;justify-content:center;font-size:.45rem;cursor:pointer}
.node:hover .rm{display:flex}
.node .port{width:9px;height:9px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);position:absolute;cursor:crosshair;z-index:5;transition:all .15s}
.node .port:hover{background:var(--accent);transform:scale(1.4)}
.port-in{left:-5px;top:50%;transform:translateY(-50%)}
.port-out{right:-5px;top:50%;transform:translateY(-50%)}
.props{width:240px;background:var(--bg-nav);border-left:1px solid var(--border);display:none;flex-direction:column;padding:.75rem;flex-shrink:0}
.props.open{display:flex}
.props h3{font-size:.65rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.75rem}
.pg{margin-bottom:.5rem}
.pg label{font-size:.65rem;color:var(--text-muted);display:block;margin-bottom:.15rem}
.pg input,.pg select{width:100%;padding:.3rem .45rem;font-size:.7rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)}
svg.conn{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:4}
.zoom{position:absolute;bottom:1rem;right:1rem;background:var(--bg-nav);border:1px solid var(--border);border-radius:6px;padding:.25rem .5rem;font-size:.65rem;color:var(--text-muted);z-index:30}
.stats{font-size:.6rem;color:var(--text-muted);padding:.4rem .5rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="nav">
<span class="nav-brand"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> Mek-Tech Designer</span>
<div class="nav-sep"></div>
<input id="diagramName" placeholder="Diagram naam...">
<select id="clientSelect"><option value="">Cliënt...</option></select>
<button class="btn btn-primary" onclick="save()">Opslaan</button>
<button class="btn btn-ghost" onclick="undo()">↩</button>
<button class="btn btn-ghost" onclick="redo()">↪</button>
<button class="btn btn-ghost" onclick="fit()">⊞</button>
<span class="nav-status" id="status"></span>
</div>
<div class="layout">
<div class="sidebar">
<input id="paletteSearch" placeholder="Zoek component..." oninput="filterPalette(this.value)">
<div class="palette" id="palette"></div>
<div class="stats" id="stats">0 componenten · 0 connecties</div>
</div>
<div class="canvas-wrap" id="wrap">
<div class="viewport" id="viewport">
<svg class="conn" id="svg"></svg>
<div id="canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></div>
</div>
<div class="zoom" id="zoom">100%</div>
</div>
<div class="props" id="props">
<h3>Eigenschappen</h3>
<div class="pg"><label>Label</label><input id="pLabel" onchange="up('label',this.value)"></div>
<div class="pg"><label>Subtitel</label><input id="pSub" onchange="up('sub',this.value)"></div>
<div class="pg"><label>Type</label><select id="pType" onchange="up('type',this.value)"></select></div>
<div class="pg"><label>Breedte</label><input id="pWidth" type="number" min="100" max="400" onchange="up('w',+this.value)"></div>
<button style="margin-top:auto;padding:.4rem;background:#3d1214;color:var(--danger);border:1px solid var(--danger);border-radius:4px;cursor:pointer;font-size:.7rem" onclick="delNode()">Verwijder</button>
</div>
</div>
<script>
const DIAGRAM_ID = '{{ diagram_id }}';
const NODES_JSON = {{ nodes_json | safe }};
const EDGES_JSON = {{ edges_json | safe }};
const NODE_TYPES = {
'source-db':{label:'Database',color:'#d29922',css:'bd-src'},'source-api':{label:'API Service',color:'#d29922',css:'bd-src'},
'source-iot':{label:'IoT Sensor',color:'#d29922',css:'bd-src'},'source-stream':{label:'Streaming',color:'#d29922',css:'bd-src'},
'ingest-kafka':{label:'Kafka',color:'#58a6ff',css:'bd-ing'},'ingest-airbyte':{label:'Airbyte',color:'#58a6ff',css:'bd-ing'},
'ingest-flink':{label:'Spark/Flink',color:'#58a6ff',css:'bd-ing'},'storage-minio':{label:'MinIO/S3',color:'#3fb950',css:'bd-sto'},
'storage-iceberg':{label:'Iceberg',color:'#3fb950',css:'bd-sto'},'storage-warehouse':{label:'Data Warehouse',color:'#3fb950',css:'bd-sto'},
'storage-lake':{label:'Lakehouse',color:'#3fb950',css:'bd-sto'},'compute-spark':{label:'Apache Spark',color:'#bc8cff',css:'bd-comp'},
'compute-trino':{label:'Trino/Presto',color:'#bc8cff',css:'bd-comp'},'compute-k8s':{label:'Kubernetes',color:'#bc8cff',css:'bd-comp'},
'compute-dbt':{label:'dbt',color:'#bc8cff',css:'bd-comp'},'ai-ollama':{label:'Ollama/vLLM',color:'#f85149',css:'bd-ai'},
'ai-mlflow':{label:'MLflow',color:'#f85149',css:'bd-ai'},'ai-vector':{label:'Vector Store',color:'#f85149',css:'bd-ai'},
'ai-model':{label:'Model Serving',color:'#f85149',css:'bd-ai'},'queue-kafka':{label:'Kafka Queue',color:'#79c0ff',css:'bd-q'},
'queue-rabbit':{label:'RabbitMQ',color:'#79c0ff',css:'bd-q'},'network-switch':{label:'Switch',color:'#7ee787',css:'bd-net'},
'network-router':{label:'Router',color:'#7ee787',css:'bd-net'},'network-fw':{label:'Firewall',color:'#7ee787',css:'bd-net'},
'network-lb':{label:'Load Balancer',color:'#7ee787',css:'bd-net'},'security-iam':{label:'IAM/Auth',color:'#f778ba',css:'bd-sec'},
'security-vault':{label:'Vault',color:'#f778ba',css:'bd-sec'},'security-grafana':{label:'Grafana',color:'#f778ba',css:'bd-sec'}
};
const CATS = [['Data Bronnen',['source-db','source-api','source-iot','source-stream']],['Ingest',['ingest-kafka','ingest-airbyte','ingest-flink']],['Opslag',['storage-minio','storage-iceberg','storage-warehouse']],['Compute',['compute-spark','compute-trino','compute-k8s','compute-dbt']],['AI/ML',['ai-ollama','ai-mlflow','ai-vector','ai-model']],['Messaging',['queue-kafka','queue-rabbit']],['Netwerk',['network-switch','network-router','network-fw','network-lb']],['Security',['security-iam','security-vault','security-grafana']]];
</script>
<script src="/python-editor/static/editor.js"></script>
</body>
</html>'''
@app.route('/python-editor/')
def index():
diagram_id = request.args.get('id')
nodes_json = '[]'
edges_json = '[]'
if diagram_id:
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT nodes FROM data_diagrams WHERE id=?', (diagram_id,))
row = c.fetchone()
conn.close()
if row:
data = json.loads(row[0])
nodes_json = json.dumps(data.get('nodes', []))
edges_json = json.dumps(data.get('edges', []))
except: pass
return render_template_string(HTML, diagram_id=diagram_id or '', nodes_json=nodes_json, edges_json=edges_json)
@app.route('/python-editor/api/clients')
def api_clients():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, name FROM clients ORDER BY name')
clients = [{'id': r[0], 'name': r[1]} for r in c.fetchall()]
conn.close()
return jsonify(clients)
@app.route('/python-editor/api/save', methods=['POST'])
def api_save():
data = request.get_json()
name = data.get('name')
client_id = data.get('client_id')
nodes = json.dumps(data.get('nodes', []))
edges = json.dumps(data.get('edges', []))
diagram_id = data.get('id')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if diagram_id:
c.execute('UPDATE data_diagrams SET name=?, client_id=?, nodes=?, edges=?, updated_at=datetime(\'now\') WHERE id=?',
(name, client_id, nodes, edges, diagram_id))
else:
c.execute('INSERT INTO data_diagrams (client_id, name, nodes, edges, diagram_type) VALUES (?,?,?,?,?)',
(client_id, name, nodes, edges, 'data_architecture'))
diagram_id = c.lastrowid
conn.commit()
conn.close()
return jsonify({'id': diagram_id})
if __name__ == '__main__':
port = int(os.environ.get('PY_PORT', 3001))
app.run(host='0.0.0.0', port=port, debug=False)
+1
View File
@@ -0,0 +1 @@
flask>=3.0
+333
View File
@@ -0,0 +1,333 @@
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();