Initial commit — Mek-Tech AI Consultancy Framework v2.0
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
flask>=3.0
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user