feat: complete qpwgraph feature parity

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

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

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

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

View File

@@ -1,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 };