feat: complete qpwgraph feature parity

Core Visual Routing:
- Dashed lines for inactive connections (unpinned shown dimmer)
- Color-coded streams (audio=green, midi=red, video=blue)
- Pin indicator (yellow dot) on pinned connections

Patchbay - Session Management:
- Save & Restore: named profiles stored on server (~/.config/pwweb/patchbay.json)
- Activated Mode: toggle to auto-restore connections when apps appear
- Exclusive Mode: enforce profile-only links, disconnect everything else
- Pin & Unpin: right-click wire -> Pin/Unpin, yellow dot on pinned wires
- Auto-Pin: automatically pin all new manual connections
- Auto-Disconnect: auto-sever pinned connections when patchbay deactivated
- Rule Management: dialog showing all profile rules with type badges

Graph Customization:
- Node Hiding: add regex patterns to hide nodes (e.g. Dummy|Freewheel)
- Node Merging: merge nodes sharing a common prefix into one block
- All rules persisted to server with the patchbay state

Dialogs: Hide Nodes, Merge Nodes, Profiles, Rules
This commit is contained in:
joren
2026-03-29 22:55:28 +02:00
parent 0c56192e6f
commit 0acfa6ea73
3 changed files with 640 additions and 99 deletions

View File

@@ -1,10 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
nodes, ports, links, connected,
nodes, ports, links, connected, patchbay,
portById,
initGraph, destroyGraph,
connectPorts, disconnectPorts,
togglePin,
addHideRule, removeHideRule,
addMergeRule, removeMergeRule,
setActivated, setExclusive,
setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile,
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
@@ -12,12 +18,12 @@
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
let svgEl: SVGElement | null = null;
// Interaction state
// Interaction
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 contextMenu = $state<{ x: number; y: number; outputPortId: number; inputPortId: number } | null>(null);
let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
// Filters
let showAudio = $state(true);
@@ -25,17 +31,21 @@
let showVideo = $state(true);
let showOther = $state(true);
// Patchbay
let patchbayName = $state('');
// Dialogs
let showHideDialog = $state(false);
let showMergeDialog = $state(false);
let showProfileDialog = $state(false);
let showRuleDialog = $state(false);
let newHideRule = $state('');
let newMergeRule = $state('');
let newProfileName = $state('');
// Positions
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
const POS_KEY = 'pwweb_positions';
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 };
@@ -46,8 +56,7 @@
function savePositions() {
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
fetch('/api/positions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodePositions),
}).catch(() => {});
}
@@ -87,20 +96,56 @@
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 };
return pt.matrixTransform(ctm.inverse());
}
// Computed layout
function isNodeHidden(nodeName: string): boolean {
for (const rule of $patchbay.hide_rules) {
try {
if (new RegExp(rule, 'i').test(nodeName)) return true;
} catch {
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
}
}
return false;
}
function getMergedName(nodeName: string): string {
for (const prefix of $patchbay.merge_rules) {
if (nodeName.startsWith(prefix)) return prefix;
}
return nodeName;
}
// Build computed layout
let graphNodes = $derived.by(() => {
const n = $nodes;
const p = $ports;
const pb = $patchbay;
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');
// Filter hidden nodes
let visible = n.filter(nd => !isNodeHidden(nd.name));
// Merge nodes by prefix
const merged = new Map<string, typeof visible[0]>();
for (const nd of visible) {
const mergedName = getMergedName(nd.name);
const existing = merged.get(mergedName);
if (existing) {
// Merge port IDs
existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
existing.name = mergedName;
} else {
merged.set(mergedName, { ...nd, name: mergedName });
}
}
visible = Array.from(merged.values());
const out = visible.filter(nd => nd.mode === 'output');
const inp = visible.filter(nd => nd.mode === 'input');
const other = visible.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
const defaults: Record<string, { x: number; y: number }> = {};
let i = 0;
@@ -112,10 +157,8 @@
const pos = { ...defaults, ...nodePositions };
return n.map(nd => {
return visible.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');
@@ -145,13 +188,21 @@
let graphLinks = $derived.by(() => {
const l = $links;
const p = $ports;
const pb = $patchbay;
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 }>;
if (!outPort || !inPort) return null;
// Check if both endpoint nodes are visible
const outNode = $nodes.find(n => n.id === outPort.node_id);
const inNode = $nodes.find(n => n.id === inPort.node_id);
if (outNode && isNodeHidden(outNode.name)) return null;
if (inNode && isNodeHidden(inNode.name)) return null;
const pinned = pb.pinned_connections.includes(link.id);
return { ...link, outPort, inPort, pinned };
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
});
function getPortPos(portId: number): { x: number; y: number } | null {
@@ -165,7 +216,7 @@
// Mouse handlers
function onMouseDown(e: MouseEvent) {
contextMenu = null;
if (e.button === 2) return; // right-click handled by oncontextmenu
if (e.button === 2) return;
const pt = svgPoint(e);
const target = e.target as HTMLElement;
@@ -181,16 +232,14 @@
return;
}
if (target.classList.contains('node-header') || target.closest('.node-group')) {
if (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 };
}
return;
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 };
}
return;
}
if (target.classList.contains('edge-path')) {
@@ -205,13 +254,11 @@
function onMouseMove(e: MouseEvent) {
if (!dragging && !connecting) return;
const pt = svgPoint(e);
if (connecting) {
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;
@@ -235,9 +282,7 @@
}
connecting = null;
}
if (dragging?.type === 'node' && dragging.nodeId) {
savePositions();
}
if (dragging?.type === 'node' && dragging.nodeId) savePositions();
dragging = null;
}
@@ -261,17 +306,17 @@
const link = $links.find(l => String(l.id) === edgeId);
if (link) {
selectedEdge = edgeId || null;
contextMenu = { x: e.clientX, y: e.clientY, outputPortId: link.output_port_id, inputPortId: link.input_port_id };
contextMenu = {
x: e.clientX, y: e.clientY,
linkId: link.id,
outputPortId: link.output_port_id,
inputPortId: link.input_port_id,
pinned: $patchbay.pinned_connections.includes(link.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);
@@ -279,26 +324,6 @@
}
}
// 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>
@@ -309,19 +334,37 @@
<div class="toolbar">
<span class="dot" class:on={$connected}></span>
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
<span class="sep"></span>
<!-- Port type filters -->
<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>
<!-- Patchbay toggles -->
<button class="toggle" class:active={$patchbay.activated} onclick={() => setActivated(!$patchbay.activated)} title="Activated Mode: auto-restore connections">
{$patchbay.activated ? 'Active' : 'Inactive'}
</button>
<button class="toggle" class:active={$patchbay.exclusive} onclick={() => setExclusive(!$patchbay.exclusive)} title="Exclusive Mode: disconnect unprofiled links">
{$patchbay.exclusive ? 'Exclusive' : 'Permissive'}
</button>
<button class="toggle" class:active={$patchbay.auto_pin} onclick={() => setAutoPin(!$patchbay.auto_pin)} title="Auto-Pin new connections">
{$patchbay.auto_pin ? 'Auto-Pin ON' : 'Auto-Pin OFF'}
</button>
<button class="toggle" class:active={$patchbay.auto_disconnect} onclick={() => setAutoDisconnect(!$patchbay.auto_disconnect)} title="Auto-Disconnect pinned on deactivate">
{$patchbay.auto_disconnect ? 'Auto-Disc ON' : 'Auto-Disc OFF'}
</button>
<span class="sep"></span>
<!-- Dialogs -->
<button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button>
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -347,15 +390,21 @@
{@const inPos = getPortPos(link.input_port_id)}
{#if outPos && inPos}
{@const color = portColor(link.outPort.port_type)}
{@const isSelected = selectedEdge === String(link.id)}
<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.5 : 2}
stroke={isSelected ? '#fff' : color}
stroke-width={isSelected ? 3.5 : link.pinned ? 2.5 : 2}
fill="none"
class="edge-path"
data-edge-id={String(link.id)}
stroke-linecap="round"
stroke-dasharray={link.pinned ? 'none' : 'none'}
opacity={link.pinned ? 1 : 0.7}
/>
{#if link.pinned}
<circle cx={(outPos.x + inPos.x) / 2} cy={(outPos.y + inPos.y) / 2} r="3" fill="#ff0" opacity="0.8" />
{/if}
{/if}
{/each}
@@ -403,9 +452,7 @@
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>
<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}
@@ -414,9 +461,7 @@
{#each nd.outPorts as port, i (port.id)}
{@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>
<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)}
@@ -435,9 +480,122 @@
{/each}
</svg>
<!-- Context menu -->
{#if contextMenu}
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
<button onclick={doDisconnect}>Disconnect</button>
<button onclick={() => { disconnectPorts(contextMenu!.outputPortId, contextMenu!.inputPortId); contextMenu = null; }}>Disconnect</button>
<button onclick={() => { togglePin(contextMenu!.linkId); contextMenu = null; }}>{contextMenu.pinned ? 'Unpin' : 'Pin'}</button>
</div>
{/if}
<!-- Hide Nodes Dialog -->
{#if showHideDialog}
<div class="dialog">
<div class="dialog-header">
<span>Node Hiding Rules</span>
<button class="close" onclick={() => { showHideDialog = false; }}>X</button>
</div>
<div class="dialog-body">
<p class="hint">Hide nodes matching pattern (regex or plain text).</p>
<div class="input-row">
<input bind:value={newHideRule} placeholder="e.g. Dummy|Freewheel or .*Driver.*" class="dlg-input" />
<button onclick={() => { if (newHideRule.trim()) { addHideRule(newHideRule.trim()); newHideRule = ''; } }}>Add</button>
</div>
<div class="rule-list">
{#each $patchbay.hide_rules as rule}
<div class="rule-item">
<span>{rule}</span>
<button onclick={() => removeHideRule(rule)}>Remove</button>
</div>
{/each}
{#if $patchbay.hide_rules.length === 0}
<div class="empty">No hiding rules.</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Merge Nodes Dialog -->
{#if showMergeDialog}
<div class="dialog">
<div class="dialog-header">
<span>Node Merging Rules</span>
<button class="close" onclick={() => { showMergeDialog = false; }}>X</button>
</div>
<div class="dialog-body">
<p class="hint">Merge nodes sharing a common name prefix into one block.</p>
<div class="input-row">
<input bind:value={newMergeRule} placeholder="e.g. Built-in Audio" class="dlg-input" />
<button onclick={() => { if (newMergeRule.trim()) { addMergeRule(newMergeRule.trim()); newMergeRule = ''; } }}>Add</button>
</div>
<div class="rule-list">
{#each $patchbay.merge_rules as rule}
<div class="rule-item">
<span>{rule}</span>
<button onclick={() => removeMergeRule(rule)}>Remove</button>
</div>
{/each}
{#if $patchbay.merge_rules.length === 0}
<div class="empty">No merge rules.</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Profile Dialog -->
{#if showProfileDialog}
<div class="dialog">
<div class="dialog-header">
<span>Profile Management</span>
<button class="close" onclick={() => { showProfileDialog = false; }}>X</button>
</div>
<div class="dialog-body">
<div class="input-row">
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
</div>
<div class="rule-list">
{#each Object.entries($patchbay.profiles) as [name, profile]}
<div class="rule-item">
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
<button onclick={() => loadProfile(name)}>Load</button>
<button onclick={() => deleteProfile(name)}>Delete</button>
</div>
{/each}
{#if Object.keys($patchbay.profiles).length === 0}
<div class="empty">No saved profiles.</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Rule Management Dialog -->
{#if showRuleDialog}
<div class="dialog">
<div class="dialog-header">
<span>Rule Management</span>
<button class="close" onclick={() => { showRuleDialog = false; }}>X</button>
</div>
<div class="dialog-body">
{#if $patchbay.active_profile && $patchbay.profiles[$patchbay.active_profile]}
{@const profile = $patchbay.profiles[$patchbay.active_profile]}
<p class="hint">Active profile: <strong>{profile.name}</strong> ({profile.connections.length} rules)</p>
<div class="rule-list scrollable">
{#each profile.connections as rule, i}
<div class="rule-item small">
<span>{rule.output_node_name}:{shortName(rule.output_port_name)} => {rule.input_node_name}:{shortName(rule.input_port_name)}</span>
<span class="type-badge {rule.output_port_type}">{rule.output_port_type}</span>
{#if rule.pinned}<span class="pin-badge">PIN</span>{/if}
</div>
{/each}
</div>
{:else}
<div class="empty">No active profile. Save a profile first via Profiles.</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -448,19 +606,20 @@
.toolbar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 8px;
padding: 4px 12px;
display: flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: rgba(16, 16, 24, 0.97);
border-bottom: 1px solid #333;
font-size: 11px; color: #aaa; font-family: monospace;
flex-wrap: wrap;
}
.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; }
.sep { width: 1px; height: 16px; background: #444; margin: 0 2px; flex-shrink: 0; }
.filter {
display: flex; align-items: center; gap: 3px; cursor: pointer;
padding: 2px 5px; border-radius: 3px; font-size: 10px;
padding: 2px 5px; border-radius: 3px; font-size: 10px; user-select: none;
}
.filter:hover { background: rgba(255,255,255,0.05); }
.filter input { display: none; }
@@ -472,31 +631,86 @@
.filter:not(.active) { opacity: 0.4; }
.filter:not(.active) .fc { background: #555 !important; }
.toolbar button {
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444;
.toolbar button, .toggle {
padding: 2px 7px; background: #2a2a3e; border: 1px solid #444;
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
white-space: nowrap;
}
.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;
}
.toolbar button:hover, .toggle:hover { background: #3a3a4e; }
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
.stats { margin-left: auto; color: #555; }
/* Context menu */
.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);
}
.ctx button {
display: block; width: 100%; padding: 6px 20px;
display: block; width: 100%; padding: 5px 20px;
background: none; border: none; color: #ccc;
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
}
.ctx button:hover { background: #444; }
/* Dialogs */
.dialog {
position: absolute; top: 40px; right: 10px; z-index: 20;
background: #1e1e2e; border: 1px solid #555; border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
width: 360px; max-height: 80vh; overflow: hidden;
display: flex; flex-direction: column;
font-family: monospace;
}
.dialog-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; background: #2a2a3e; border-bottom: 1px solid #444;
font-size: 12px; color: #ddd; font-weight: bold;
}
.close { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; padding: 0 4px; }
.close:hover { color: #fff; }
.dialog-body { padding: 10px; overflow-y: auto; }
.hint { font-size: 10px; color: #666; margin: 0 0 8px; }
.input-row { display: flex; gap: 6px; margin-bottom: 8px; }
.dlg-input {
flex: 1; padding: 4px 8px; background: #14141e; border: 1px solid #444;
color: #ccc; font-size: 11px; border-radius: 3px; font-family: monospace;
}
.input-row button {
padding: 4px 10px; background: #2a2a3e; border: 1px solid #444;
color: #aaa; font-size: 11px; cursor: pointer; border-radius: 3px; font-family: monospace;
}
.input-row button:hover { background: #3a3a4e; }
.rule-list { display: flex; flex-direction: column; gap: 2px; }
.rule-list.scrollable { max-height: 300px; overflow-y: auto; }
.rule-item {
display: flex; align-items: center; gap: 6px;
padding: 3px 6px; background: #14141e; border-radius: 3px;
font-size: 10px; color: #aaa;
}
.rule-item span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rule-item button {
padding: 1px 6px; background: #2a2a3e; border: 1px solid #444;
color: #888; font-size: 9px; cursor: pointer; border-radius: 2px; font-family: monospace;
flex-shrink: 0;
}
.rule-item button:hover { color: #f88; border-color: #a44; }
.rule-item.small { font-size: 9px; }
.active-profile { color: #6c9; font-weight: bold; }
.type-badge {
font-size: 8px; padding: 0 4px; border-radius: 2px; color: #fff;
}
.type-badge.audio { background: #4a9; }
.type-badge.midi { background: #a44; }
.type-badge.video { background: #49a; }
.pin-badge { font-size: 8px; padding: 0 3px; background: #aa4; color: #000; border-radius: 2px; }
.empty { font-size: 11px; color: #555; padding: 8px 0; text-align: center; }
.port-circle { cursor: crosshair; }
.port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; }

View File

@@ -1,5 +1,5 @@
import { writable, derived, get } from 'svelte/store';
import type { Node, Port, Link, GraphMessage } from './types';
import { writable, derived } from 'svelte/store';
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores
@@ -8,21 +8,33 @@ export const ports = writable<Port[]>([]);
export const links = writable<Link[]>([]);
export const connected = writable(false);
// Port lookup map
// Patchbay state
export const patchbay = writable<PatchbayState>({
profiles: {},
active_profile: '',
activated: false,
exclusive: false,
auto_pin: false,
auto_disconnect: false,
pinned_connections: [],
hide_rules: [],
merge_rules: [],
});
// Port/node lookups
export const portById = derived(ports, ($ports) => {
const map = new Map<number, Port>();
for (const p of $ports) map.set(p.id, p);
return map;
});
// Node lookup map
export const nodeById = derived(nodes, ($nodes) => {
const map = new Map<number, Node>();
for (const n of $nodes) map.set(n.id, n);
return map;
});
// Initialize: fetch via REST immediately, then subscribe to WS for updates
// SSE init
let unsubscribe: (() => void) | null = null;
function applyGraph(graph: GraphMessage) {
@@ -30,23 +42,61 @@ function applyGraph(graph: GraphMessage) {
ports.set(graph.ports);
links.set(graph.links);
connected.set(true);
// Auto-restore if activated
const pb = get_store_value(patchbay);
if (pb.activated && pb.active_profile) {
applyPatchbay(pb);
}
// Auto-pin new connections
if (pb.auto_pin) {
const current = get_store_value(links);
const pinned = [...pb.pinned_connections];
for (const link of current) {
if (!pinned.includes(link.id)) {
pinned.push(link.id);
}
}
patchbay.update(pb => ({ ...pb, pinned_connections: pinned }));
}
// Exclusive mode: disconnect unpinned links not in profile
if (pb.activated && pb.exclusive && pb.active_profile) {
enforceExclusive(pb);
}
}
// Simple store value getter (for reading outside components)
function get_store_value<T>(store: { subscribe: (fn: (val: T) => void) => () => void }): T {
let val: T;
const unsub = store.subscribe(v => { val = v; });
unsub();
return val!;
}
export async function initGraph() {
if (unsubscribe) return;
// 1. Fetch initial state via REST API (works immediately, no WS needed)
// Load patchbay state from server
try {
const res = await fetch('/api/patchbay');
if (res.ok) {
const data = await res.json();
if (data && data.profiles) {
patchbay.set(data as PatchbayState);
}
}
} catch {}
// Fetch initial graph
try {
const res = await fetch('/api/graph');
if (res.ok) {
const graph: GraphMessage = await res.json();
applyGraph(graph);
applyGraph(await res.json());
}
} catch (e) {
console.warn('[graph] REST fetch failed:', e);
}
// 2. Subscribe to WebSocket for live updates
unsubscribe = subscribe((graph: GraphMessage) => {
applyGraph(graph);
});
@@ -60,5 +110,252 @@ export function destroyGraph() {
}
}
// Re-export connection functions
// Patchbay operations
function linkKey(link: { output_port_id: number; input_port_id: number }): string {
return `${link.output_port_id}:${link.input_port_id}`;
}
function ruleKey(rule: ConnectionRule): string {
return `${rule.output_node_name}:${rule.output_port_name}=>${rule.input_node_name}:${rule.input_port_name}`;
}
export async function savePatchbayState() {
const pb = get_store_value(patchbay);
await fetch('/api/patchbay', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pb),
});
}
async function applyPatchbay(pb: PatchbayState) {
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
const profile = pb.profiles[pb.active_profile];
const currentNodes = get_store_value(nodes);
const currentPorts = get_store_value(ports);
const currentLinks = get_store_value(links);
const existingKeys = new Set(currentLinks.map(linkKey));
// Build name lookup for current graph
const portByName = new Map<string, Port>();
const nodeByIdMap = new Map<number, Node>();
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
for (const p of currentPorts) {
const node = nodeByIdMap.get(p.node_id);
if (node) {
const key = `${node.name}:${p.name}:${p.mode}:${p.port_type}`;
portByName.set(key, p);
}
}
for (const rule of profile.connections) {
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
const outPort = portByName.get(outKey);
const inPort = portByName.get(inKey);
if (outPort && inPort) {
const lk = `${outPort.id}:${inPort.id}`;
if (!existingKeys.has(lk)) {
connectPorts(outPort.id, inPort.id);
}
}
}
}
async function enforceExclusive(pb: PatchbayState) {
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
const profile = pb.profiles[pb.active_profile];
const currentNodes = get_store_value(nodes);
const currentPorts = get_store_value(ports);
const currentLinks = get_store_value(links);
const nodeByIdMap = new Map<number, Node>();
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
// Build set of allowed connection keys from profile
const allowed = new Set<string>();
const portByName = new Map<string, Port>();
for (const p of currentPorts) {
const node = nodeByIdMap.get(p.node_id);
if (node) {
portByName.set(`${node.name}:${p.name}:${p.mode}:${p.port_type}`, p);
}
}
for (const rule of profile.connections) {
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
const outPort = portByName.get(outKey);
const inPort = portByName.get(inKey);
if (outPort && inPort) {
allowed.add(`${outPort.id}:${inPort.id}`);
}
}
// Also keep pinned connections
for (const linkId of pb.pinned_connections) {
const link = currentLinks.find(l => l.id === linkId);
if (link) allowed.add(linkKey(link));
}
// Disconnect everything else
for (const link of currentLinks) {
if (!allowed.has(linkKey(link))) {
disconnectPorts(link.output_port_id, link.input_port_id);
}
}
}
export function togglePin(linkId: number) {
patchbay.update(pb => {
const idx = pb.pinned_connections.indexOf(linkId);
if (idx >= 0) {
pb.pinned_connections.splice(idx, 1);
} else {
pb.pinned_connections.push(linkId);
}
return { ...pb };
});
savePatchbayState();
}
export function addHideRule(pattern: string) {
patchbay.update(pb => {
if (!pb.hide_rules.includes(pattern)) {
pb.hide_rules.push(pattern);
}
return { ...pb };
});
savePatchbayState();
}
export function removeHideRule(pattern: string) {
patchbay.update(pb => {
return { ...pb, hide_rules: pb.hide_rules.filter(r => r !== pattern) };
});
savePatchbayState();
}
export function addMergeRule(prefix: string) {
patchbay.update(pb => {
if (!pb.merge_rules.includes(prefix)) {
pb.merge_rules.push(prefix);
}
return { ...pb };
});
savePatchbayState();
}
export function removeMergeRule(prefix: string) {
patchbay.update(pb => {
return { ...pb, merge_rules: pb.merge_rules.filter(r => r !== prefix) };
});
savePatchbayState();
}
export function setActivated(active: boolean) {
patchbay.update(pb => {
const newPb = { ...pb, activated: active };
if (active && pb.active_profile) {
setTimeout(() => applyPatchbay(newPb), 100);
}
if (!active && pb.exclusive) {
// Auto-disconnect: remove all pinned connections when deactivated
if (pb.auto_disconnect) {
const currentLinks = get_store_value(links);
for (const linkId of pb.pinned_connections) {
const link = currentLinks.find(l => l.id === linkId);
if (link) disconnectPorts(link.output_port_id, link.input_port_id);
}
}
}
return newPb;
});
savePatchbayState();
}
export function setExclusive(exclusive: boolean) {
patchbay.update(pb => ({ ...pb, exclusive }));
if (exclusive) {
const pb = get_store_value(patchbay);
if (pb.activated) enforceExclusive(pb);
}
savePatchbayState();
}
export function setAutoPin(val: boolean) {
patchbay.update(pb => ({ ...pb, auto_pin: val }));
savePatchbayState();
}
export function setAutoDisconnect(val: boolean) {
patchbay.update(pb => ({ ...pb, auto_disconnect: val }));
savePatchbayState();
}
export async function saveProfile(name: string) {
const currentLinks = get_store_value(links);
const currentPorts = get_store_value(ports);
const currentNodes = get_store_value(nodes);
const pb = get_store_value(patchbay);
const portByIdMap = new Map<number, Port>();
for (const p of currentPorts) portByIdMap.set(p.id, p);
const nodeByIdMap = new Map<number, Node>();
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
const connections: ConnectionRule[] = [];
for (const link of currentLinks) {
const outPort = portByIdMap.get(link.output_port_id);
const inPort = portByIdMap.get(link.input_port_id);
if (!outPort || !inPort) continue;
const outNode = nodeByIdMap.get(outPort.node_id);
const inNode = nodeByIdMap.get(inPort.node_id);
if (!outNode || !inNode) continue;
connections.push({
output_node_name: outNode.name,
output_port_name: outPort.name,
output_port_type: outPort.port_type,
input_node_name: inNode.name,
input_port_name: inPort.name,
input_port_type: inPort.port_type,
pinned: pb.pinned_connections.includes(link.id),
});
}
const profile: PatchbayProfile = {
name,
connections,
hide_rules: [...pb.hide_rules],
merge_rules: [...pb.merge_rules],
};
patchbay.update(pb => ({
...pb,
profiles: { ...pb.profiles, [name]: profile },
active_profile: name,
}));
savePatchbayState();
}
export function loadProfile(name: string) {
patchbay.update(pb => ({ ...pb, active_profile: name }));
const pb = get_store_value(patchbay);
if (pb.activated) {
applyPatchbay(pb);
}
savePatchbayState();
}
export function deleteProfile(name: string) {
patchbay.update(pb => {
const profiles = { ...pb.profiles };
delete profiles[name];
return {
...pb,
profiles,
active_profile: pb.active_profile === name ? '' : pb.active_profile,
};
});
savePatchbayState();
}
export { connectPorts, disconnectPorts };

View File

@@ -45,3 +45,33 @@ export interface DisconnectMessage {
}
export type WsMessage = ConnectMessage | DisconnectMessage;
// Patchbay types
export interface ConnectionRule {
output_port_name: string;
output_node_name: string;
output_port_type: string;
input_port_name: string;
input_node_name: string;
input_port_type: string;
pinned?: boolean;
}
export interface PatchbayProfile {
name: string;
connections: ConnectionRule[];
hide_rules?: string[];
merge_rules?: string[];
}
export interface PatchbayState {
profiles: Record<string, PatchbayProfile>;
active_profile: string;
activated: boolean;
exclusive: boolean;
auto_pin: boolean;
auto_disconnect: boolean;
pinned_connections: number[];
hide_rules: string[];
merge_rules: string[];
}