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
This commit is contained in:
@@ -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<number | null>(null);
|
||||
let selectedEdge = $state<string | null>(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<string, { x: number; y: number }> {
|
||||
try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; }
|
||||
}
|
||||
function savePos(pos: Record<string, { x: number; y: number }>) {
|
||||
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<string, { x: number; y: number }> {
|
||||
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<string, { x: number; y: number }> = {};
|
||||
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<number, Port>();
|
||||
for (const port of p) portMap.set(port.id, port);
|
||||
// Positions
|
||||
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
|
||||
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<number, { x: number; y: number }>();
|
||||
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<number, Port>();
|
||||
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<Link & { outPort: Port; inPort: Port }>;
|
||||
});
|
||||
|
||||
// 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<number, Port>();
|
||||
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<string, { x: number; y: number }> = {};
|
||||
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<number, { x: number; y: number }>();
|
||||
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<number, Port>();
|
||||
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<Link & { outPort: Port; inPort: Port }>;
|
||||
});
|
||||
|
||||
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(); });
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="wrap">
|
||||
<div class="bar">
|
||||
<div class="toolbar">
|
||||
<span class="dot" class:on={$connected}></span>
|
||||
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
|
||||
<span class="st">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
|
||||
<span class="help">Drag port->port to connect. Dbl-click wire to disconnect. Scroll to zoom. Drag bg to pan.</span>
|
||||
|
||||
<span class="sep"></span>
|
||||
<label class="filter" class:active={showAudio}><input type="checkbox" bind:checked={showAudio} /> <span class="fc audio"></span> Audio</label>
|
||||
<label class="filter" class:active={showMidi}><input type="checkbox" bind:checked={showMidi} /> <span class="fc midi"></span> MIDI</label>
|
||||
<label class="filter" class:active={showVideo}><input type="checkbox" bind:checked={showVideo} /> <span class="fc video"></span> Video</label>
|
||||
<label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
|
||||
|
||||
<span class="sep"></span>
|
||||
<button onclick={savePatchbay} title="Save current connections">Save Patchbay</button>
|
||||
<button onclick={loadPatchbay} title="Load saved connections">Load Patchbay</button>
|
||||
<input class="pbname" bind:value={patchbayName} placeholder="patchbay name" />
|
||||
|
||||
<span class="stats">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -311,9 +332,15 @@
|
||||
onmousemove={onMouseMove}
|
||||
onmouseup={onMouseUp}
|
||||
onwheel={onWheel}
|
||||
ondblclick={onEdgeDblClick}
|
||||
oncontextmenu={onContextMenu}
|
||||
class="canvas"
|
||||
>
|
||||
<defs>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="1" dy="1" stdDeviation="2" flood-opacity="0.4" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Edges -->
|
||||
{#each graphLinks as link (link.id)}
|
||||
{@const outPos = getPortPos(link.output_port_id)}
|
||||
@@ -323,15 +350,16 @@
|
||||
<path
|
||||
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
|
||||
stroke={selectedEdge === String(link.id) ? '#fff' : color}
|
||||
stroke-width={selectedEdge === String(link.id) ? 3 : 2}
|
||||
stroke-width={selectedEdge === String(link.id) ? 3.5 : 2}
|
||||
fill="none"
|
||||
class="edge-path"
|
||||
data-edge-id={String(link.id)}
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Connecting line (while dragging) -->
|
||||
<!-- Connecting line -->
|
||||
{#if connecting}
|
||||
<path
|
||||
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
|
||||
@@ -339,158 +367,138 @@
|
||||
stroke-width="2"
|
||||
stroke-dasharray="6 3"
|
||||
fill="none"
|
||||
opacity="0.8"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#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'}
|
||||
|
||||
<g class="node-group" data-node-id={String(nd.id)}>
|
||||
<!-- Background -->
|
||||
<rect
|
||||
x={nd.x} y={nd.y}
|
||||
width={nd.width} height={nd.height}
|
||||
rx="5" ry="5"
|
||||
fill="#1e1e2e"
|
||||
stroke={borderColor}
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<rect
|
||||
x={nd.x} y={nd.y}
|
||||
width={nd.width} height="24"
|
||||
rx="5" ry="5"
|
||||
fill={headerColor}
|
||||
class="node-header"
|
||||
/>
|
||||
<rect
|
||||
x={nd.x} y={nd.y + 18}
|
||||
width={nd.width} height="6"
|
||||
fill={headerColor}
|
||||
/>
|
||||
<text
|
||||
x={nd.x + 6} y={nd.y + 16}
|
||||
font-size="11" font-family="monospace"
|
||||
fill="#ddd" font-weight="bold"
|
||||
>
|
||||
{nd.name.length > 28 ? nd.name.substring(0, 25) + '...' : nd.name}
|
||||
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
|
||||
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
|
||||
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
|
||||
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
|
||||
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
|
||||
{nd.nick || nd.name}
|
||||
</text>
|
||||
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
|
||||
[{nd.node_type}]
|
||||
</text>
|
||||
|
||||
<!-- Input ports (left side) -->
|
||||
{#each nd.inPorts as port, i (port.id)}
|
||||
{@const py = nd.y + 24 + i * 18 + 9}
|
||||
<circle
|
||||
cx={nd.x} cy={py}
|
||||
r="5"
|
||||
fill={portColor(port.port_type)}
|
||||
stroke="#fff"
|
||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||
class="port-circle"
|
||||
data-port-id={String(port.id)}
|
||||
onmouseenter={() => { hoveredPort = port.id; }}
|
||||
onmouseleave={() => { hoveredPort = null; }}
|
||||
/>
|
||||
<text
|
||||
x={nd.x + 10} y={py + 4}
|
||||
font-size="10" font-family="monospace"
|
||||
fill="#aaa"
|
||||
>{shortName(port.name)}</text>
|
||||
{@const py = nd.y + 22 + i * 16 + 8}
|
||||
{#if isPortVisible(port.port_type)}
|
||||
<circle
|
||||
cx={nd.x} cy={py} r="4"
|
||||
fill={portColor(port.port_type)}
|
||||
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||
class="port-circle"
|
||||
data-port-id={String(port.id)}
|
||||
onmouseenter={() => { hoveredPort = port.id; }}
|
||||
onmouseleave={() => { hoveredPort = null; }}
|
||||
/>
|
||||
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">
|
||||
{shortName(port.name)}
|
||||
</text>
|
||||
{:else}
|
||||
<circle cx={nd.x} cy={py} r="3" fill="#333" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Output ports (right side) -->
|
||||
{#each nd.outPorts as port, i (port.id)}
|
||||
{@const py = nd.y + 24 + i * 18 + 9}
|
||||
<text
|
||||
x={nd.x + nd.width - 10} y={py + 4}
|
||||
font-size="10" font-family="monospace"
|
||||
fill="#aaa"
|
||||
text-anchor="end"
|
||||
>{shortName(port.name)}</text>
|
||||
<circle
|
||||
cx={nd.x + nd.width} cy={py}
|
||||
r="5"
|
||||
fill={portColor(port.port_type)}
|
||||
stroke="#fff"
|
||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||
class="port-circle"
|
||||
data-port-id={String(port.id)}
|
||||
onmouseenter={() => { hoveredPort = port.id; }}
|
||||
onmouseleave={() => { hoveredPort = null; }}
|
||||
/>
|
||||
{@const py = nd.y + 22 + i * 16 + 8}
|
||||
{#if isPortVisible(port.port_type)}
|
||||
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">
|
||||
{shortName(port.name)}
|
||||
</text>
|
||||
<circle
|
||||
cx={nd.x + nd.width} cy={py} r="4"
|
||||
fill={portColor(port.port_type)}
|
||||
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||
class="port-circle"
|
||||
data-port-id={String(port.id)}
|
||||
onmouseenter={() => { hoveredPort = port.id; }}
|
||||
onmouseleave={() => { hoveredPort = null; }}
|
||||
/>
|
||||
{:else}
|
||||
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
{#if contextMenu}
|
||||
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
|
||||
<button onclick={doDisconnect}>Disconnect</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
||||
.canvas { width: 100%; height: 100%; display: block; cursor: default; }
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
.toolbar {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(16, 16, 24, 0.97);
|
||||
border-bottom: 1px solid #333;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #a44;
|
||||
display: inline-block;
|
||||
font-size: 11px; color: #aaa; font-family: monospace;
|
||||
}
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
|
||||
.dot.on { background: #4a9; }
|
||||
.sep { width: 1px; height: 16px; background: #444; margin: 0 4px; }
|
||||
|
||||
.st { margin-left: auto; color: #666; }
|
||||
.help { color: #555; font-size: 11px; margin-left: 16px; }
|
||||
|
||||
.port-circle {
|
||||
cursor: crosshair;
|
||||
.filter {
|
||||
display: flex; align-items: center; gap: 3px; cursor: pointer;
|
||||
padding: 2px 5px; border-radius: 3px; font-size: 10px;
|
||||
}
|
||||
.port-circle:hover {
|
||||
filter: brightness(1.5);
|
||||
.filter:hover { background: rgba(255,255,255,0.05); }
|
||||
.filter input { display: none; }
|
||||
.filter .fc { width: 8px; height: 8px; border-radius: 2px; }
|
||||
.filter .fc.audio { background: #4a9; }
|
||||
.filter .fc.midi { background: #a44; }
|
||||
.filter .fc.video { background: #49a; }
|
||||
.filter .fc.other { background: #999; }
|
||||
.filter:not(.active) { opacity: 0.4; }
|
||||
.filter:not(.active) .fc { background: #555 !important; }
|
||||
|
||||
.toolbar button {
|
||||
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444;
|
||||
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
.toolbar button:hover { background: #3a3a4e; }
|
||||
|
||||
.pbname {
|
||||
width: 100px; padding: 2px 6px; background: #1a1a2e; border: 1px solid #444;
|
||||
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
|
||||
.edge-path {
|
||||
cursor: pointer;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
.edge-path:hover {
|
||||
stroke-width: 4;
|
||||
}
|
||||
.stats { margin-left: auto; color: #555; }
|
||||
|
||||
.node-header {
|
||||
cursor: grab;
|
||||
.ctx {
|
||||
position: fixed; z-index: 100;
|
||||
background: #2a2a3e; border: 1px solid #555;
|
||||
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
}
|
||||
.node-group:hover rect {
|
||||
filter: brightness(1.1);
|
||||
.ctx button {
|
||||
display: block; width: 100%; padding: 6px 20px;
|
||||
background: none; border: none; color: #ccc;
|
||||
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
|
||||
}
|
||||
.ctx button:hover { background: #444; }
|
||||
|
||||
.port-circle { cursor: crosshair; }
|
||||
.port-circle:hover { filter: brightness(1.5); }
|
||||
.edge-path { cursor: pointer; pointer-events: stroke; }
|
||||
.edge-path:hover { stroke-width: 4; }
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,40 @@
|
||||
#include <cstdio>
|
||||
#include <algorithm>
|
||||
#include <condition_variable>
|
||||
#include <fstream>
|
||||
#include <sys/stat.h>
|
||||
#include <pwd.h>
|
||||
|
||||
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<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
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<bool> m_running;
|
||||
|
||||
// SSE clients
|
||||
mutable std::mutex m_sse_mutex;
|
||||
std::set<httplib::DataSink *> m_sse_clients;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user