Merge feature/complete-qpwgraph-features into master

This commit is contained in:
joren
2026-03-29 22:55:57 +02:00
3 changed files with 640 additions and 99 deletions

View File

@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { import {
nodes, ports, links, connected, nodes, ports, links, connected, patchbay,
portById, portById,
initGraph, destroyGraph, initGraph, destroyGraph,
connectPorts, disconnectPorts, connectPorts, disconnectPorts,
togglePin,
addHideRule, removeHideRule,
addMergeRule, removeMergeRule,
setActivated, setExclusive,
setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile,
} from '../lib/stores'; } from '../lib/stores';
import type { Node, Port, Link } from '../lib/types'; import type { Node, Port, Link } from '../lib/types';
@@ -12,12 +18,12 @@
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 }); let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
let svgEl: SVGElement | null = null; 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 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 connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null);
let hoveredPort = $state<number | null>(null); let hoveredPort = $state<number | null>(null);
let selectedEdge = $state<string | 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 // Filters
let showAudio = $state(true); let showAudio = $state(true);
@@ -25,17 +31,21 @@
let showVideo = $state(true); let showVideo = $state(true);
let showOther = $state(true); let showOther = $state(true);
// Patchbay // Dialogs
let patchbayName = $state(''); 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 // Positions
let nodePositions = $state<Record<string, { x: number; y: number }>>({}); let nodePositions = $state<Record<string, { x: number; y: number }>>({});
const POS_KEY = 'pwweb_positions'; const POS_KEY = 'pwweb_positions';
function loadPositions() { function loadPositions() {
// Try localStorage first
try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; } try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
// Also load from server
fetch('/api/positions').then(r => r.json()).then(data => { fetch('/api/positions').then(r => r.json()).then(data => {
if (data && typeof data === 'object' && !data.error) { if (data && typeof data === 'object' && !data.error) {
nodePositions = { ...data, ...nodePositions }; nodePositions = { ...data, ...nodePositions };
@@ -46,8 +56,7 @@
function savePositions() { function savePositions() {
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {} try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
fetch('/api/positions', { fetch('/api/positions', {
method: 'PUT', method: 'PUT', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodePositions), body: JSON.stringify(nodePositions),
}).catch(() => {}); }).catch(() => {});
} }
@@ -87,20 +96,56 @@
pt.x = e.clientX; pt.y = e.clientY; pt.x = e.clientX; pt.y = e.clientY;
const ctm = (svgEl as SVGGraphicsElement).getScreenCTM(); const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
if (!ctm) return { x: e.clientX, y: e.clientY }; if (!ctm) return { x: e.clientX, y: e.clientY };
const svgP = pt.matrixTransform(ctm.inverse()); return pt.matrixTransform(ctm.inverse());
return { x: svgP.x, y: svgP.y };
} }
// 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(() => { let graphNodes = $derived.by(() => {
const n = $nodes; const n = $nodes;
const p = $ports; const p = $ports;
const pb = $patchbay;
const portMap = new Map<number, Port>(); const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port); for (const port of p) portMap.set(port.id, port);
const out = n.filter(nd => nd.mode === 'output'); // Filter hidden nodes
const inp = n.filter(nd => nd.mode === 'input'); let visible = n.filter(nd => !isNodeHidden(nd.name));
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
// 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 }> = {}; const defaults: Record<string, { x: number; y: number }> = {};
let i = 0; let i = 0;
@@ -112,10 +157,8 @@
const pos = { ...defaults, ...nodePositions }; 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 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 allInPorts = ndPorts.filter(pp => pp.mode === 'input');
const allOutPorts = ndPorts.filter(pp => pp.mode === 'output'); const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');
@@ -145,13 +188,21 @@
let graphLinks = $derived.by(() => { let graphLinks = $derived.by(() => {
const l = $links; const l = $links;
const p = $ports; const p = $ports;
const pb = $patchbay;
const portMap = new Map<number, Port>(); const portMap = new Map<number, Port>();
for (const port of p) portMap.set(port.id, port); for (const port of p) portMap.set(port.id, port);
return l.map(link => { return l.map(link => {
const outPort = portMap.get(link.output_port_id); const outPort = portMap.get(link.output_port_id);
const inPort = portMap.get(link.input_port_id); const inPort = portMap.get(link.input_port_id);
return { ...link, outPort, inPort }; if (!outPort || !inPort) return null;
}).filter(l => l.outPort && l.inPort && isPortVisible(l.outPort!.port_type)) as Array<Link & { outPort: Port; inPort: Port }>; // 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 { function getPortPos(portId: number): { x: number; y: number } | null {
@@ -165,7 +216,7 @@
// Mouse handlers // Mouse handlers
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
contextMenu = null; contextMenu = null;
if (e.button === 2) return; // right-click handled by oncontextmenu if (e.button === 2) return;
const pt = svgPoint(e); const pt = svgPoint(e);
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -181,9 +232,8 @@
return; return;
} }
if (target.classList.contains('node-header') || target.closest('.node-group')) { if (target.closest('.node-group')) {
const nodeGroup = target.closest('.node-group') as HTMLElement; const nodeGroup = target.closest('.node-group') as HTMLElement;
if (nodeGroup) {
const nodeId = nodeGroup.dataset.nodeId!; const nodeId = nodeGroup.dataset.nodeId!;
const nd = graphNodes.find(n => String(n.id) === nodeId); const nd = graphNodes.find(n => String(n.id) === nodeId);
if (nd) { if (nd) {
@@ -191,7 +241,6 @@
} }
return; return;
} }
}
if (target.classList.contains('edge-path')) { if (target.classList.contains('edge-path')) {
selectedEdge = target.dataset.edgeId || null; selectedEdge = target.dataset.edgeId || null;
@@ -205,13 +254,11 @@
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
if (!dragging && !connecting) return; if (!dragging && !connecting) return;
const pt = svgPoint(e); const pt = svgPoint(e);
if (connecting) { if (connecting) {
connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y }; connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y };
return; return;
} }
if (!dragging) return; if (!dragging) return;
if (dragging.type === 'node' && dragging.nodeId) { if (dragging.type === 'node' && dragging.nodeId) {
const dx = pt.x - dragging.startX; const dx = pt.x - dragging.startX;
const dy = pt.y - dragging.startY; const dy = pt.y - dragging.startY;
@@ -235,9 +282,7 @@
} }
connecting = null; connecting = null;
} }
if (dragging?.type === 'node' && dragging.nodeId) { if (dragging?.type === 'node' && dragging.nodeId) savePositions();
savePositions();
}
dragging = null; dragging = null;
} }
@@ -261,17 +306,17 @@
const link = $links.find(l => String(l.id) === edgeId); const link = $links.find(l => String(l.id) === edgeId);
if (link) { if (link) {
selectedEdge = edgeId || null; 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) { function onKey(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) { if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
const link = $links.find(l => String(l.id) === 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(); }); onMount(() => { initGraph(); loadPositions(); });
onDestroy(() => { destroyGraph(); }); onDestroy(() => { destroyGraph(); });
</script> </script>
@@ -309,19 +334,37 @@
<div class="toolbar"> <div class="toolbar">
<span class="dot" class:on={$connected}></span> <span class="dot" class:on={$connected}></span>
<span>{$connected ? 'Connected' : 'Disconnected'}</span> <span>{$connected ? 'Connected' : 'Disconnected'}</span>
<span class="sep"></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={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={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={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> <label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
<span class="sep"></span> <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> </div>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -347,15 +390,21 @@
{@const inPos = getPortPos(link.input_port_id)} {@const inPos = getPortPos(link.input_port_id)}
{#if outPos && inPos} {#if outPos && inPos}
{@const color = portColor(link.outPort.port_type)} {@const color = portColor(link.outPort.port_type)}
{@const isSelected = selectedEdge === String(link.id)}
<path <path
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)} d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
stroke={selectedEdge === String(link.id) ? '#fff' : color} stroke={isSelected ? '#fff' : color}
stroke-width={selectedEdge === String(link.id) ? 3.5 : 2} stroke-width={isSelected ? 3.5 : link.pinned ? 2.5 : 2}
fill="none" fill="none"
class="edge-path" class="edge-path"
data-edge-id={String(link.id)} data-edge-id={String(link.id)}
stroke-linecap="round" 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} {/if}
{/each} {/each}
@@ -403,9 +452,7 @@
onmouseenter={() => { hoveredPort = port.id; }} onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }} onmouseleave={() => { hoveredPort = null; }}
/> />
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999"> <text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">{shortName(port.name)}</text>
{shortName(port.name)}
</text>
{:else} {:else}
<circle cx={nd.x} cy={py} r="3" fill="#333" /> <circle cx={nd.x} cy={py} r="3" fill="#333" />
{/if} {/if}
@@ -414,9 +461,7 @@
{#each nd.outPorts as port, i (port.id)} {#each nd.outPorts as port, i (port.id)}
{@const py = nd.y + 22 + i * 16 + 8} {@const py = nd.y + 22 + i * 16 + 8}
{#if isPortVisible(port.port_type)} {#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"> <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>
{shortName(port.name)}
</text>
<circle <circle
cx={nd.x + nd.width} cy={py} r="4" cx={nd.x + nd.width} cy={py} r="4"
fill={portColor(port.port_type)} fill={portColor(port.port_type)}
@@ -435,9 +480,122 @@
{/each} {/each}
</svg> </svg>
<!-- Context menu -->
{#if contextMenu} {#if contextMenu}
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu"> <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> </div>
{/if} {/if}
</div> </div>
@@ -448,19 +606,20 @@
.toolbar { .toolbar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10; position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 6px;
padding: 4px 12px; padding: 4px 10px;
background: rgba(16, 16, 24, 0.97); background: rgba(16, 16, 24, 0.97);
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
font-size: 11px; color: #aaa; font-family: monospace; 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 { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
.dot.on { background: #4a9; } .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 { .filter {
display: flex; align-items: center; gap: 3px; cursor: pointer; 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:hover { background: rgba(255,255,255,0.05); }
.filter input { display: none; } .filter input { display: none; }
@@ -472,31 +631,86 @@
.filter:not(.active) { opacity: 0.4; } .filter:not(.active) { opacity: 0.4; }
.filter:not(.active) .fc { background: #555 !important; } .filter:not(.active) .fc { background: #555 !important; }
.toolbar button { .toolbar button, .toggle {
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444; padding: 2px 7px; background: #2a2a3e; border: 1px solid #444;
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace; color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
white-space: nowrap;
} }
.toolbar button:hover { background: #3a3a4e; } .toolbar button:hover, .toggle:hover { background: #3a3a4e; }
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
.pbname {
width: 100px; padding: 2px 6px; background: #1a1a2e; border: 1px solid #444;
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
}
.stats { margin-left: auto; color: #555; } .stats { margin-left: auto; color: #555; }
/* Context menu */
.ctx { .ctx {
position: fixed; z-index: 100; position: fixed; z-index: 100;
background: #2a2a3e; border: 1px solid #555; background: #2a2a3e; border: 1px solid #555;
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
} }
.ctx button { .ctx button {
display: block; width: 100%; padding: 6px 20px; display: block; width: 100%; padding: 5px 20px;
background: none; border: none; color: #ccc; background: none; border: none; color: #ccc;
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace; font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
} }
.ctx button:hover { background: #444; } .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 { cursor: crosshair; }
.port-circle:hover { filter: brightness(1.5); } .port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; } .edge-path { cursor: pointer; pointer-events: stroke; }

View File

@@ -1,5 +1,5 @@
import { writable, derived, get } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import type { Node, Port, Link, GraphMessage } from './types'; import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
import { subscribe, connectPorts, disconnectPorts } from './ws'; import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores // Raw graph stores
@@ -8,21 +8,33 @@ export const ports = writable<Port[]>([]);
export const links = writable<Link[]>([]); export const links = writable<Link[]>([]);
export const connected = writable(false); 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) => { export const portById = derived(ports, ($ports) => {
const map = new Map<number, Port>(); const map = new Map<number, Port>();
for (const p of $ports) map.set(p.id, p); for (const p of $ports) map.set(p.id, p);
return map; return map;
}); });
// Node lookup map
export const nodeById = derived(nodes, ($nodes) => { export const nodeById = derived(nodes, ($nodes) => {
const map = new Map<number, Node>(); const map = new Map<number, Node>();
for (const n of $nodes) map.set(n.id, n); for (const n of $nodes) map.set(n.id, n);
return map; return map;
}); });
// Initialize: fetch via REST immediately, then subscribe to WS for updates // SSE init
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
function applyGraph(graph: GraphMessage) { function applyGraph(graph: GraphMessage) {
@@ -30,23 +42,61 @@ function applyGraph(graph: GraphMessage) {
ports.set(graph.ports); ports.set(graph.ports);
links.set(graph.links); links.set(graph.links);
connected.set(true); 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() { export async function initGraph() {
if (unsubscribe) return; 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 { try {
const res = await fetch('/api/graph'); const res = await fetch('/api/graph');
if (res.ok) { if (res.ok) {
const graph: GraphMessage = await res.json(); applyGraph(await res.json());
applyGraph(graph);
} }
} catch (e) { } catch (e) {
console.warn('[graph] REST fetch failed:', e); console.warn('[graph] REST fetch failed:', e);
} }
// 2. Subscribe to WebSocket for live updates
unsubscribe = subscribe((graph: GraphMessage) => { unsubscribe = subscribe((graph: GraphMessage) => {
applyGraph(graph); 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 }; export { connectPorts, disconnectPorts };

View File

@@ -45,3 +45,33 @@ export interface DisconnectMessage {
} }
export type WsMessage = ConnectMessage | 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[];
}