Files
pwweb/frontend/src/components/GraphCanvas.svelte
joren 3609a50dd2 fix: volume slider click position using element getBoundingClientRect
- Rewrote applyVolumeAtMouse to use hitarea element's screen rect directly
- Works correctly regardless of zoom/pan level
- Made hitarea taller (18px) for easier grabbing
2026-03-30 01:22:30 +02:00

1007 lines
42 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
nodes, ports, links, connected, patchbay,
portById,
initGraph, destroyGraph,
connectPorts, disconnectPorts,
togglePin,
addHideRule, removeHideRule,
addMergeRule, removeMergeRule,
setActivated, setExclusive,
setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute,
createNullSink, createLoopback, loadModule,
getQuantum, setQuantum,
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
// Viewport
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
let svgEl: SVGElement | null = null;
// 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; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
let showPropsDialog = $state<number | null>(null); // node ID or null
// Filters
let showAudio = $state(true);
let showMidi = $state(true);
let showVideo = $state(true);
let showOther = $state(true);
// Dialogs
let showHideDialog = $state(false);
let showMergeDialog = $state(false);
let showProfileDialog = $state(false);
let showRuleDialog = $state(false);
let showVirtualMenu = $state(false);
let splitNodes = $state(false);
let showNetworkDialog = $state<{ type: string } | null>(null);
let netHost = $state('127.0.0.1');
let netPort = $state('4713');
let newHideRule = $state('');
let newMergeRule = $state('');
let newProfileName = $state('');
// Positions
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
let currentQuantum = $state(0);
const POS_KEY = 'pwweb_positions';
function loadPositions() {
try { nodePositions = JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { nodePositions = {}; }
fetch('/api/positions').then(r => r.json()).then(data => {
if (data && typeof data === 'object' && !data.error) {
nodePositions = { ...data, ...nodePositions };
}
}).catch(() => {});
}
function savePositions() {
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
fetch('/api/positions', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodePositions),
}).catch(() => {});
}
// Helpers
function portColor(pt: string): string {
switch (pt) {
case 'audio': return '#4a9';
case 'midi': return '#a44';
case 'video': return '#49a';
default: return '#999';
}
}
function shortName(name: string): string {
const idx = name.lastIndexOf(':');
return idx >= 0 ? name.substring(idx + 1) : name;
}
function isPortVisible(pt: string): boolean {
switch (pt) {
case 'audio': return showAudio;
case 'midi': return showMidi;
case 'video': return showVideo;
default: return showOther;
}
}
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
function svgPoint(e: MouseEvent): { x: number; y: number } {
if (!svgEl) return { x: e.clientX, y: e.clientY };
const pt = (svgEl as any).createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
const ctm = (svgEl as SVGGraphicsElement).getScreenCTM();
if (!ctm) return { x: e.clientX, y: e.clientY };
return pt.matrixTransform(ctm.inverse());
}
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);
// Filter hidden nodes
let visible = n.filter(nd => !isNodeHidden(nd.name));
// Merge nodes by prefix (unless split mode)
if (!splitNodes) {
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) {
existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
existing.name = mergedName;
const mergedInPorts = existing.port_ids.map(pid => portMap.get(pid)).filter((p): p is Port => !!p && p.mode === 'input');
const mergedOutPorts = existing.port_ids.map(pid => portMap.get(pid)).filter((p): p is Port => !!p && p.mode === 'output');
if (mergedInPorts.length > 0 && mergedOutPorts.length > 0) {
existing.mode = 'duplex';
}
} 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;
for (const nd of out) { defaults[String(nd.id)] = { x: 0, y: (i % 10) * 90 }; i++; }
i = 0;
for (const nd of other) { defaults[String(nd.id)] = { x: 280, y: (i % 10) * 90 }; i++; }
i = 0;
for (const nd of inp) { defaults[String(nd.id)] = { x: 560, y: (i % 10) * 90 }; i++; }
const pos = { ...defaults, ...nodePositions };
return visible.map(nd => {
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
const allInPorts = ndPorts.filter(pp => pp.mode === 'input');
const allOutPorts = ndPorts.filter(pp => pp.mode === 'output');
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
const headerH = 22;
const portH = 16;
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider
const width = 220;
const portPositions = new Map<number, { x: number; y: number }>();
let yi = headerH;
for (const port of allInPorts) {
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
yi += portH;
}
let yo = headerH;
for (const port of allOutPorts) {
portPositions.set(port.id, { x: nodePos.x + width, y: nodePos.y + yo + portH / 2 });
yo += portH;
}
return { ...nd, x: nodePos.x, y: nodePos.y, width, height, inPorts: allInPorts, outPorts: allOutPorts, portPositions };
});
});
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);
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 {
for (const node of graphNodes) {
const pos = node.portPositions.get(portId);
if (pos) return pos;
}
return null;
}
// Volume drag state
let volumeDragging = $state<{ nodeId: number } | null>(null);
function applyVolumeAtMouse(e: MouseEvent, nodeId: number) {
const nd = graphNodes.find(n => n.id === nodeId);
if (!nd || !svgEl) return;
// Get the hitarea SVG element's screen rect directly
const hitarea = svgEl.querySelector(`.node-group[data-node-id="${nodeId}"] .vol-hitarea`);
if (!hitarea) return;
const rect = (hitarea as SVGElement).getBoundingClientRect();
// Ratio from left edge of hitarea
const ratio = (e.clientX - rect.left) / rect.width;
const clamped = Math.max(0, Math.min(1, ratio));
setNodeVolume(nodeId, clamped);
}
// Mouse handlers
function onMouseDown(e: MouseEvent) {
// Ignore clicks outside the SVG (toolbar, dialogs, etc.)
if (svgEl && !svgEl.contains(e.target as Node)) return;
contextMenu = null;
nodeContextMenu = null;
showVirtualMenu = false;
if (e.button === 2) return;
const pt = svgPoint(e);
const target = e.target as HTMLElement;
// Volume slider - high priority
if (target.classList.contains('vol-handle') || target.classList.contains('vol-hitarea')) {
const nodeEl = target.closest('.node-group');
if (nodeEl) {
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
volumeDragging = { nodeId };
// Immediately apply volume at click position
applyVolumeAtMouse(e, nodeId);
}
return;
}
// Mute button
if (target.classList.contains('mute-btn') || target.classList.contains('mute-text')) {
const nodeEl = target.closest('.node-group');
if (nodeEl) {
const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId);
const nd = graphNodes.find(n => n.id === nodeId);
if (nd) setNodeMute(nodeId, !nd.mute);
}
return;
}
if (target.classList.contains('port-circle')) {
const portId = Number(target.dataset.portId);
const port = $portById.get(portId);
if (port && port.mode === 'output') {
const pos = getPortPos(portId);
if (pos) {
connecting = { outputPortId: portId, outputX: pos.x, outputY: pos.y, mouseX: pt.x, mouseY: pt.y, portType: port.port_type };
}
}
return;
}
if (target.closest('.node-group')) {
const nodeGroup = target.closest('.node-group') as HTMLElement;
const nodeId = nodeGroup.dataset.nodeId!;
const nd = graphNodes.find(n => String(n.id) === nodeId);
if (nd) {
dragging = { type: 'node', startX: pt.x, startY: pt.y, origX: nd.x, origY: nd.y, nodeId };
}
return;
}
if (target.classList.contains('edge-path')) {
selectedEdge = target.dataset.edgeId || null;
return;
}
selectedEdge = null;
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
}
function onMouseMove(e: MouseEvent) {
// Volume dragging takes priority
if (volumeDragging) {
applyVolumeAtMouse(e, volumeDragging.nodeId);
return;
}
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;
nodePositions[dragging.nodeId] = { x: dragging.origX + dx, y: dragging.origY + dy };
} else if (dragging.type === 'pan') {
const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
viewBox = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy };
}
}
function onMouseUp(e: MouseEvent) {
if (volumeDragging) {
volumeDragging = null;
return;
}
if (connecting) {
const target = e.target as HTMLElement;
if (target.classList.contains('port-circle')) {
const inputPortId = Number(target.dataset.portId);
const port = $portById.get(inputPortId);
if (port && port.mode === 'input' && port.port_type === connecting.portType) {
connectPorts(connecting.outputPortId, inputPortId);
}
}
connecting = null;
}
if (dragging?.type === 'node' && dragging.nodeId) savePositions();
dragging = null;
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const pt = svgPoint(e);
const factor = e.deltaY > 0 ? 1.1 : 0.9;
viewBox = {
x: pt.x - (pt.x - viewBox.x) * factor,
y: pt.y - (pt.y - viewBox.y) * factor,
w: viewBox.w * factor,
h: viewBox.h * factor,
};
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
const target = e.target as HTMLElement;
// Right-click on edge
if (target.classList.contains('edge-path')) {
const edgeId = target.dataset.edgeId;
const link = $links.find(l => String(l.id) === edgeId);
if (link) {
selectedEdge = edgeId || null;
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),
};
}
return;
}
// Right-click on node
const nodeGroup = target.closest('.node-group') as HTMLElement;
if (nodeGroup) {
const nodeId = Number(nodeGroup.dataset.nodeId);
const nd = $nodes.find(n => n.id === nodeId);
if (nd) {
nodeContextMenu = { x: e.clientX, y: e.clientY, nodeId, nodeName: nd.name };
}
}
}
function unloadNodeModule() {
if (!nodeContextMenu) return;
// Try to find and unload the module that created this node
// Use pactl to unload by looking up the module
fetch('/api/load-module', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module: 'module-null-sink', args: '' }),
}).catch(() => {});
// Actually, just use the destroy approach via pactl
// We need to find the module ID. Let's use a different endpoint.
// For now, use pw-cli to destroy the node
const name = nodeContextMenu.nodeName;
fetch('/api/destroy-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeContextMenu.nodeId }),
}).catch(() => {});
nodeContextMenu = null;
}
function onKey(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdge) {
const link = $links.find(l => String(l.id) === selectedEdge);
if (link) { disconnectPorts(link.output_port_id, link.input_port_id); selectedEdge = null; }
}
}
onMount(() => { initGraph(); loadPositions(); getQuantum().then(q => { currentQuantum = q; }); });
onDestroy(() => { destroyGraph(); });
</script>
<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; nodeContextMenu = null; }} />
<div class="wrap">
<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>
<!-- 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 class="toggle" class:active={splitNodes} onclick={() => { splitNodes = !splitNodes; }} title="Show input/output as separate nodes">Split</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>
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
<span class="sep"></span>
<label class="quantum-label">Buffer:
<select class="quantum-select" onchange={(e) => { const q = Number((e.target as HTMLSelectElement).value); if (q > 0) { currentQuantum = q; setQuantum(q); } }}>
<option value="0" selected={currentQuantum === 0}>default</option>
{#each [32, 64, 128, 256, 512, 1024, 2048, 4096] as q}
<option value={q} selected={currentQuantum === q}>{q}</option>
{/each}
</select>
</label>
<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>
<!-- Virtual device dropdown -->
{#if showVirtualMenu}
<div class="virt-menu">
<div class="virt-header">Add Virtual Device</div>
<button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
<button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
<div class="virt-sep"></div>
<button onclick={async () => { showVirtualMenu = false; await loadModule('module-native-protocol-tcp', 'auth-anonymous=1 port=' + netPort); }}>TCP Network Server (port {netPort})</button>
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-sink' }; }}>TCP Tunnel Sink...</button>
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-source' }; }}>TCP Tunnel Source...</button>
</div>
{/if}
<!-- Network config dialog -->
{#if showNetworkDialog}
<div class="dialog" style="right:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:300px">
<div class="dialog-header">
<span>{showNetworkDialog.type === 'tunnel-sink' ? 'TCP Tunnel Sink' : 'TCP Tunnel Source'}</span>
<button class="close" onclick={() => { showNetworkDialog = null; }}>X</button>
</div>
<div class="dialog-body">
<div class="input-row">
<span style="font-size:10px;color:#888;width:50px">Host:</span>
<input class="dlg-input" bind:value={netHost} placeholder="127.0.0.1" />
</div>
<div class="input-row">
<span style="font-size:10px;color:#888;width:50px">Port:</span>
<input class="dlg-input" bind:value={netPort} placeholder="4713" />
</div>
<div class="input-row" style="justify-content:flex-end">
<button onclick={() => { showNetworkDialog = null; }}>Cancel</button>
<button onclick={() => {
const mod = showNetworkDialog!.type === 'tunnel-sink' ? 'module-tunnel-sink' : 'module-tunnel-source';
showNetworkDialog = null;
loadModule(mod, 'server=tcp:' + netHost + ':' + netPort);
}}>Connect</button>
</div>
</div>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svgEl}
viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
onmousedown={onMouseDown}
onmousemove={onMouseMove}
onmouseup={onMouseUp}
onwheel={onWheel}
oncontextmenu={onContextMenu}
class="canvas"
>
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="1" stdDeviation="2" flood-opacity="0.4" />
</filter>
</defs>
<!-- Edges -->
{#each graphLinks as link (link.id)}
{@const outPos = getPortPos(link.output_port_id)}
{@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={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}
<!-- Connecting line -->
{#if connecting}
<path
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
stroke={portColor(connecting.portType)}
stroke-width="2"
stroke-dasharray="6 3"
fill="none"
opacity="0.7"
/>
{/if}
<!-- Nodes -->
{#each graphNodes as nd (nd.id)}
{@const isSource = nd.mode === 'output'}
{@const isSink = nd.mode === 'input'}
{@const isDuplex = nd.mode === 'duplex'}
{@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : isDuplex ? '#1a1a2a' : '#1e1e2e'}
{@const border = isSource ? '#4a9' : isSink ? '#a44' : isDuplex ? '#49a' : '#555'}
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : isDuplex ? '#262638' : '#262638'}
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
{nd.nick || nd.name}
</text>
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
[{nd.node_type}]
</text>
{#each nd.inPorts as port, i (port.id)}
{@const py = nd.y + 22 + i * 16 + 8}
{#if isPortVisible(port.port_type)}
<circle
cx={nd.x} cy={py} r="4"
fill={portColor(port.port_type)}
stroke={hoveredPort === port.id ? '#fff' : '#333'}
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
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>
{:else}
<circle cx={nd.x} cy={py} r="3" fill="#333" />
{/if}
{/each}
{#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>
<circle
cx={nd.x + nd.width} cy={py} r="4"
fill={portColor(port.port_type)}
stroke={hoveredPort === port.id ? '#fff' : '#333'}
stroke-width={hoveredPort === port.id ? 2 : 0.5}
class="port-circle"
data-port-id={String(port.id)}
onmouseenter={() => { hoveredPort = port.id; }}
onmouseleave={() => { hoveredPort = null; }}
/>
{:else}
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
{/if}
{/each}
<!-- Volume slider + mute button at bottom of node -->
<rect
class="mute-btn"
x={nd.x + nd.width - 24} y={nd.y + nd.height - 17}
width="16" height="12" rx="2"
fill={nd.mute ? '#a44' : '#2a2a3e'}
stroke="#555" stroke-width="0.5"
/>
<text
class="mute-text"
x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
font-size="8" font-family="monospace"
fill={nd.mute ? '#fff' : '#888'}
text-anchor="middle"
>{nd.mute ? 'M' : 'm'}</text>
<!-- Volume bar background (visual only) -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="4" rx="2" fill="#333" />
<!-- Volume bar fill (visual only, clamped to 1.0) -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} height="4" rx="2" fill={nd.mute ? '#666' : '#4a9'} />
<!-- Volume click/drag hit area (tall transparent rect over the whole slider) -->
<rect class="vol-hitarea" x={nd.x + 8} y={nd.y + nd.height - 20} width={nd.width - 36} height="18" fill="transparent" />
<!-- Volume handle circle -->
<circle class="vol-handle" cx={nd.x + 8 + (nd.width - 36) * Math.max(0, Math.min(1, nd.volume))} cy={nd.y + nd.height - 10} r="3.5" fill={nd.mute ? '#888' : '#6cb'} stroke="#fff" stroke-width="1" />
<!-- Volume % label -->
<text x={nd.x + 8} y={nd.y + nd.height - 15} font-size="7" font-family="monospace" fill="#888">
{Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
</text>
</g>
{/each}
</svg>
<!-- Context menu -->
{#if contextMenu}
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
<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}
{#if nodeContextMenu}
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
<button onclick={() => { showPropsDialog = nodeContextMenu!.nodeId; nodeContextMenu = null; }}>Properties</button>
<button onclick={() => {
fetch('/api/destroy-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeContextMenu!.nodeId }),
}).catch(() => {});
nodeContextMenu = null;
}}>Destroy Node</button>
</div>
{/if}
<!-- Properties dialog -->
{#if showPropsDialog !== null}
{@const nd = $nodes.find(n => n.id === showPropsDialog)}
{#if nd}
<div class="dialog">
<div class="dialog-header">
<span>Properties: {nd.nick || nd.name}</span>
<button class="close" onclick={() => { showPropsDialog = null; }}>X</button>
</div>
<div class="dialog-body">
<table class="props-table">
<tbody>
<tr><td class="pk">ID</td><td>{nd.id}</td></tr>
<tr><td class="pk">Name</td><td>{nd.name}</td></tr>
{#if nd.nick}<tr><td class="pk">Nick</td><td>{nd.nick}</td></tr>{/if}
{#if nd.media_name}<tr><td class="pk">Media Name</td><td>{nd.media_name}</td></tr>{/if}
<tr><td class="pk">Class</td><td>{nd.mode} / {nd.node_type}</td></tr>
<tr><td class="pk">Volume</td><td>{Math.round(nd.volume * 100)}% {nd.mute ? '(muted)' : ''}</td></tr>
<tr><td class="pk">Ports</td><td>{nd.port_ids.length}</td></tr>
{#if nd.node_type === 'audio' || nd.channels > 0}
<tr><td class="pk">Channels</td><td>{nd.channels > 0 ? nd.channels : '-'}</td></tr>
<tr><td class="pk">Sample Rate</td><td>{nd.sample_rate > 0 ? nd.sample_rate + ' Hz' : 'default'}</td></tr>
{#if nd.quantum > 0}<tr><td class="pk">Latency</td><td>{nd.quantum} samples{#if nd.sample_rate > 0} ({(nd.quantum / nd.sample_rate * 1000).toFixed(1)} ms){/if}</td></tr>{/if}
{/if}
{#if nd.format}<tr><td class="pk">Format</td><td>{nd.format}</td></tr>{/if}
{#if nd.rate > 0}<tr><td class="pk">Period Size</td><td>{nd.rate}</td></tr>{/if}
{#if nd.device_name}<tr><td class="pk">Device</td><td>{nd.device_name}</td></tr>{/if}
{#if nd.device_bus}<tr><td class="pk">Bus</td><td>{nd.device_bus}</td></tr>{/if}
{#if nd.api}<tr><td class="pk">API</td><td>{nd.api}</td></tr>{/if}
{#if nd.priority > 0}<tr><td class="pk">Priority</td><td>{nd.priority}</td></tr>{/if}
</tbody>
</table>
</div>
</div>
{/if}
{/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>
<style>
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; user-select: none; -webkit-user-select: none; }
.canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; user-select: none; -webkit-user-select: none; }
.toolbar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
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 2px; flex-shrink: 0; }
.filter {
display: flex; align-items: center; gap: 3px; cursor: pointer;
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; }
.filter .fc { width: 8px; height: 8px; border-radius: 2px; }
.filter .fc.audio { background: #4a9; }
.filter .fc.midi { background: #a44; }
.filter .fc.video { background: #49a; }
.filter .fc.other { background: #999; }
.filter:not(.active) { opacity: 0.4; }
.filter:not(.active) .fc { background: #555 !important; }
.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, .toggle:hover { background: #3a3a4e; }
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
.quantum-label { font-size: 10px; color: #888; display: flex; align-items: center; gap: 4px; }
.quantum-select {
padding: 1px 4px; background: #1a1a2e; border: 1px solid #444;
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
cursor: pointer;
}
.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: 5px 20px;
background: none; border: none; color: #ccc;
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
}
.ctx button:hover { background: #444; }
.ctx-title {
padding: 4px 16px; font-size: 9px; color: #666;
border-bottom: 1px solid #444; font-family: monospace;
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 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; }
.props-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.props-table td { padding: 2px 6px; border-bottom: 1px solid #2a2a3e; }
.props-table td:first-child { color: #888; white-space: nowrap; width: 100px; }
.props-table td:last-child { color: #ccc; word-break: break-all; }
.port-circle { cursor: crosshair; }
.port-circle:hover { filter: brightness(1.5); }
.edge-path { cursor: pointer; pointer-events: stroke; }
.edge-path:hover { stroke-width: 4; }
.mute-btn { cursor: pointer; }
.mute-btn:hover { filter: brightness(1.3); }
.mute-text { pointer-events: none; }
.vol-bg { pointer-events: none; }
.vol-bar { pointer-events: none; }
.vol-hitarea { cursor: pointer; }
.vol-handle { cursor: ew-resize; pointer-events: none; }
.vol-handle:hover { filter: brightness(1.3); }
.add-btn { background: #1a3a2a !important; border-color: #4a9 !important; color: #6c9 !important; }
.virt-menu {
position: absolute; top: 30px; z-index: 30;
right: 80px;
background: #2a2a3e; border: 1px solid #555;
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
}
.virt-header {
padding: 4px 16px; font-size: 9px; color: #666; text-transform: uppercase;
letter-spacing: 1px; border-bottom: 1px solid #444;
}
.virt-sep { height: 1px; background: #444; margin: 2px 0; }
.virt-menu button {
display: block; width: 100%; padding: 6px 16px;
background: none; border: none; color: #ccc;
font-size: 11px; cursor: pointer; text-align: left; font-family: monospace;
white-space: nowrap;
}
.virt-menu button:hover { background: #444; }
</style>