From 77e2fdca14ed093836a61ec07ce80d4d8339dece Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 22:45:10 +0200 Subject: [PATCH] feat: full feature set - filters, context menu, patchbay, position persistence Backend: - GET/PUT /api/positions - server-side node position persistence (~/.config/pwweb/) - GET/PUT /api/patchbay - save/load connection snapshots - POST /api/patchbay/apply - apply saved connections Frontend (custom SVG canvas): - Right-click wire to disconnect (context menu) - Port type filter toolbar (audio/midi/video/other toggles) - Save/Load patchbay buttons - Node positions persisted to localStorage + server - Node border color by mode (green=output, red=input) - Type indicator in node header [audio] [midi] - Selected wire highlight (white, thicker) - Select wire + Delete to disconnect - Drag output port to input port to connect - Pan (drag bg) and zoom (scroll wheel) - Filtered ports shown as dim dots when hidden --- frontend/src/components/GraphCanvas.svelte | 572 +++++++++++---------- src/web_server.cpp | 94 ++++ src/web_server.h | 8 +- 3 files changed, 388 insertions(+), 286 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index 3b54f17..93a702c 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -8,119 +8,51 @@ } from '../lib/stores'; import type { Node, Port, Link } from '../lib/types'; - // Viewport state + // Viewport let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 }); + let svgEl: SVGElement | null = null; + + // Interaction state let dragging = $state<{ type: string; startX: number; startY: number; origX: number; origY: number; nodeId?: string } | null>(null); let connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null); let hoveredPort = $state(null); let selectedEdge = $state(null); - let svgEl: SVGElement | null = null; + let contextMenu = $state<{ x: number; y: number; outputPortId: number; inputPortId: number } | null>(null); - // Node positions - const POS_KEY = 'pwweb_pos'; - function loadPos(): Record { - try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; } - } - function savePos(pos: Record) { - try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {} - } - let nodePositions = $state(loadPos()); + // Filters + let showAudio = $state(true); + let showMidi = $state(true); + let showVideo = $state(true); + let showOther = $state(true); - // Layout defaults - function getDefaultPositions(n: Node[]): Record { - const out = n.filter(nd => nd.mode === 'output'); - const inp = n.filter(nd => nd.mode === 'input'); - const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input'); - const result: Record = {}; - let i = 0; - for (const nd of out) { - if (!result[nd.id]) result[nd.id] = { x: 0, y: (i % 8) * 100 }; - i++; - } - i = 0; - for (const nd of other) { - if (!result[nd.id]) result[nd.id] = { x: 320, y: (i % 8) * 100 }; - i++; - } - i = 0; - for (const nd of inp) { - if (!result[nd.id]) result[nd.id] = { x: 640, y: (i % 8) * 100 }; - i++; - } - return result; - } + // Patchbay + let patchbayName = $state(''); - // Compute layout - let graphNodes = $derived.by(() => { - const n = $nodes; - const p = $ports; - const portMap = new Map(); - for (const port of p) portMap.set(port.id, port); + // Positions + let nodePositions = $state>({}); + const POS_KEY = 'pwweb_positions'; - const defaults = getDefaultPositions(n); - const pos = { ...defaults, ...nodePositions }; - - return n.map(nd => { - const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[]; - const inPorts = ndPorts.filter(pp => pp.mode === 'input'); - const outPorts = ndPorts.filter(pp => pp.mode === 'output'); - const nodePos = pos[String(nd.id)] || { x: 0, y: 0 }; - - // Compute node dimensions - const maxPorts = Math.max(inPorts.length, outPorts.length, 1); - const headerH = 24; - const portH = 18; - const height = headerH + maxPorts * portH + 6; - const width = 200; - - // Compute port positions - const portPositions = new Map(); - let yi = headerH; - for (const port of inPorts) { - portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 }); - yi += portH; + function loadPositions() { + // Try localStorage first + try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; } + // Also load from server + fetch('/api/positions').then(r => r.json()).then(data => { + if (data && typeof data === 'object' && !data.error) { + nodePositions = { ...data, ...nodePositions }; } - let yo = headerH; - for (const port of outPorts) { - portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 }); - yo += portH; - } - - return { - ...nd, - x: nodePos.x, - y: nodePos.y, - width, - height, - inPorts, - outPorts, - portPositions, - }; - }); - }); - - let graphLinks = $derived.by(() => { - const l = $links; - const p = $ports; - const portMap = new Map(); - for (const port of p) portMap.set(port.id, port); - - return l.map(link => { - const outPort = portMap.get(link.output_port_id); - const inPort = portMap.get(link.input_port_id); - return { ...link, outPort, inPort }; - }).filter(l => l.outPort && l.inPort) as Array; - }); - - // Port positions lookup across all nodes - function getPortPos(portId: number): { x: number; y: number } | null { - for (const node of graphNodes) { - const pos = node.portPositions.get(portId); - if (pos) return pos; - } - return null; + }).catch(() => {}); } + function savePositions() { + try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {} + fetch('/api/positions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(nodePositions), + }).catch(() => {}); + } + + // Helpers function portColor(pt: string): string { switch (pt) { case 'audio': return '#4a9'; @@ -135,75 +67,137 @@ return idx >= 0 ? name.substring(idx + 1) : name; } - // Bezier curve between two points + function isPortVisible(pt: string): boolean { + switch (pt) { + case 'audio': return showAudio; + case 'midi': return showMidi; + case 'video': return showVideo; + default: return showOther; + } + } + function bezierPath(x1: number, y1: number, x2: number, y2: number): string { - const dx = Math.abs(x2 - x1) * 0.5; + const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30); return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; } - // Mouse events function svgPoint(e: MouseEvent): { x: number; y: number } { if (!svgEl) return { x: e.clientX, y: e.clientY }; const pt = (svgEl as any).createSVGPoint(); - pt.x = e.clientX; - pt.y = e.clientY; + pt.x = e.clientX; pt.y = e.clientY; const ctm = (svgEl as SVGGraphicsElement).getScreenCTM(); if (!ctm) return { x: e.clientX, y: e.clientY }; const svgP = pt.matrixTransform(ctm.inverse()); return { x: svgP.x, y: svgP.y }; } + // Computed layout + let graphNodes = $derived.by(() => { + const n = $nodes; + const p = $ports; + const portMap = new Map(); + for (const port of p) portMap.set(port.id, port); + + const out = n.filter(nd => nd.mode === 'output'); + const inp = n.filter(nd => nd.mode === 'input'); + const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input'); + + const defaults: Record = {}; + let i = 0; + for (const nd of out) { defaults[String(nd.id)] = { x: 0, y: (i % 10) * 90 }; i++; } + i = 0; + for (const nd of other) { defaults[String(nd.id)] = { x: 280, y: (i % 10) * 90 }; i++; } + i = 0; + for (const nd of inp) { defaults[String(nd.id)] = { x: 560, y: (i % 10) * 90 }; i++; } + + const pos = { ...defaults, ...nodePositions }; + + return n.map(nd => { + const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[]; + const inPorts = ndPorts.filter(pp => pp.mode === 'input' && isPortVisible(pp.port_type)); + const outPorts = ndPorts.filter(pp => pp.mode === 'output' && isPortVisible(pp.port_type)); + const allInPorts = ndPorts.filter(pp => pp.mode === 'input'); + const allOutPorts = ndPorts.filter(pp => pp.mode === 'output'); + + const nodePos = pos[String(nd.id)] || { x: 0, y: 0 }; + const headerH = 22; + const portH = 16; + const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1); + const height = headerH + maxPorts * portH + 4; + const width = 220; + + const portPositions = new Map(); + let yi = headerH; + for (const port of allInPorts) { + portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 }); + yi += portH; + } + let yo = headerH; + for (const port of allOutPorts) { + portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 }); + yo += portH; + } + + return { ...nd, x: nodePos.x, y: nodePos.y, width, height, inPorts: allInPorts, outPorts: allOutPorts, portPositions }; + }); + }); + + let graphLinks = $derived.by(() => { + const l = $links; + const p = $ports; + const portMap = new Map(); + for (const port of p) portMap.set(port.id, port); + return l.map(link => { + const outPort = portMap.get(link.output_port_id); + const inPort = portMap.get(link.input_port_id); + return { ...link, outPort, inPort }; + }).filter(l => l.outPort && l.inPort && isPortVisible(l.outPort!.port_type)) as Array; + }); + + function getPortPos(portId: number): { x: number; y: number } | null { + for (const node of graphNodes) { + const pos = node.portPositions.get(portId); + if (pos) return pos; + } + return null; + } + + // Mouse handlers function onMouseDown(e: MouseEvent) { + contextMenu = null; + if (e.button === 2) return; // right-click handled by oncontextmenu const pt = svgPoint(e); const target = e.target as HTMLElement; - // Check if clicking on a port circle if (target.classList.contains('port-circle')) { const portId = Number(target.dataset.portId); const port = $portById.get(portId); if (port && port.mode === 'output') { const pos = getPortPos(portId); if (pos) { - connecting = { - outputPortId: portId, - outputX: pos.x, - outputY: pos.y, - mouseX: pt.x, - mouseY: pt.y, - portType: port.port_type, - }; + connecting = { outputPortId: portId, outputX: pos.x, outputY: pos.y, mouseX: pt.x, mouseY: pt.y, portType: port.port_type }; } } return; } - // Check if clicking on a node header (to drag) if (target.classList.contains('node-header') || target.closest('.node-group')) { const nodeGroup = target.closest('.node-group') as HTMLElement; if (nodeGroup) { const nodeId = nodeGroup.dataset.nodeId!; const nd = graphNodes.find(n => String(n.id) === nodeId); if (nd) { - dragging = { - type: 'node', - startX: pt.x, - startY: pt.y, - origX: nd.x, - origY: nd.y, - nodeId, - }; + dragging = { type: 'node', startX: pt.x, startY: pt.y, origX: nd.x, origY: nd.y, nodeId }; } return; } } - // Check if clicking on an edge if (target.classList.contains('edge-path')) { selectedEdge = target.dataset.edgeId || null; return; } - // Click on background: pan selectedEdge = null; dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y }; } @@ -213,26 +207,19 @@ const pt = svgPoint(e); if (connecting) { - connecting.mouseX = pt.x; - connecting.mouseY = pt.y; + connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y }; return; } - if (!dragging) return; if (dragging.type === 'node' && dragging.nodeId) { const dx = pt.x - dragging.startX; const dy = pt.y - dragging.startY; - nodePositions[dragging.nodeId] = { - x: dragging.origX + dx, - y: dragging.origY + dy, - }; - nodePositions = nodePositions; // trigger reactivity + nodePositions[dragging.nodeId] = { x: dragging.origX + dx, y: dragging.origY + dy }; } else if (dragging.type === 'pan') { const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1)); const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1)); - viewBox.x = dragging.origX - dx; - viewBox.y = dragging.origY - dy; + viewBox = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy }; } } @@ -248,11 +235,9 @@ } connecting = null; } - if (dragging?.type === 'node' && dragging.nodeId) { - savePos(nodePositions); + savePositions(); } - dragging = null; } @@ -260,47 +245,83 @@ e.preventDefault(); const pt = svgPoint(e); const factor = e.deltaY > 0 ? 1.1 : 0.9; - const nw = viewBox.w * factor; - const nh = viewBox.h * factor; - viewBox.x = pt.x - (pt.x - viewBox.x) * factor; - viewBox.y = pt.y - (pt.y - viewBox.y) * factor; - viewBox.w = nw; - viewBox.h = nh; + viewBox = { + x: pt.x - (pt.x - viewBox.x) * factor, + y: pt.y - (pt.y - viewBox.y) * factor, + w: viewBox.w * factor, + h: viewBox.h * factor, + }; } - function onEdgeDblClick(e: MouseEvent) { + function onContextMenu(e: MouseEvent) { + e.preventDefault(); const target = e.target as HTMLElement; if (target.classList.contains('edge-path')) { const edgeId = target.dataset.edgeId; const link = $links.find(l => String(l.id) === edgeId); if (link) { - disconnectPorts(link.output_port_id, link.input_port_id); + selectedEdge = edgeId || null; + contextMenu = { x: e.clientX, y: e.clientY, outputPortId: link.output_port_id, inputPortId: link.input_port_id }; } } } + function doDisconnect() { + if (!contextMenu) return; + disconnectPorts(contextMenu.outputPortId, contextMenu.inputPortId); + contextMenu = null; + } + function onKey(e: KeyboardEvent) { if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) { const link = $links.find(l => String(l.id) === selectedEdge); - if (link) { - disconnectPorts(link.output_port_id, link.input_port_id); - selectedEdge = null; - } + if (link) { disconnectPorts(link.output_port_id, link.input_port_id); selectedEdge = null; } } } - onMount(() => { initGraph(); }); + // Patchbay + async function savePatchbay() { + const name = patchbayName.trim() || new Date().toISOString().replace(/[:.]/g, '-'); + const data = { name, connections: $links.map(l => ({ output_port_id: l.output_port_id, input_port_id: l.input_port_id })) }; + await fetch('/api/patchbay', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); + patchbayName = name; + } + + async function loadPatchbay() { + try { + const res = await fetch('/api/patchbay'); + const data = await res.json(); + if (data.connections && data.connections.length > 0) { + for (const conn of data.connections) { + await fetch('/api/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(conn) }); + } + } + } catch {} + } + + onMount(() => { initGraph(); loadPositions(); }); onDestroy(() => { destroyGraph(); });
-
+
{$connected ? 'Connected' : 'Disconnected'} - {$nodes.length} nodes | {$ports.length} ports | {$links.length} links - Drag port->port to connect. Dbl-click wire to disconnect. Scroll to zoom. Drag bg to pan. + + + + + + + + + + + + + {$nodes.length} nodes | {$ports.length} ports | {$links.length} links
@@ -311,9 +332,15 @@ onmousemove={onMouseMove} onmouseup={onMouseUp} onwheel={onWheel} - ondblclick={onEdgeDblClick} + oncontextmenu={onContextMenu} class="canvas" > + + + + + + {#each graphLinks as link (link.id)} {@const outPos = getPortPos(link.output_port_id)} @@ -323,15 +350,16 @@ {/if} {/each} - + {#if connecting} {/if} {#each graphNodes as nd (nd.id)} - {@const headerColor = nd.mode === 'output' ? '#2a3a2a' : nd.mode === 'input' ? '#3a2a2a' : '#2a2a3a'} - {@const borderColor = nd.mode === 'output' ? '#4a9' : nd.mode === 'input' ? '#a44' : '#555'} + {@const isSource = nd.mode === 'output'} + {@const isSink = nd.mode === 'input'} + {@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'} + {@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'} + {@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'} - - - - - - - - - {nd.name.length > 28 ? nd.name.substring(0, 25) + '...' : nd.name} + + + + + + {nd.nick || nd.name} + + + [{nd.node_type}] - {#each nd.inPorts as port, i (port.id)} - {@const py = nd.y + 24 + i * 18 + 9} - { hoveredPort = port.id; }} - onmouseleave={() => { hoveredPort = null; }} - /> - {shortName(port.name)} + {@const py = nd.y + 22 + i * 16 + 8} + {#if isPortVisible(port.port_type)} + { hoveredPort = port.id; }} + onmouseleave={() => { hoveredPort = null; }} + /> + + {shortName(port.name)} + + {:else} + + {/if} {/each} - {#each nd.outPorts as port, i (port.id)} - {@const py = nd.y + 24 + i * 18 + 9} - {shortName(port.name)} - { hoveredPort = port.id; }} - onmouseleave={() => { hoveredPort = null; }} - /> + {@const py = nd.y + 22 + i * 16 + 8} + {#if isPortVisible(port.port_type)} + + {shortName(port.name)} + + { hoveredPort = port.id; }} + onmouseleave={() => { hoveredPort = null; }} + /> + {:else} + + {/if} {/each} {/each} + + {#if contextMenu} + + {/if}
diff --git a/src/web_server.cpp b/src/web_server.cpp index b33fd10..63e2cd2 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -4,9 +4,40 @@ #include #include #include +#include +#include +#include using namespace pwgraph; +// ============================================================================ +// File I/O helpers +// ============================================================================ + +std::string WebServer::dataDir() const { + const char *home = getenv("HOME"); + if (!home) { + auto *pw = getpwuid(getuid()); + if (pw) home = pw->pw_dir; + } + std::string dir = (home ? home : "/tmp"); + dir += "/.config/pwweb"; + mkdir(dir.c_str(), 0755); + return dir; +} + +std::string WebServer::readFile(const std::string &path) const { + std::ifstream f(path); + if (!f.is_open()) return "{}"; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +void WebServer::writeFile(const std::string &path, const std::string &data) const { + std::ofstream f(path); + if (f.is_open()) f << data; +} + // ============================================================================ // JSON serialization helpers // ============================================================================ @@ -309,6 +340,69 @@ void WebServer::setupRoutes() { }; m_http.Options("/api/connect", cors_handler); m_http.Options("/api/disconnect", cors_handler); + m_http.Options("/api/positions", cors_handler); + m_http.Options("/api/patchbay", cors_handler); + + // Positions persistence: GET /api/positions + m_http.Get("/api/positions", [this](const httplib::Request &, httplib::Response &res) { + std::string path = dataDir() + "/positions.json"; + res.set_content(readFile(path), "application/json"); + res.set_header("Access-Control-Allow-Origin", "*"); + }); + + // Positions persistence: PUT /api/positions + m_http.Put("/api/positions", [this](const httplib::Request &req, httplib::Response &res) { + std::string path = dataDir() + "/positions.json"; + writeFile(path, req.body); + res.set_content("{\"ok\":true}", "application/json"); + res.set_header("Access-Control-Allow-Origin", "*"); + }); + + // Patchbay: GET /api/patchbay + m_http.Get("/api/patchbay", [this](const httplib::Request &, httplib::Response &res) { + std::string path = dataDir() + "/patchbay.json"; + res.set_content(readFile(path), "application/json"); + res.set_header("Access-Control-Allow-Origin", "*"); + }); + + // Patchbay: PUT /api/patchbay (save current connections as a named snapshot) + m_http.Put("/api/patchbay", [this](const httplib::Request &req, httplib::Response &res) { + std::string path = dataDir() + "/patchbay.json"; + writeFile(path, req.body); + res.set_content("{\"ok\":true}", "application/json"); + res.set_header("Access-Control-Allow-Origin", "*"); + }); + + // Patchbay: POST /api/patchbay/apply (apply saved connections to current graph) + m_http.Post("/api/patchbay/apply", [this](const httplib::Request &req, httplib::Response &res) { + // req.body is a JSON array of {output_port_id, input_port_id} pairs + // We just parse it and connect each pair + // Simple parsing: find all pairs + std::string body = req.body; + size_t pos = 0; + int connected = 0; + while (pos < body.size()) { + uint32_t out_id = 0, in_id = 0; + int chars = 0; + if (sscanf(body.c_str() + pos, + "{\"output_port_id\":%u,\"input_port_id\":%u}%n", + &out_id, &in_id, &chars) == 2 || + sscanf(body.c_str() + pos, + "{\"output_port_id\":%u, \"input_port_id\":%u}%n", + &out_id, &in_id, &chars) == 2) + { + if (m_engine.connectPorts(out_id, in_id)) + connected++; + } + pos = body.find('{', pos + 1); + if (pos == std::string::npos) break; + } + if (connected > 0) broadcastGraph(); + char buf[64]; + snprintf(buf, sizeof(buf), "{\"ok\":true,\"connected\":%d}", connected); + res.set_content(buf, "application/json"); + res.set_header("Access-Control-Allow-Origin", "*"); + }); } // end of web_server.cpp diff --git a/src/web_server.h b/src/web_server.h index 2602a23..cb689f9 100644 --- a/src/web_server.h +++ b/src/web_server.h @@ -6,7 +6,7 @@ #include #include #include -#include +#include namespace pwgraph { @@ -17,13 +17,14 @@ public: bool start(); void stop(); - - // Broadcast graph update to all connected SSE clients void broadcastGraph(); private: void setupRoutes(); std::string buildGraphJson() const; + std::string dataDir() const; + std::string readFile(const std::string &path) const; + void writeFile(const std::string &path, const std::string &data) const; GraphEngine &m_engine; int m_port; @@ -31,7 +32,6 @@ private: std::thread m_thread; std::atomic m_running; - // SSE clients mutable std::mutex m_sse_mutex; std::set m_sse_clients; };