Merge feature/complete-qpwgraph-features into master
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
nodes, ports, links, connected,
|
||||
nodes, ports, links, connected, patchbay,
|
||||
portById,
|
||||
initGraph, destroyGraph,
|
||||
connectPorts, disconnectPorts,
|
||||
togglePin,
|
||||
addHideRule, removeHideRule,
|
||||
addMergeRule, removeMergeRule,
|
||||
setActivated, setExclusive,
|
||||
setAutoPin, setAutoDisconnect,
|
||||
saveProfile, loadProfile, deleteProfile,
|
||||
} from '../lib/stores';
|
||||
import type { Node, Port, Link } from '../lib/types';
|
||||
|
||||
@@ -12,12 +18,12 @@
|
||||
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
|
||||
let svgEl: SVGElement | null = null;
|
||||
|
||||
// Interaction state
|
||||
// Interaction
|
||||
let dragging = $state<{ type: string; startX: number; startY: number; origX: number; origY: number; nodeId?: string } | null>(null);
|
||||
let connecting = $state<{ outputPortId: number; outputX: number; outputY: number; mouseX: number; mouseY: number; portType: string } | null>(null);
|
||||
let hoveredPort = $state<number | null>(null);
|
||||
let selectedEdge = $state<string | null>(null);
|
||||
let contextMenu = $state<{ x: number; y: number; outputPortId: number; inputPortId: number } | null>(null);
|
||||
let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
|
||||
|
||||
// Filters
|
||||
let showAudio = $state(true);
|
||||
@@ -25,17 +31,21 @@
|
||||
let showVideo = $state(true);
|
||||
let showOther = $state(true);
|
||||
|
||||
// Patchbay
|
||||
let patchbayName = $state('');
|
||||
// Dialogs
|
||||
let showHideDialog = $state(false);
|
||||
let showMergeDialog = $state(false);
|
||||
let showProfileDialog = $state(false);
|
||||
let showRuleDialog = $state(false);
|
||||
let newHideRule = $state('');
|
||||
let newMergeRule = $state('');
|
||||
let newProfileName = $state('');
|
||||
|
||||
// Positions
|
||||
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
|
||||
const POS_KEY = 'pwweb_positions';
|
||||
|
||||
function loadPositions() {
|
||||
// Try localStorage first
|
||||
try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
|
||||
// Also load from server
|
||||
fetch('/api/positions').then(r => r.json()).then(data => {
|
||||
if (data && typeof data === 'object' && !data.error) {
|
||||
nodePositions = { ...data, ...nodePositions };
|
||||
@@ -46,8 +56,7 @@
|
||||
function savePositions() {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
|
||||
fetch('/api/positions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(nodePositions),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -87,20 +96,56 @@
|
||||
pt.x = e.clientX; pt.y = e.clientY;
|
||||
const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
|
||||
if (!ctm) return { x: e.clientX, y: e.clientY };
|
||||
const svgP = pt.matrixTransform(ctm.inverse());
|
||||
return { x: svgP.x, y: svgP.y };
|
||||
return pt.matrixTransform(ctm.inverse());
|
||||
}
|
||||
|
||||
// Computed layout
|
||||
function isNodeHidden(nodeName: string): boolean {
|
||||
for (const rule of $patchbay.hide_rules) {
|
||||
try {
|
||||
if (new RegExp(rule, 'i').test(nodeName)) return true;
|
||||
} catch {
|
||||
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMergedName(nodeName: string): string {
|
||||
for (const prefix of $patchbay.merge_rules) {
|
||||
if (nodeName.startsWith(prefix)) return prefix;
|
||||
}
|
||||
return nodeName;
|
||||
}
|
||||
|
||||
// Build computed layout
|
||||
let graphNodes = $derived.by(() => {
|
||||
const n = $nodes;
|
||||
const p = $ports;
|
||||
const pb = $patchbay;
|
||||
const portMap = new Map<number, Port>();
|
||||
for (const port of p) portMap.set(port.id, port);
|
||||
|
||||
const out = n.filter(nd => nd.mode === 'output');
|
||||
const inp = n.filter(nd => nd.mode === 'input');
|
||||
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
|
||||
// Filter hidden nodes
|
||||
let visible = n.filter(nd => !isNodeHidden(nd.name));
|
||||
|
||||
// Merge nodes by prefix
|
||||
const merged = new Map<string, typeof visible[0]>();
|
||||
for (const nd of visible) {
|
||||
const mergedName = getMergedName(nd.name);
|
||||
const existing = merged.get(mergedName);
|
||||
if (existing) {
|
||||
// Merge port IDs
|
||||
existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
|
||||
existing.name = mergedName;
|
||||
} else {
|
||||
merged.set(mergedName, { ...nd, name: mergedName });
|
||||
}
|
||||
}
|
||||
visible = Array.from(merged.values());
|
||||
|
||||
const out = visible.filter(nd => nd.mode === 'output');
|
||||
const inp = visible.filter(nd => nd.mode === 'input');
|
||||
const other = visible.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
|
||||
|
||||
const defaults: Record<string, { x: number; y: number }> = {};
|
||||
let i = 0;
|
||||
@@ -112,10 +157,8 @@
|
||||
|
||||
const pos = { ...defaults, ...nodePositions };
|
||||
|
||||
return n.map(nd => {
|
||||
return visible.map(nd => {
|
||||
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
|
||||
const inPorts = ndPorts.filter(pp => pp.mode === 'input' && isPortVisible(pp.port_type));
|
||||
const outPorts = ndPorts.filter(pp => pp.mode === 'output' && isPortVisible(pp.port_type));
|
||||
const allInPorts = ndPorts.filter(pp => pp.mode === 'input');
|
||||
const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');
|
||||
|
||||
@@ -145,13 +188,21 @@
|
||||
let graphLinks = $derived.by(() => {
|
||||
const l = $links;
|
||||
const p = $ports;
|
||||
const pb = $patchbay;
|
||||
const portMap = new Map<number, Port>();
|
||||
for (const port of p) portMap.set(port.id, port);
|
||||
return l.map(link => {
|
||||
const outPort = portMap.get(link.output_port_id);
|
||||
const inPort = portMap.get(link.input_port_id);
|
||||
return { ...link, outPort, inPort };
|
||||
}).filter(l => l.outPort && l.inPort && isPortVisible(l.outPort!.port_type)) as Array<Link & { outPort: Port; inPort: Port }>;
|
||||
if (!outPort || !inPort) return null;
|
||||
// Check if both endpoint nodes are visible
|
||||
const outNode = $nodes.find(n => n.id === outPort.node_id);
|
||||
const inNode = $nodes.find(n => n.id === inPort.node_id);
|
||||
if (outNode && isNodeHidden(outNode.name)) return null;
|
||||
if (inNode && isNodeHidden(inNode.name)) return null;
|
||||
const pinned = pb.pinned_connections.includes(link.id);
|
||||
return { ...link, outPort, inPort, pinned };
|
||||
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
|
||||
});
|
||||
|
||||
function getPortPos(portId: number): { x: number; y: number } | null {
|
||||
@@ -165,7 +216,7 @@
|
||||
// Mouse handlers
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
contextMenu = null;
|
||||
if (e.button === 2) return; // right-click handled by oncontextmenu
|
||||
if (e.button === 2) return;
|
||||
const pt = svgPoint(e);
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
@@ -181,9 +232,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('node-header') || target.closest('.node-group')) {
|
||||
if (target.closest('.node-group')) {
|
||||
const nodeGroup = target.closest('.node-group') as HTMLElement;
|
||||
if (nodeGroup) {
|
||||
const nodeId = nodeGroup.dataset.nodeId!;
|
||||
const nd = graphNodes.find(n => String(n.id) === nodeId);
|
||||
if (nd) {
|
||||
@@ -191,7 +241,6 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.classList.contains('edge-path')) {
|
||||
selectedEdge = target.dataset.edgeId || null;
|
||||
@@ -205,13 +254,11 @@
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!dragging && !connecting) return;
|
||||
const pt = svgPoint(e);
|
||||
|
||||
if (connecting) {
|
||||
connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y };
|
||||
return;
|
||||
}
|
||||
if (!dragging) return;
|
||||
|
||||
if (dragging.type === 'node' && dragging.nodeId) {
|
||||
const dx = pt.x - dragging.startX;
|
||||
const dy = pt.y - dragging.startY;
|
||||
@@ -235,9 +282,7 @@
|
||||
}
|
||||
connecting = null;
|
||||
}
|
||||
if (dragging?.type === 'node' && dragging.nodeId) {
|
||||
savePositions();
|
||||
}
|
||||
if (dragging?.type === 'node' && dragging.nodeId) savePositions();
|
||||
dragging = null;
|
||||
}
|
||||
|
||||
@@ -261,17 +306,17 @@
|
||||
const link = $links.find(l => String(l.id) === edgeId);
|
||||
if (link) {
|
||||
selectedEdge = edgeId || null;
|
||||
contextMenu = { x: e.clientX, y: e.clientY, outputPortId: link.output_port_id, inputPortId: link.input_port_id };
|
||||
contextMenu = {
|
||||
x: e.clientX, y: e.clientY,
|
||||
linkId: link.id,
|
||||
outputPortId: link.output_port_id,
|
||||
inputPortId: link.input_port_id,
|
||||
pinned: $patchbay.pinned_connections.includes(link.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (!contextMenu) return;
|
||||
disconnectPorts(contextMenu.outputPortId, contextMenu.inputPortId);
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
|
||||
const link = $links.find(l => String(l.id) === selectedEdge);
|
||||
@@ -279,26 +324,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Patchbay
|
||||
async function savePatchbay() {
|
||||
const name = patchbayName.trim() || new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const data = { name, connections: $links.map(l => ({ output_port_id: l.output_port_id, input_port_id: l.input_port_id })) };
|
||||
await fetch('/api/patchbay', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
patchbayName = name;
|
||||
}
|
||||
|
||||
async function loadPatchbay() {
|
||||
try {
|
||||
const res = await fetch('/api/patchbay');
|
||||
const data = await res.json();
|
||||
if (data.connections && data.connections.length > 0) {
|
||||
for (const conn of data.connections) {
|
||||
await fetch('/api/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(conn) });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMount(() => { initGraph(); loadPositions(); });
|
||||
onDestroy(() => { destroyGraph(); });
|
||||
</script>
|
||||
@@ -309,19 +334,37 @@
|
||||
<div class="toolbar">
|
||||
<span class="dot" class:on={$connected}></span>
|
||||
<span>{$connected ? 'Connected' : 'Disconnected'}</span>
|
||||
|
||||
<span class="sep"></span>
|
||||
|
||||
<!-- Port type filters -->
|
||||
<label class="filter" class:active={showAudio}><input type="checkbox" bind:checked={showAudio} /> <span class="fc audio"></span> Audio</label>
|
||||
<label class="filter" class:active={showMidi}><input type="checkbox" bind:checked={showMidi} /> <span class="fc midi"></span> MIDI</label>
|
||||
<label class="filter" class:active={showVideo}><input type="checkbox" bind:checked={showVideo} /> <span class="fc video"></span> Video</label>
|
||||
<label class="filter" class:active={showOther}><input type="checkbox" bind:checked={showOther} /> <span class="fc other"></span> Other</label>
|
||||
|
||||
<span class="sep"></span>
|
||||
<button onclick={savePatchbay} title="Save current connections">Save Patchbay</button>
|
||||
<button onclick={loadPatchbay} title="Load saved connections">Load Patchbay</button>
|
||||
<input class="pbname" bind:value={patchbayName} placeholder="patchbay name" />
|
||||
|
||||
<span class="stats">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
|
||||
<!-- Patchbay toggles -->
|
||||
<button class="toggle" class:active={$patchbay.activated} onclick={() => setActivated(!$patchbay.activated)} title="Activated Mode: auto-restore connections">
|
||||
{$patchbay.activated ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
<button class="toggle" class:active={$patchbay.exclusive} onclick={() => setExclusive(!$patchbay.exclusive)} title="Exclusive Mode: disconnect unprofiled links">
|
||||
{$patchbay.exclusive ? 'Exclusive' : 'Permissive'}
|
||||
</button>
|
||||
<button class="toggle" class:active={$patchbay.auto_pin} onclick={() => setAutoPin(!$patchbay.auto_pin)} title="Auto-Pin new connections">
|
||||
{$patchbay.auto_pin ? 'Auto-Pin ON' : 'Auto-Pin OFF'}
|
||||
</button>
|
||||
<button class="toggle" class:active={$patchbay.auto_disconnect} onclick={() => setAutoDisconnect(!$patchbay.auto_disconnect)} title="Auto-Disconnect pinned on deactivate">
|
||||
{$patchbay.auto_disconnect ? 'Auto-Disc ON' : 'Auto-Disc OFF'}
|
||||
</button>
|
||||
<span class="sep"></span>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button>
|
||||
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
||||
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
||||
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
||||
|
||||
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -347,15 +390,21 @@
|
||||
{@const inPos = getPortPos(link.input_port_id)}
|
||||
{#if outPos && inPos}
|
||||
{@const color = portColor(link.outPort.port_type)}
|
||||
{@const isSelected = selectedEdge === String(link.id)}
|
||||
<path
|
||||
d={bezierPath(outPos.x, outPos.y, inPos.x, inPos.y)}
|
||||
stroke={selectedEdge === String(link.id) ? '#fff' : color}
|
||||
stroke-width={selectedEdge === String(link.id) ? 3.5 : 2}
|
||||
stroke={isSelected ? '#fff' : color}
|
||||
stroke-width={isSelected ? 3.5 : link.pinned ? 2.5 : 2}
|
||||
fill="none"
|
||||
class="edge-path"
|
||||
data-edge-id={String(link.id)}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={link.pinned ? 'none' : 'none'}
|
||||
opacity={link.pinned ? 1 : 0.7}
|
||||
/>
|
||||
{#if link.pinned}
|
||||
<circle cx={(outPos.x + inPos.x) / 2} cy={(outPos.y + inPos.y) / 2} r="3" fill="#ff0" opacity="0.8" />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -403,9 +452,7 @@
|
||||
onmouseenter={() => { hoveredPort = port.id; }}
|
||||
onmouseleave={() => { hoveredPort = null; }}
|
||||
/>
|
||||
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">
|
||||
{shortName(port.name)}
|
||||
</text>
|
||||
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">{shortName(port.name)}</text>
|
||||
{:else}
|
||||
<circle cx={nd.x} cy={py} r="3" fill="#333" />
|
||||
{/if}
|
||||
@@ -414,9 +461,7 @@
|
||||
{#each nd.outPorts as port, i (port.id)}
|
||||
{@const py = nd.y + 22 + i * 16 + 8}
|
||||
{#if isPortVisible(port.port_type)}
|
||||
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">
|
||||
{shortName(port.name)}
|
||||
</text>
|
||||
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">{shortName(port.name)}</text>
|
||||
<circle
|
||||
cx={nd.x + nd.width} cy={py} r="4"
|
||||
fill={portColor(port.port_type)}
|
||||
@@ -435,9 +480,122 @@
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Context menu -->
|
||||
{#if contextMenu}
|
||||
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
|
||||
<button onclick={doDisconnect}>Disconnect</button>
|
||||
<button onclick={() => { disconnectPorts(contextMenu!.outputPortId, contextMenu!.inputPortId); contextMenu = null; }}>Disconnect</button>
|
||||
<button onclick={() => { togglePin(contextMenu!.linkId); contextMenu = null; }}>{contextMenu.pinned ? 'Unpin' : 'Pin'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hide Nodes Dialog -->
|
||||
{#if showHideDialog}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Node Hiding Rules</span>
|
||||
<button class="close" onclick={() => { showHideDialog = false; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="hint">Hide nodes matching pattern (regex or plain text).</p>
|
||||
<div class="input-row">
|
||||
<input bind:value={newHideRule} placeholder="e.g. Dummy|Freewheel or .*Driver.*" class="dlg-input" />
|
||||
<button onclick={() => { if (newHideRule.trim()) { addHideRule(newHideRule.trim()); newHideRule = ''; } }}>Add</button>
|
||||
</div>
|
||||
<div class="rule-list">
|
||||
{#each $patchbay.hide_rules as rule}
|
||||
<div class="rule-item">
|
||||
<span>{rule}</span>
|
||||
<button onclick={() => removeHideRule(rule)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if $patchbay.hide_rules.length === 0}
|
||||
<div class="empty">No hiding rules.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Merge Nodes Dialog -->
|
||||
{#if showMergeDialog}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Node Merging Rules</span>
|
||||
<button class="close" onclick={() => { showMergeDialog = false; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="hint">Merge nodes sharing a common name prefix into one block.</p>
|
||||
<div class="input-row">
|
||||
<input bind:value={newMergeRule} placeholder="e.g. Built-in Audio" class="dlg-input" />
|
||||
<button onclick={() => { if (newMergeRule.trim()) { addMergeRule(newMergeRule.trim()); newMergeRule = ''; } }}>Add</button>
|
||||
</div>
|
||||
<div class="rule-list">
|
||||
{#each $patchbay.merge_rules as rule}
|
||||
<div class="rule-item">
|
||||
<span>{rule}</span>
|
||||
<button onclick={() => removeMergeRule(rule)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if $patchbay.merge_rules.length === 0}
|
||||
<div class="empty">No merge rules.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Profile Dialog -->
|
||||
{#if showProfileDialog}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Profile Management</span>
|
||||
<button class="close" onclick={() => { showProfileDialog = false; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="input-row">
|
||||
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
|
||||
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
|
||||
</div>
|
||||
<div class="rule-list">
|
||||
{#each Object.entries($patchbay.profiles) as [name, profile]}
|
||||
<div class="rule-item">
|
||||
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
|
||||
<button onclick={() => loadProfile(name)}>Load</button>
|
||||
<button onclick={() => deleteProfile(name)}>Delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if Object.keys($patchbay.profiles).length === 0}
|
||||
<div class="empty">No saved profiles.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rule Management Dialog -->
|
||||
{#if showRuleDialog}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Rule Management</span>
|
||||
<button class="close" onclick={() => { showRuleDialog = false; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
{#if $patchbay.active_profile && $patchbay.profiles[$patchbay.active_profile]}
|
||||
{@const profile = $patchbay.profiles[$patchbay.active_profile]}
|
||||
<p class="hint">Active profile: <strong>{profile.name}</strong> ({profile.connections.length} rules)</p>
|
||||
<div class="rule-list scrollable">
|
||||
{#each profile.connections as rule, i}
|
||||
<div class="rule-item small">
|
||||
<span>{rule.output_node_name}:{shortName(rule.output_port_name)} => {rule.input_node_name}:{shortName(rule.input_port_name)}</span>
|
||||
<span class="type-badge {rule.output_port_type}">{rule.output_port_type}</span>
|
||||
{#if rule.pinned}<span class="pin-badge">PIN</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">No active profile. Save a profile first via Profiles.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -448,19 +606,20 @@
|
||||
|
||||
.toolbar {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 12px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(16, 16, 24, 0.97);
|
||||
border-bottom: 1px solid #333;
|
||||
font-size: 11px; color: #aaa; font-family: monospace;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; }
|
||||
.dot.on { background: #4a9; }
|
||||
.sep { width: 1px; height: 16px; background: #444; margin: 0 4px; }
|
||||
.sep { width: 1px; height: 16px; background: #444; margin: 0 2px; flex-shrink: 0; }
|
||||
|
||||
.filter {
|
||||
display: flex; align-items: center; gap: 3px; cursor: pointer;
|
||||
padding: 2px 5px; border-radius: 3px; font-size: 10px;
|
||||
padding: 2px 5px; border-radius: 3px; font-size: 10px; user-select: none;
|
||||
}
|
||||
.filter:hover { background: rgba(255,255,255,0.05); }
|
||||
.filter input { display: none; }
|
||||
@@ -472,31 +631,86 @@
|
||||
.filter:not(.active) { opacity: 0.4; }
|
||||
.filter:not(.active) .fc { background: #555 !important; }
|
||||
|
||||
.toolbar button {
|
||||
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444;
|
||||
.toolbar button, .toggle {
|
||||
padding: 2px 7px; background: #2a2a3e; border: 1px solid #444;
|
||||
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolbar button:hover { background: #3a3a4e; }
|
||||
|
||||
.pbname {
|
||||
width: 100px; padding: 2px 6px; background: #1a1a2e; border: 1px solid #444;
|
||||
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
.toolbar button:hover, .toggle:hover { background: #3a3a4e; }
|
||||
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
|
||||
|
||||
.stats { margin-left: auto; color: #555; }
|
||||
|
||||
/* Context menu */
|
||||
.ctx {
|
||||
position: fixed; z-index: 100;
|
||||
background: #2a2a3e; border: 1px solid #555;
|
||||
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
}
|
||||
.ctx button {
|
||||
display: block; width: 100%; padding: 6px 20px;
|
||||
display: block; width: 100%; padding: 5px 20px;
|
||||
background: none; border: none; color: #ccc;
|
||||
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
|
||||
}
|
||||
.ctx button:hover { background: #444; }
|
||||
|
||||
/* Dialogs */
|
||||
.dialog {
|
||||
position: absolute; top: 40px; right: 10px; z-index: 20;
|
||||
background: #1e1e2e; border: 1px solid #555; border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
|
||||
width: 360px; max-height: 80vh; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
font-family: monospace;
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px; background: #2a2a3e; border-bottom: 1px solid #444;
|
||||
font-size: 12px; color: #ddd; font-weight: bold;
|
||||
}
|
||||
.close { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; padding: 0 4px; }
|
||||
.close:hover { color: #fff; }
|
||||
.dialog-body { padding: 10px; overflow-y: auto; }
|
||||
.hint { font-size: 10px; color: #666; margin: 0 0 8px; }
|
||||
|
||||
.input-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.dlg-input {
|
||||
flex: 1; padding: 4px 8px; background: #14141e; border: 1px solid #444;
|
||||
color: #ccc; font-size: 11px; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
.input-row button {
|
||||
padding: 4px 10px; background: #2a2a3e; border: 1px solid #444;
|
||||
color: #aaa; font-size: 11px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
.input-row button:hover { background: #3a3a4e; }
|
||||
|
||||
.rule-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rule-list.scrollable { max-height: 300px; overflow-y: auto; }
|
||||
.rule-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 6px; background: #14141e; border-radius: 3px;
|
||||
font-size: 10px; color: #aaa;
|
||||
}
|
||||
.rule-item span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rule-item button {
|
||||
padding: 1px 6px; background: #2a2a3e; border: 1px solid #444;
|
||||
color: #888; font-size: 9px; cursor: pointer; border-radius: 2px; font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rule-item button:hover { color: #f88; border-color: #a44; }
|
||||
.rule-item.small { font-size: 9px; }
|
||||
.active-profile { color: #6c9; font-weight: bold; }
|
||||
|
||||
.type-badge {
|
||||
font-size: 8px; padding: 0 4px; border-radius: 2px; color: #fff;
|
||||
}
|
||||
.type-badge.audio { background: #4a9; }
|
||||
.type-badge.midi { background: #a44; }
|
||||
.type-badge.video { background: #49a; }
|
||||
.pin-badge { font-size: 8px; padding: 0 3px; background: #aa4; color: #000; border-radius: 2px; }
|
||||
|
||||
.empty { font-size: 11px; color: #555; padding: 8px 0; text-align: center; }
|
||||
|
||||
.port-circle { cursor: crosshair; }
|
||||
.port-circle:hover { filter: brightness(1.5); }
|
||||
.edge-path { cursor: pointer; pointer-events: stroke; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage } from './types';
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -8,21 +8,33 @@ export const ports = writable<Port[]>([]);
|
||||
export const links = writable<Link[]>([]);
|
||||
export const connected = writable(false);
|
||||
|
||||
// Port lookup map
|
||||
// Patchbay state
|
||||
export const patchbay = writable<PatchbayState>({
|
||||
profiles: {},
|
||||
active_profile: '',
|
||||
activated: false,
|
||||
exclusive: false,
|
||||
auto_pin: false,
|
||||
auto_disconnect: false,
|
||||
pinned_connections: [],
|
||||
hide_rules: [],
|
||||
merge_rules: [],
|
||||
});
|
||||
|
||||
// Port/node lookups
|
||||
export const portById = derived(ports, ($ports) => {
|
||||
const map = new Map<number, Port>();
|
||||
for (const p of $ports) map.set(p.id, p);
|
||||
return map;
|
||||
});
|
||||
|
||||
// Node lookup map
|
||||
export const nodeById = derived(nodes, ($nodes) => {
|
||||
const map = new Map<number, Node>();
|
||||
for (const n of $nodes) map.set(n.id, n);
|
||||
return map;
|
||||
});
|
||||
|
||||
// Initialize: fetch via REST immediately, then subscribe to WS for updates
|
||||
// SSE init
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function applyGraph(graph: GraphMessage) {
|
||||
@@ -30,23 +42,61 @@ function applyGraph(graph: GraphMessage) {
|
||||
ports.set(graph.ports);
|
||||
links.set(graph.links);
|
||||
connected.set(true);
|
||||
|
||||
// Auto-restore if activated
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated && pb.active_profile) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
// Auto-pin new connections
|
||||
if (pb.auto_pin) {
|
||||
const current = get_store_value(links);
|
||||
const pinned = [...pb.pinned_connections];
|
||||
for (const link of current) {
|
||||
if (!pinned.includes(link.id)) {
|
||||
pinned.push(link.id);
|
||||
}
|
||||
}
|
||||
patchbay.update(pb => ({ ...pb, pinned_connections: pinned }));
|
||||
}
|
||||
// Exclusive mode: disconnect unpinned links not in profile
|
||||
if (pb.activated && pb.exclusive && pb.active_profile) {
|
||||
enforceExclusive(pb);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple store value getter (for reading outside components)
|
||||
function get_store_value<T>(store: { subscribe: (fn: (val: T) => void) => () => void }): T {
|
||||
let val: T;
|
||||
const unsub = store.subscribe(v => { val = v; });
|
||||
unsub();
|
||||
return val!;
|
||||
}
|
||||
|
||||
export async function initGraph() {
|
||||
if (unsubscribe) return;
|
||||
|
||||
// 1. Fetch initial state via REST API (works immediately, no WS needed)
|
||||
// Load patchbay state from server
|
||||
try {
|
||||
const res = await fetch('/api/patchbay');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.profiles) {
|
||||
patchbay.set(data as PatchbayState);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch initial graph
|
||||
try {
|
||||
const res = await fetch('/api/graph');
|
||||
if (res.ok) {
|
||||
const graph: GraphMessage = await res.json();
|
||||
applyGraph(graph);
|
||||
applyGraph(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[graph] REST fetch failed:', e);
|
||||
}
|
||||
|
||||
// 2. Subscribe to WebSocket for live updates
|
||||
unsubscribe = subscribe((graph: GraphMessage) => {
|
||||
applyGraph(graph);
|
||||
});
|
||||
@@ -60,5 +110,252 @@ export function destroyGraph() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export connection functions
|
||||
// Patchbay operations
|
||||
function linkKey(link: { output_port_id: number; input_port_id: number }): string {
|
||||
return `${link.output_port_id}:${link.input_port_id}`;
|
||||
}
|
||||
|
||||
function ruleKey(rule: ConnectionRule): string {
|
||||
return `${rule.output_node_name}:${rule.output_port_name}=>${rule.input_node_name}:${rule.input_port_name}`;
|
||||
}
|
||||
|
||||
export async function savePatchbayState() {
|
||||
const pb = get_store_value(patchbay);
|
||||
await fetch('/api/patchbay', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pb),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPatchbay(pb: PatchbayState) {
|
||||
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
||||
const profile = pb.profiles[pb.active_profile];
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentLinks = get_store_value(links);
|
||||
const existingKeys = new Set(currentLinks.map(linkKey));
|
||||
|
||||
// Build name lookup for current graph
|
||||
const portByName = new Map<string, Port>();
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
for (const p of currentPorts) {
|
||||
const node = nodeByIdMap.get(p.node_id);
|
||||
if (node) {
|
||||
const key = `${node.name}:${p.name}:${p.mode}:${p.port_type}`;
|
||||
portByName.set(key, p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of profile.connections) {
|
||||
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
||||
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
||||
const outPort = portByName.get(outKey);
|
||||
const inPort = portByName.get(inKey);
|
||||
if (outPort && inPort) {
|
||||
const lk = `${outPort.id}:${inPort.id}`;
|
||||
if (!existingKeys.has(lk)) {
|
||||
connectPorts(outPort.id, inPort.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceExclusive(pb: PatchbayState) {
|
||||
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
||||
const profile = pb.profiles[pb.active_profile];
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentLinks = get_store_value(links);
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
|
||||
// Build set of allowed connection keys from profile
|
||||
const allowed = new Set<string>();
|
||||
const portByName = new Map<string, Port>();
|
||||
for (const p of currentPorts) {
|
||||
const node = nodeByIdMap.get(p.node_id);
|
||||
if (node) {
|
||||
portByName.set(`${node.name}:${p.name}:${p.mode}:${p.port_type}`, p);
|
||||
}
|
||||
}
|
||||
for (const rule of profile.connections) {
|
||||
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
||||
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
||||
const outPort = portByName.get(outKey);
|
||||
const inPort = portByName.get(inKey);
|
||||
if (outPort && inPort) {
|
||||
allowed.add(`${outPort.id}:${inPort.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also keep pinned connections
|
||||
for (const linkId of pb.pinned_connections) {
|
||||
const link = currentLinks.find(l => l.id === linkId);
|
||||
if (link) allowed.add(linkKey(link));
|
||||
}
|
||||
|
||||
// Disconnect everything else
|
||||
for (const link of currentLinks) {
|
||||
if (!allowed.has(linkKey(link))) {
|
||||
disconnectPorts(link.output_port_id, link.input_port_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePin(linkId: number) {
|
||||
patchbay.update(pb => {
|
||||
const idx = pb.pinned_connections.indexOf(linkId);
|
||||
if (idx >= 0) {
|
||||
pb.pinned_connections.splice(idx, 1);
|
||||
} else {
|
||||
pb.pinned_connections.push(linkId);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function addHideRule(pattern: string) {
|
||||
patchbay.update(pb => {
|
||||
if (!pb.hide_rules.includes(pattern)) {
|
||||
pb.hide_rules.push(pattern);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function removeHideRule(pattern: string) {
|
||||
patchbay.update(pb => {
|
||||
return { ...pb, hide_rules: pb.hide_rules.filter(r => r !== pattern) };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function addMergeRule(prefix: string) {
|
||||
patchbay.update(pb => {
|
||||
if (!pb.merge_rules.includes(prefix)) {
|
||||
pb.merge_rules.push(prefix);
|
||||
}
|
||||
return { ...pb };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function removeMergeRule(prefix: string) {
|
||||
patchbay.update(pb => {
|
||||
return { ...pb, merge_rules: pb.merge_rules.filter(r => r !== prefix) };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setActivated(active: boolean) {
|
||||
patchbay.update(pb => {
|
||||
const newPb = { ...pb, activated: active };
|
||||
if (active && pb.active_profile) {
|
||||
setTimeout(() => applyPatchbay(newPb), 100);
|
||||
}
|
||||
if (!active && pb.exclusive) {
|
||||
// Auto-disconnect: remove all pinned connections when deactivated
|
||||
if (pb.auto_disconnect) {
|
||||
const currentLinks = get_store_value(links);
|
||||
for (const linkId of pb.pinned_connections) {
|
||||
const link = currentLinks.find(l => l.id === linkId);
|
||||
if (link) disconnectPorts(link.output_port_id, link.input_port_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newPb;
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setExclusive(exclusive: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, exclusive }));
|
||||
if (exclusive) {
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) enforceExclusive(pb);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setAutoPin(val: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, auto_pin: val }));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function setAutoDisconnect(val: boolean) {
|
||||
patchbay.update(pb => ({ ...pb, auto_disconnect: val }));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export async function saveProfile(name: string) {
|
||||
const currentLinks = get_store_value(links);
|
||||
const currentPorts = get_store_value(ports);
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const pb = get_store_value(patchbay);
|
||||
|
||||
const portByIdMap = new Map<number, Port>();
|
||||
for (const p of currentPorts) portByIdMap.set(p.id, p);
|
||||
const nodeByIdMap = new Map<number, Node>();
|
||||
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
||||
|
||||
const connections: ConnectionRule[] = [];
|
||||
for (const link of currentLinks) {
|
||||
const outPort = portByIdMap.get(link.output_port_id);
|
||||
const inPort = portByIdMap.get(link.input_port_id);
|
||||
if (!outPort || !inPort) continue;
|
||||
const outNode = nodeByIdMap.get(outPort.node_id);
|
||||
const inNode = nodeByIdMap.get(inPort.node_id);
|
||||
if (!outNode || !inNode) continue;
|
||||
connections.push({
|
||||
output_node_name: outNode.name,
|
||||
output_port_name: outPort.name,
|
||||
output_port_type: outPort.port_type,
|
||||
input_node_name: inNode.name,
|
||||
input_port_name: inPort.name,
|
||||
input_port_type: inPort.port_type,
|
||||
pinned: pb.pinned_connections.includes(link.id),
|
||||
});
|
||||
}
|
||||
|
||||
const profile: PatchbayProfile = {
|
||||
name,
|
||||
connections,
|
||||
hide_rules: [...pb.hide_rules],
|
||||
merge_rules: [...pb.merge_rules],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
profiles: { ...pb.profiles, [name]: profile },
|
||||
active_profile: name,
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function loadProfile(name: string) {
|
||||
patchbay.update(pb => ({ ...pb, active_profile: name }));
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string) {
|
||||
patchbay.update(pb => {
|
||||
const profiles = { ...pb.profiles };
|
||||
delete profiles[name];
|
||||
return {
|
||||
...pb,
|
||||
profiles,
|
||||
active_profile: pb.active_profile === name ? '' : pb.active_profile,
|
||||
};
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export { connectPorts, disconnectPorts };
|
||||
|
||||
@@ -45,3 +45,33 @@ export interface DisconnectMessage {
|
||||
}
|
||||
|
||||
export type WsMessage = ConnectMessage | DisconnectMessage;
|
||||
|
||||
// Patchbay types
|
||||
export interface ConnectionRule {
|
||||
output_port_name: string;
|
||||
output_node_name: string;
|
||||
output_port_type: string;
|
||||
input_port_name: string;
|
||||
input_node_name: string;
|
||||
input_port_type: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface PatchbayProfile {
|
||||
name: string;
|
||||
connections: ConnectionRule[];
|
||||
hide_rules?: string[];
|
||||
merge_rules?: string[];
|
||||
}
|
||||
|
||||
export interface PatchbayState {
|
||||
profiles: Record<string, PatchbayProfile>;
|
||||
active_profile: string;
|
||||
activated: boolean;
|
||||
exclusive: boolean;
|
||||
auto_pin: boolean;
|
||||
auto_disconnect: boolean;
|
||||
pinned_connections: number[];
|
||||
hide_rules: string[];
|
||||
merge_rules: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user