Merge feature/full-feature-set into master
This commit is contained in:
@@ -8,119 +8,51 @@
|
|||||||
} from '../lib/stores';
|
} from '../lib/stores';
|
||||||
import type { Node, Port, Link } from '../lib/types';
|
import type { Node, Port, Link } from '../lib/types';
|
||||||
|
|
||||||
// Viewport state
|
// Viewport
|
||||||
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;
|
||||||
|
|
||||||
|
// Interaction state
|
||||||
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 svgEl: SVGElement | null = null;
|
let contextMenu = $state<{ x: number; y: number; outputPortId: number; inputPortId: number } | null>(null);
|
||||||
|
|
||||||
// Node positions
|
// Filters
|
||||||
const POS_KEY = 'pwweb_pos';
|
let showAudio = $state(true);
|
||||||
function loadPos(): Record<string, { x: number; y: number }> {
|
let showMidi = $state(true);
|
||||||
try { return JSON.parse(localStorage.getItem(POS_KEY) || '{}'); } catch { return {}; }
|
let showVideo = $state(true);
|
||||||
}
|
let showOther = $state(true);
|
||||||
function savePos(pos: Record<string, { x: number; y: number }>) {
|
|
||||||
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {}
|
|
||||||
}
|
|
||||||
let nodePositions = $state(loadPos());
|
|
||||||
|
|
||||||
// Layout defaults
|
// Patchbay
|
||||||
function getDefaultPositions(n: Node[]): Record<string, { x: number; y: number }> {
|
let patchbayName = $state('');
|
||||||
const out = n.filter(nd => nd.mode === 'output');
|
|
||||||
const inp = n.filter(nd => nd.mode === 'input');
|
// Positions
|
||||||
const other = n.filter(nd => nd.mode !== 'output' && nd.mode !== 'input');
|
let nodePositions = $state<Record<string, { x: number; y: number }>>({});
|
||||||
const result: Record<string, { x: number; y: number }> = {};
|
const POS_KEY = 'pwweb_positions';
|
||||||
let i = 0;
|
|
||||||
for (const nd of out) {
|
function loadPositions() {
|
||||||
if (!result[nd.id]) result[nd.id] = { x: 0, y: (i % 8) * 100 };
|
// Try localStorage first
|
||||||
i++;
|
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 };
|
||||||
}
|
}
|
||||||
i = 0;
|
}).catch(() => {});
|
||||||
for (const nd of other) {
|
|
||||||
if (!result[nd.id]) result[nd.id] = { x: 320, y: (i % 8) * 100 };
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
i = 0;
|
|
||||||
for (const nd of inp) {
|
|
||||||
if (!result[nd.id]) result[nd.id] = { x: 640, y: (i % 8) * 100 };
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute layout
|
function savePositions() {
|
||||||
let graphNodes = $derived.by(() => {
|
try { localStorage.setItem(POS_KEY, JSON.stringify(nodePositions)); } catch {}
|
||||||
const n = $nodes;
|
fetch('/api/positions', {
|
||||||
const p = $ports;
|
method: 'PUT',
|
||||||
const portMap = new Map<number, Port>();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
for (const port of p) portMap.set(port.id, port);
|
body: JSON.stringify(nodePositions),
|
||||||
|
}).catch(() => {});
|
||||||
const defaults = getDefaultPositions(n);
|
|
||||||
const pos = { ...defaults, ...nodePositions };
|
|
||||||
|
|
||||||
return n.map(nd => {
|
|
||||||
const ndPorts = nd.port_ids.map(pid => portMap.get(pid)).filter(Boolean) as Port[];
|
|
||||||
const inPorts = ndPorts.filter(pp => pp.mode === 'input');
|
|
||||||
const outPorts = ndPorts.filter(pp => pp.mode === 'output');
|
|
||||||
const nodePos = pos[String(nd.id)] || { x: 0, y: 0 };
|
|
||||||
|
|
||||||
// Compute node dimensions
|
|
||||||
const maxPorts = Math.max(inPorts.length, outPorts.length, 1);
|
|
||||||
const headerH = 24;
|
|
||||||
const portH = 18;
|
|
||||||
const height = headerH + maxPorts * portH + 6;
|
|
||||||
const width = 200;
|
|
||||||
|
|
||||||
// Compute port positions
|
|
||||||
const portPositions = new Map<number, { x: number; y: number }>();
|
|
||||||
let yi = headerH;
|
|
||||||
for (const port of inPorts) {
|
|
||||||
portPositions.set(port.id, { x: nodePos.x, y: nodePos.y + yi + portH / 2 });
|
|
||||||
yi += portH;
|
|
||||||
}
|
|
||||||
let yo = headerH;
|
|
||||||
for (const port of outPorts) {
|
|
||||||
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,
|
|
||||||
outPorts,
|
|
||||||
portPositions,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let graphLinks = $derived.by(() => {
|
|
||||||
const l = $links;
|
|
||||||
const p = $ports;
|
|
||||||
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) as Array<Link & { outPort: Port; inPort: Port }>;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Port positions lookup across all nodes
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
function portColor(pt: string): string {
|
function portColor(pt: string): string {
|
||||||
switch (pt) {
|
switch (pt) {
|
||||||
case 'audio': return '#4a9';
|
case 'audio': return '#4a9';
|
||||||
@@ -135,75 +67,137 @@
|
|||||||
return idx >= 0 ? name.substring(idx + 1) : name;
|
return idx >= 0 ? name.substring(idx + 1) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bezier curve between two points
|
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 {
|
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
const dx = Math.abs(x2 - x1) * 0.5;
|
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 30);
|
||||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse events
|
|
||||||
function svgPoint(e: MouseEvent): { x: number; y: number } {
|
function svgPoint(e: MouseEvent): { x: number; y: number } {
|
||||||
if (!svgEl) return { x: e.clientX, y: e.clientY };
|
if (!svgEl) return { x: e.clientX, y: e.clientY };
|
||||||
const pt = (svgEl as any).createSVGPoint();
|
const pt = (svgEl as any).createSVGPoint();
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX; pt.y = e.clientY;
|
||||||
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());
|
const svgP = pt.matrixTransform(ctm.inverse());
|
||||||
return { x: svgP.x, y: svgP.y };
|
return { x: svgP.x, y: svgP.y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed layout
|
||||||
|
let graphNodes = $derived.by(() => {
|
||||||
|
const n = $nodes;
|
||||||
|
const p = $ports;
|
||||||
|
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');
|
||||||
|
|
||||||
|
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 n.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');
|
||||||
|
|
||||||
|
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 + 4;
|
||||||
|
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 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 }>;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse handlers
|
||||||
function onMouseDown(e: MouseEvent) {
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
contextMenu = null;
|
||||||
|
if (e.button === 2) return; // right-click handled by oncontextmenu
|
||||||
const pt = svgPoint(e);
|
const pt = svgPoint(e);
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
// Check if clicking on a port circle
|
|
||||||
if (target.classList.contains('port-circle')) {
|
if (target.classList.contains('port-circle')) {
|
||||||
const portId = Number(target.dataset.portId);
|
const portId = Number(target.dataset.portId);
|
||||||
const port = $portById.get(portId);
|
const port = $portById.get(portId);
|
||||||
if (port && port.mode === 'output') {
|
if (port && port.mode === 'output') {
|
||||||
const pos = getPortPos(portId);
|
const pos = getPortPos(portId);
|
||||||
if (pos) {
|
if (pos) {
|
||||||
connecting = {
|
connecting = { outputPortId: portId, outputX: pos.x, outputY: pos.y, mouseX: pt.x, mouseY: pt.y, portType: port.port_type };
|
||||||
outputPortId: portId,
|
|
||||||
outputX: pos.x,
|
|
||||||
outputY: pos.y,
|
|
||||||
mouseX: pt.x,
|
|
||||||
mouseY: pt.y,
|
|
||||||
portType: port.port_type,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if clicking on a node header (to drag)
|
|
||||||
if (target.classList.contains('node-header') || target.closest('.node-group')) {
|
if (target.classList.contains('node-header') || target.closest('.node-group')) {
|
||||||
const nodeGroup = target.closest('.node-group') as HTMLElement;
|
const nodeGroup = target.closest('.node-group') as HTMLElement;
|
||||||
if (nodeGroup) {
|
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 = {
|
dragging = { type: 'node', startX: pt.x, startY: pt.y, origX: nd.x, origY: nd.y, nodeId };
|
||||||
type: 'node',
|
|
||||||
startX: pt.x,
|
|
||||||
startY: pt.y,
|
|
||||||
origX: nd.x,
|
|
||||||
origY: nd.y,
|
|
||||||
nodeId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if clicking on an edge
|
|
||||||
if (target.classList.contains('edge-path')) {
|
if (target.classList.contains('edge-path')) {
|
||||||
selectedEdge = target.dataset.edgeId || null;
|
selectedEdge = target.dataset.edgeId || null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click on background: pan
|
|
||||||
selectedEdge = null;
|
selectedEdge = null;
|
||||||
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
|
dragging = { type: 'pan', startX: e.clientX, startY: e.clientY, origX: viewBox.x, origY: viewBox.y };
|
||||||
}
|
}
|
||||||
@@ -213,26 +207,19 @@
|
|||||||
const pt = svgPoint(e);
|
const pt = svgPoint(e);
|
||||||
|
|
||||||
if (connecting) {
|
if (connecting) {
|
||||||
connecting.mouseX = pt.x;
|
connecting = { ...connecting, mouseX: pt.x, mouseY: pt.y };
|
||||||
connecting.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;
|
||||||
nodePositions[dragging.nodeId] = {
|
nodePositions[dragging.nodeId] = { x: dragging.origX + dx, y: dragging.origY + dy };
|
||||||
x: dragging.origX + dx,
|
|
||||||
y: dragging.origY + dy,
|
|
||||||
};
|
|
||||||
nodePositions = nodePositions; // trigger reactivity
|
|
||||||
} else if (dragging.type === 'pan') {
|
} else if (dragging.type === 'pan') {
|
||||||
const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
|
const dx = (e.clientX - dragging.startX) * (viewBox.w / (svgEl?.clientWidth || 1));
|
||||||
const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
|
const dy = (e.clientY - dragging.startY) * (viewBox.h / (svgEl?.clientHeight || 1));
|
||||||
viewBox.x = dragging.origX - dx;
|
viewBox = { ...viewBox, x: dragging.origX - dx, y: dragging.origY - dy };
|
||||||
viewBox.y = dragging.origY - dy;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +235,9 @@
|
|||||||
}
|
}
|
||||||
connecting = null;
|
connecting = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dragging?.type === 'node' && dragging.nodeId) {
|
if (dragging?.type === 'node' && dragging.nodeId) {
|
||||||
savePos(nodePositions);
|
savePositions();
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging = null;
|
dragging = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,47 +245,83 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pt = svgPoint(e);
|
const pt = svgPoint(e);
|
||||||
const factor = e.deltaY > 0 ? 1.1 : 0.9;
|
const factor = e.deltaY > 0 ? 1.1 : 0.9;
|
||||||
const nw = viewBox.w * factor;
|
viewBox = {
|
||||||
const nh = viewBox.h * factor;
|
x: pt.x - (pt.x - viewBox.x) * factor,
|
||||||
viewBox.x = pt.x - (pt.x - viewBox.x) * factor;
|
y: pt.y - (pt.y - viewBox.y) * factor,
|
||||||
viewBox.y = pt.y - (pt.y - viewBox.y) * factor;
|
w: viewBox.w * factor,
|
||||||
viewBox.w = nw;
|
h: viewBox.h * factor,
|
||||||
viewBox.h = nh;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEdgeDblClick(e: MouseEvent) {
|
function onContextMenu(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.classList.contains('edge-path')) {
|
if (target.classList.contains('edge-path')) {
|
||||||
const edgeId = target.dataset.edgeId;
|
const edgeId = target.dataset.edgeId;
|
||||||
const link = $links.find(l => String(l.id) === edgeId);
|
const link = $links.find(l => String(l.id) === edgeId);
|
||||||
if (link) {
|
if (link) {
|
||||||
disconnectPorts(link.output_port_id, link.input_port_id);
|
selectedEdge = edgeId || null;
|
||||||
|
contextMenu = { x: e.clientX, y: e.clientY, outputPortId: link.output_port_id, inputPortId: link.input_port_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);
|
||||||
if (link) {
|
if (link) { disconnectPorts(link.output_port_id, link.input_port_id); selectedEdge = null; }
|
||||||
disconnectPorts(link.output_port_id, link.input_port_id);
|
|
||||||
selectedEdge = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => { initGraph(); });
|
// 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(); });
|
onDestroy(() => { destroyGraph(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="bar">
|
<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="st">{$nodes.length} nodes | {$ports.length} ports | {$links.length} links</span>
|
|
||||||
<span class="help">Drag port->port to connect. Dbl-click wire to disconnect. Scroll to zoom. Drag bg to pan.</span>
|
<span class="sep"></span>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -311,9 +332,15 @@
|
|||||||
onmousemove={onMouseMove}
|
onmousemove={onMouseMove}
|
||||||
onmouseup={onMouseUp}
|
onmouseup={onMouseUp}
|
||||||
onwheel={onWheel}
|
onwheel={onWheel}
|
||||||
ondblclick={onEdgeDblClick}
|
oncontextmenu={onContextMenu}
|
||||||
class="canvas"
|
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 -->
|
<!-- Edges -->
|
||||||
{#each graphLinks as link (link.id)}
|
{#each graphLinks as link (link.id)}
|
||||||
{@const outPos = getPortPos(link.output_port_id)}
|
{@const outPos = getPortPos(link.output_port_id)}
|
||||||
@@ -323,15 +350,16 @@
|
|||||||
<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={selectedEdge === String(link.id) ? '#fff' : color}
|
||||||
stroke-width={selectedEdge === String(link.id) ? 3 : 2}
|
stroke-width={selectedEdge === String(link.id) ? 3.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"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Connecting line (while dragging) -->
|
<!-- Connecting line -->
|
||||||
{#if connecting}
|
{#if connecting}
|
||||||
<path
|
<path
|
||||||
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
|
d={bezierPath(connecting.outputX, connecting.outputY, connecting.mouseX, connecting.mouseY)}
|
||||||
@@ -339,158 +367,138 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-dasharray="6 3"
|
stroke-dasharray="6 3"
|
||||||
fill="none"
|
fill="none"
|
||||||
opacity="0.8"
|
opacity="0.7"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Nodes -->
|
<!-- Nodes -->
|
||||||
{#each graphNodes as nd (nd.id)}
|
{#each graphNodes as nd (nd.id)}
|
||||||
{@const headerColor = nd.mode === 'output' ? '#2a3a2a' : nd.mode === 'input' ? '#3a2a2a' : '#2a2a3a'}
|
{@const isSource = nd.mode === 'output'}
|
||||||
{@const borderColor = nd.mode === 'output' ? '#4a9' : nd.mode === 'input' ? '#a44' : '#555'}
|
{@const isSink = nd.mode === 'input'}
|
||||||
|
{@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'}
|
||||||
|
{@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'}
|
||||||
|
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'}
|
||||||
|
|
||||||
<g class="node-group" data-node-id={String(nd.id)}>
|
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
|
||||||
<!-- Background -->
|
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
|
||||||
<rect
|
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
|
||||||
x={nd.x} y={nd.y}
|
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
|
||||||
width={nd.width} height={nd.height}
|
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
|
||||||
rx="5" ry="5"
|
{nd.nick || nd.name}
|
||||||
fill="#1e1e2e"
|
</text>
|
||||||
stroke={borderColor}
|
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
|
||||||
stroke-width="1"
|
[{nd.node_type}]
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<rect
|
|
||||||
x={nd.x} y={nd.y}
|
|
||||||
width={nd.width} height="24"
|
|
||||||
rx="5" ry="5"
|
|
||||||
fill={headerColor}
|
|
||||||
class="node-header"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x={nd.x} y={nd.y + 18}
|
|
||||||
width={nd.width} height="6"
|
|
||||||
fill={headerColor}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={nd.x + 6} y={nd.y + 16}
|
|
||||||
font-size="11" font-family="monospace"
|
|
||||||
fill="#ddd" font-weight="bold"
|
|
||||||
>
|
|
||||||
{nd.name.length > 28 ? nd.name.substring(0, 25) + '...' : nd.name}
|
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Input ports (left side) -->
|
|
||||||
{#each nd.inPorts as port, i (port.id)}
|
{#each nd.inPorts as port, i (port.id)}
|
||||||
{@const py = nd.y + 24 + i * 18 + 9}
|
{@const py = nd.y + 22 + i * 16 + 8}
|
||||||
|
{#if isPortVisible(port.port_type)}
|
||||||
<circle
|
<circle
|
||||||
cx={nd.x} cy={py}
|
cx={nd.x} cy={py} r="4"
|
||||||
r="5"
|
|
||||||
fill={portColor(port.port_type)}
|
fill={portColor(port.port_type)}
|
||||||
stroke="#fff"
|
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
||||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||||
class="port-circle"
|
class="port-circle"
|
||||||
data-port-id={String(port.id)}
|
data-port-id={String(port.id)}
|
||||||
onmouseenter={() => { hoveredPort = port.id; }}
|
onmouseenter={() => { hoveredPort = port.id; }}
|
||||||
onmouseleave={() => { hoveredPort = null; }}
|
onmouseleave={() => { hoveredPort = null; }}
|
||||||
/>
|
/>
|
||||||
<text
|
<text x={nd.x + 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999">
|
||||||
x={nd.x + 10} y={py + 4}
|
{shortName(port.name)}
|
||||||
font-size="10" font-family="monospace"
|
</text>
|
||||||
fill="#aaa"
|
{:else}
|
||||||
>{shortName(port.name)}</text>
|
<circle cx={nd.x} cy={py} r="3" fill="#333" />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Output ports (right side) -->
|
|
||||||
{#each nd.outPorts as port, i (port.id)}
|
{#each nd.outPorts as port, i (port.id)}
|
||||||
{@const py = nd.y + 24 + i * 18 + 9}
|
{@const py = nd.y + 22 + i * 16 + 8}
|
||||||
<text
|
{#if isPortVisible(port.port_type)}
|
||||||
x={nd.x + nd.width - 10} y={py + 4}
|
<text x={nd.x + nd.width - 8} y={py + 3.5} font-size="9" font-family="monospace" fill="#999" text-anchor="end">
|
||||||
font-size="10" font-family="monospace"
|
{shortName(port.name)}
|
||||||
fill="#aaa"
|
</text>
|
||||||
text-anchor="end"
|
|
||||||
>{shortName(port.name)}</text>
|
|
||||||
<circle
|
<circle
|
||||||
cx={nd.x + nd.width} cy={py}
|
cx={nd.x + nd.width} cy={py} r="4"
|
||||||
r="5"
|
|
||||||
fill={portColor(port.port_type)}
|
fill={portColor(port.port_type)}
|
||||||
stroke="#fff"
|
stroke={hoveredPort === port.id ? '#fff' : '#333'}
|
||||||
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
stroke-width={hoveredPort === port.id ? 2 : 0.5}
|
||||||
class="port-circle"
|
class="port-circle"
|
||||||
data-port-id={String(port.id)}
|
data-port-id={String(port.id)}
|
||||||
onmouseenter={() => { hoveredPort = port.id; }}
|
onmouseenter={() => { hoveredPort = port.id; }}
|
||||||
onmouseleave={() => { hoveredPort = null; }}
|
onmouseleave={() => { hoveredPort = null; }}
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{#if contextMenu}
|
||||||
|
<div class="ctx" style="left:{contextMenu.x}px;top:{contextMenu.y}px" role="menu">
|
||||||
|
<button onclick={doDisconnect}>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrap {
|
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
||||||
width: 100%;
|
.canvas { width: 100%; height: 100%; display: block; cursor: default; }
|
||||||
height: 100vh;
|
|
||||||
background: #1a1a2e;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas {
|
.toolbar {
|
||||||
width: 100%;
|
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
height: 100%;
|
display: flex; align-items: center; gap: 8px;
|
||||||
display: block;
|
padding: 4px 12px;
|
||||||
cursor: default;
|
background: rgba(16, 16, 24, 0.97);
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 16px;
|
|
||||||
background: rgba(20, 20, 30, 0.95);
|
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
font-size: 12px;
|
font-size: 11px; color: #aaa; font-family: monospace;
|
||||||
color: #aaa;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #a44;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
.st { margin-left: auto; color: #666; }
|
.filter {
|
||||||
.help { color: #555; font-size: 11px; margin-left: 16px; }
|
display: flex; align-items: center; gap: 3px; cursor: pointer;
|
||||||
|
padding: 2px 5px; border-radius: 3px; font-size: 10px;
|
||||||
.port-circle {
|
|
||||||
cursor: crosshair;
|
|
||||||
}
|
}
|
||||||
.port-circle:hover {
|
.filter:hover { background: rgba(255,255,255,0.05); }
|
||||||
filter: brightness(1.5);
|
.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 {
|
||||||
|
padding: 2px 8px; background: #2a2a3e; border: 1px solid #444;
|
||||||
|
color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-path {
|
.stats { margin-left: auto; color: #555; }
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: stroke;
|
|
||||||
}
|
|
||||||
.edge-path:hover {
|
|
||||||
stroke-width: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-header {
|
.ctx {
|
||||||
cursor: grab;
|
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);
|
||||||
}
|
}
|
||||||
.node-group:hover rect {
|
.ctx button {
|
||||||
filter: brightness(1.1);
|
display: block; width: 100%; padding: 6px 20px;
|
||||||
|
background: none; border: none; color: #ccc;
|
||||||
|
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
|
||||||
}
|
}
|
||||||
|
.ctx button:hover { background: #444; }
|
||||||
|
|
||||||
|
.port-circle { cursor: crosshair; }
|
||||||
|
.port-circle:hover { filter: brightness(1.5); }
|
||||||
|
.edge-path { cursor: pointer; pointer-events: stroke; }
|
||||||
|
.edge-path:hover { stroke-width: 4; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,9 +4,40 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <pwd.h>
|
||||||
|
|
||||||
using namespace pwgraph;
|
using namespace pwgraph;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// File I/O helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
std::string WebServer::dataDir() const {
|
||||||
|
const char *home = getenv("HOME");
|
||||||
|
if (!home) {
|
||||||
|
auto *pw = getpwuid(getuid());
|
||||||
|
if (pw) home = pw->pw_dir;
|
||||||
|
}
|
||||||
|
std::string dir = (home ? home : "/tmp");
|
||||||
|
dir += "/.config/pwweb";
|
||||||
|
mkdir(dir.c_str(), 0755);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string WebServer::readFile(const std::string &path) const {
|
||||||
|
std::ifstream f(path);
|
||||||
|
if (!f.is_open()) return "{}";
|
||||||
|
return std::string((std::istreambuf_iterator<char>(f)),
|
||||||
|
std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebServer::writeFile(const std::string &path, const std::string &data) const {
|
||||||
|
std::ofstream f(path);
|
||||||
|
if (f.is_open()) f << data;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// JSON serialization helpers
|
// JSON serialization helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -309,6 +340,69 @@ void WebServer::setupRoutes() {
|
|||||||
};
|
};
|
||||||
m_http.Options("/api/connect", cors_handler);
|
m_http.Options("/api/connect", cors_handler);
|
||||||
m_http.Options("/api/disconnect", cors_handler);
|
m_http.Options("/api/disconnect", cors_handler);
|
||||||
|
m_http.Options("/api/positions", cors_handler);
|
||||||
|
m_http.Options("/api/patchbay", cors_handler);
|
||||||
|
|
||||||
|
// Positions persistence: GET /api/positions
|
||||||
|
m_http.Get("/api/positions", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/positions.json";
|
||||||
|
res.set_content(readFile(path), "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Positions persistence: PUT /api/positions
|
||||||
|
m_http.Put("/api/positions", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/positions.json";
|
||||||
|
writeFile(path, req.body);
|
||||||
|
res.set_content("{\"ok\":true}", "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Patchbay: GET /api/patchbay
|
||||||
|
m_http.Get("/api/patchbay", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/patchbay.json";
|
||||||
|
res.set_content(readFile(path), "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Patchbay: PUT /api/patchbay (save current connections as a named snapshot)
|
||||||
|
m_http.Put("/api/patchbay", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/patchbay.json";
|
||||||
|
writeFile(path, req.body);
|
||||||
|
res.set_content("{\"ok\":true}", "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Patchbay: POST /api/patchbay/apply (apply saved connections to current graph)
|
||||||
|
m_http.Post("/api/patchbay/apply", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
// req.body is a JSON array of {output_port_id, input_port_id} pairs
|
||||||
|
// We just parse it and connect each pair
|
||||||
|
// Simple parsing: find all pairs
|
||||||
|
std::string body = req.body;
|
||||||
|
size_t pos = 0;
|
||||||
|
int connected = 0;
|
||||||
|
while (pos < body.size()) {
|
||||||
|
uint32_t out_id = 0, in_id = 0;
|
||||||
|
int chars = 0;
|
||||||
|
if (sscanf(body.c_str() + pos,
|
||||||
|
"{\"output_port_id\":%u,\"input_port_id\":%u}%n",
|
||||||
|
&out_id, &in_id, &chars) == 2 ||
|
||||||
|
sscanf(body.c_str() + pos,
|
||||||
|
"{\"output_port_id\":%u, \"input_port_id\":%u}%n",
|
||||||
|
&out_id, &in_id, &chars) == 2)
|
||||||
|
{
|
||||||
|
if (m_engine.connectPorts(out_id, in_id))
|
||||||
|
connected++;
|
||||||
|
}
|
||||||
|
pos = body.find('{', pos + 1);
|
||||||
|
if (pos == std::string::npos) break;
|
||||||
|
}
|
||||||
|
if (connected > 0) broadcastGraph();
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "{\"ok\":true,\"connected\":%d}", connected);
|
||||||
|
res.set_content(buf, "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// end of web_server.cpp
|
// end of web_server.cpp
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <functional>
|
#include <string>
|
||||||
|
|
||||||
namespace pwgraph {
|
namespace pwgraph {
|
||||||
|
|
||||||
@@ -17,13 +17,14 @@ public:
|
|||||||
|
|
||||||
bool start();
|
bool start();
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
// Broadcast graph update to all connected SSE clients
|
|
||||||
void broadcastGraph();
|
void broadcastGraph();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupRoutes();
|
void setupRoutes();
|
||||||
std::string buildGraphJson() const;
|
std::string buildGraphJson() const;
|
||||||
|
std::string dataDir() const;
|
||||||
|
std::string readFile(const std::string &path) const;
|
||||||
|
void writeFile(const std::string &path, const std::string &data) const;
|
||||||
|
|
||||||
GraphEngine &m_engine;
|
GraphEngine &m_engine;
|
||||||
int m_port;
|
int m_port;
|
||||||
@@ -31,7 +32,6 @@ private:
|
|||||||
std::thread m_thread;
|
std::thread m_thread;
|
||||||
std::atomic<bool> m_running;
|
std::atomic<bool> m_running;
|
||||||
|
|
||||||
// SSE clients
|
|
||||||
mutable std::mutex m_sse_mutex;
|
mutable std::mutex m_sse_mutex;
|
||||||
std::set<httplib::DataSink *> m_sse_clients;
|
std::set<httplib::DataSink *> m_sse_clients;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user