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:
@@ -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,16 +232,14 @@
|
|||||||
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) {
|
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;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.classList.contains('edge-path')) {
|
if (target.classList.contains('edge-path')) {
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user