Add visual graph editor for entity interconnections
SVG-based node graph with ELK.js autolayout showing all 13 entity types and their relationships. Features include: - Pan/zoom canvas with bounds clamping and dead-zone click detection - Interactive minimap with viewport rectangle, click-to-pan, drag-to-move, and dual resize handles (bottom-left/bottom-right) - Movable toolbar with drag handle and inline zoom percentage indicator - Entity-type SVG icons from Lucide icon set with subtype-specific overrides - Command palette search (/) with keyboard navigation and fly-to - Node selection with upstream/downstream chain highlighting - Double-click to zoom-to-card, edit/delete overlay on hover - Legend panel, orphan node detection, running state indicators - Full i18n support with languageChanged re-render - Catmull-Rom-to-cubic bezier edge routing for smooth curves Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -144,3 +144,15 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
|
||||
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
|
||||
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
|
||||
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
|
||||
|
||||
### Dynamic content and language changes
|
||||
|
||||
When a feature module generates HTML with baked-in `t()` calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the `languageChanged` event and re-render:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (_initialized) _reRender();
|
||||
});
|
||||
```
|
||||
|
||||
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
||||
|
||||
606
server/src/wled_controller/static/css/graph-editor.css
Normal file
606
server/src/wled_controller/static/css/graph-editor.css
Normal file
@@ -0,0 +1,606 @@
|
||||
/* ── Graph Editor ─────────────────────────────────────── */
|
||||
|
||||
/* Full viewport: hide footer & scroll-to-top when graph is active */
|
||||
#tab-graph #graph-editor-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height, 60px) - 20px);
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 20;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.graph-toolbar-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
cursor: grab;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
margin-right: 2px;
|
||||
padding-right: 2px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.graph-toolbar-drag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-toolbar .btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.graph-toolbar .btn-icon:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.graph-toolbar .btn-icon.active {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
.graph-toolbar-sep {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
/* ── Legend panel ── */
|
||||
|
||||
.graph-legend {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
font-size: 0.8rem;
|
||||
display: none;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.graph-legend.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-legend-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.graph-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Minimap ── */
|
||||
|
||||
.graph-minimap {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
min-width: 120px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.graph-minimap.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-minimap svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.graph-minimap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 3px 6px;
|
||||
cursor: grab;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-minimap-header.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-minimap-resize {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.graph-minimap-resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-bottom: 2px solid var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.graph-minimap-resize-br {
|
||||
right: 0;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.graph-minimap-resize-br::after {
|
||||
right: 2px;
|
||||
border-right: 2px solid var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-minimap-resize-bl {
|
||||
left: 0;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.graph-minimap-resize-bl::after {
|
||||
left: 2px;
|
||||
border-left: 2px solid var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-minimap-viewport {
|
||||
fill: var(--primary-color);
|
||||
fill-opacity: 0.08;
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 2;
|
||||
stroke-opacity: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.graph-minimap-node {
|
||||
rx: 2;
|
||||
ry: 2;
|
||||
}
|
||||
|
||||
/* ── SVG canvas ── */
|
||||
|
||||
.graph-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.graph-svg.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-svg.connecting {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ── Grid background ── */
|
||||
|
||||
.graph-grid-dot {
|
||||
fill: var(--border-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── Node styles ── */
|
||||
|
||||
.graph-node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.graph-node-body {
|
||||
fill: var(--card-bg);
|
||||
stroke: var(--border-color);
|
||||
stroke-width: 1;
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
transition: stroke 0.15s;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-node-body {
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-node.selected .graph-node-body {
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.graph-node-color-bar {
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
}
|
||||
|
||||
/* Clip the right side of color bar to be square */
|
||||
.graph-node-color-bar-clip rect {
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
}
|
||||
|
||||
.graph-node-title {
|
||||
fill: var(--text-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.graph-node-subtitle {
|
||||
fill: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.graph-node-icon {
|
||||
stroke: var(--text-muted);
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Running indicator (animated gradient border) ── */
|
||||
|
||||
.graph-node.running .graph-node-body {
|
||||
stroke: url(#running-gradient);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
@keyframes graph-running-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.graph-node.running .graph-node-running-ring {
|
||||
animation: graph-running-rotate 3s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* Running dot indicator */
|
||||
.graph-node-running-dot {
|
||||
r: 4;
|
||||
fill: var(--success-color);
|
||||
}
|
||||
|
||||
/* ── Ports ── */
|
||||
|
||||
.graph-port {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.graph-port circle {
|
||||
fill: var(--bg-secondary);
|
||||
stroke: var(--text-muted);
|
||||
stroke-width: 1.5;
|
||||
r: 5;
|
||||
transition: fill 0.15s, stroke 0.15s, r 0.15s;
|
||||
}
|
||||
|
||||
.graph-port:hover circle {
|
||||
fill: var(--primary-color);
|
||||
stroke: var(--primary-color);
|
||||
r: 6;
|
||||
}
|
||||
|
||||
.graph-port.connected circle {
|
||||
fill: var(--text-secondary);
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-port-label {
|
||||
fill: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Edges ── */
|
||||
|
||||
.graph-edge {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, stroke-width 0.15s;
|
||||
}
|
||||
|
||||
.graph-edge:hover {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.graph-edge-arrow {
|
||||
fill: currentColor;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.graph-edge.highlighted {
|
||||
opacity: 1;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.graph-edge.dimmed {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* Edge type colors */
|
||||
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
|
||||
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
|
||||
.graph-edge-value { stroke: #FFA726; color: #FFA726; }
|
||||
.graph-edge-device { stroke: #78909C; color: #78909C; }
|
||||
.graph-edge-clock { stroke: #26C6DA; color: #26C6DA; stroke-dasharray: 6 3; }
|
||||
.graph-edge-audio { stroke: #EF5350; color: #EF5350; }
|
||||
.graph-edge-template { stroke: #AB47BC; color: #AB47BC; stroke-dasharray: 4 2; }
|
||||
.graph-edge-scene { stroke: #CE93D8; color: #CE93D8; }
|
||||
.graph-edge-default { stroke: var(--text-muted); color: var(--text-muted); }
|
||||
|
||||
/* Animated flow dots on active edges */
|
||||
.graph-edge-flow {
|
||||
fill: none;
|
||||
stroke-width: 0;
|
||||
}
|
||||
|
||||
.graph-edge-flow circle {
|
||||
r: 3;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Drag connection preview ── */
|
||||
|
||||
.graph-drag-edge {
|
||||
fill: none;
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 3;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Hover overlay (action buttons) ── */
|
||||
|
||||
.graph-node-overlay {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-node:hover .graph-node-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-node-overlay-bg {
|
||||
fill: var(--card-bg);
|
||||
stroke: var(--border-color);
|
||||
stroke-width: 1;
|
||||
rx: 6;
|
||||
ry: 6;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn {
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn rect {
|
||||
fill: transparent;
|
||||
rx: 4;
|
||||
ry: 4;
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn:hover rect {
|
||||
fill: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn text {
|
||||
fill: var(--text-color);
|
||||
font-size: 14px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn.danger:hover rect {
|
||||
fill: var(--danger-color);
|
||||
}
|
||||
|
||||
.graph-node-overlay-btn.danger:hover text {
|
||||
fill: var(--primary-contrast);
|
||||
}
|
||||
|
||||
/* ── Selection rectangle ── */
|
||||
|
||||
.graph-selection-rect {
|
||||
fill: var(--primary-color);
|
||||
fill-opacity: 0.08;
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
|
||||
/* ── Orphan warning ── */
|
||||
|
||||
.graph-node.orphan .graph-node-body {
|
||||
stroke: var(--warning-color);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
/* ── Search highlight ── */
|
||||
|
||||
.graph-node.search-match .graph-node-body {
|
||||
stroke: var(--info-color);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* ── Zoom label (inline in toolbar) ── */
|
||||
|
||||
.graph-zoom-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-empty h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.graph-empty p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Graph command palette ── */
|
||||
|
||||
.graph-search {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 30;
|
||||
width: 360px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px var(--shadow-color);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.graph-search.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.graph-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.graph-search-results {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.graph-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.graph-search-item:hover,
|
||||
.graph-search-item.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.graph-search-item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.graph-search-item-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.graph-search-item-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Loading overlay for relayout ── */
|
||||
|
||||
.graph-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -157,6 +157,13 @@ import {
|
||||
updateCalibrationLine, resetCalibrationView,
|
||||
} from './features/advanced-calibration.js';
|
||||
|
||||
// Layer 5.5: graph editor
|
||||
import {
|
||||
loadGraphEditor, openGraphSearch, closeGraphSearch,
|
||||
toggleGraphLegend, toggleGraphMinimap,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
|
||||
} from './features/graph-editor.js';
|
||||
|
||||
// Layer 6: tabs, navigation, command palette, settings
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
||||
import { navigateToCard } from './core/navigation.js';
|
||||
@@ -455,6 +462,17 @@ Object.assign(window, {
|
||||
updateCalibrationLine,
|
||||
resetCalibrationView,
|
||||
|
||||
// graph editor
|
||||
loadGraphEditor,
|
||||
openGraphSearch,
|
||||
closeGraphSearch,
|
||||
toggleGraphLegend,
|
||||
toggleGraphMinimap,
|
||||
graphFitAll,
|
||||
graphZoomIn,
|
||||
graphZoomOut,
|
||||
graphRelayout,
|
||||
|
||||
// tabs / navigation / command palette
|
||||
switchTab,
|
||||
startAutoRefresh,
|
||||
@@ -488,7 +506,7 @@ document.addEventListener('keydown', (e) => {
|
||||
|
||||
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
||||
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
||||
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams' };
|
||||
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'graph' };
|
||||
const tab = tabMap[e.key];
|
||||
if (tab) {
|
||||
e.preventDefault();
|
||||
|
||||
233
server/src/wled_controller/static/js/core/graph-canvas.js
Normal file
233
server/src/wled_controller/static/js/core/graph-canvas.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Graph SVG canvas — pan, zoom, viewBox management.
|
||||
*/
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_SENSITIVITY = 0.001;
|
||||
const PAN_DEAD_ZONE = 4; // px before drag starts
|
||||
const BOUNDS_MARGIN_FACTOR = 0.5; // allow panning half a viewport past data bounds
|
||||
|
||||
export class GraphCanvas {
|
||||
/**
|
||||
* @param {SVGSVGElement} svg
|
||||
*/
|
||||
constructor(svg) {
|
||||
this.svg = svg;
|
||||
/** @type {SVGGElement} */
|
||||
this.root = svg.querySelector('.graph-root');
|
||||
this._vx = 0;
|
||||
this._vy = 0;
|
||||
this._zoom = 1;
|
||||
this._panning = false;
|
||||
this._panPending = false; // left-click pending dead-zone check
|
||||
this._panStart = null;
|
||||
this._panViewStart = null;
|
||||
this._listeners = [];
|
||||
this._onZoomChange = null;
|
||||
this._onViewChange = null; // called on every pan/zoom with getViewport()
|
||||
this._justPanned = false; // true briefly after a pan ends, survives through click event
|
||||
this._bounds = null; // data bounds for view clamping {x, y, width, height}
|
||||
/** Set to true externally to suppress pan on left-click (e.g. during node drag). */
|
||||
this.blockPan = false;
|
||||
this._bind();
|
||||
}
|
||||
|
||||
get zoom() { return this._zoom; }
|
||||
get viewX() { return this._vx; }
|
||||
get viewY() { return this._vy; }
|
||||
/** True while the user is actively panning (drag started). */
|
||||
get isPanning() { return this._panning; }
|
||||
/** True briefly after a pan gesture ends — use to suppress click-after-pan. */
|
||||
get wasPanning() { return this._justPanned; }
|
||||
|
||||
set onZoomChange(fn) { this._onZoomChange = fn; }
|
||||
set onViewChange(fn) { this._onViewChange = fn; }
|
||||
|
||||
/** Set data bounds for view clamping. */
|
||||
setBounds(bounds) { this._bounds = bounds; }
|
||||
|
||||
/** Get the visible viewport in graph coordinates. */
|
||||
getViewport() {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
return {
|
||||
x: this._vx,
|
||||
y: this._vy,
|
||||
width: r.width / this._zoom,
|
||||
height: r.height / this._zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert screen (client) coordinates to graph coordinates. */
|
||||
screenToGraph(sx, sy) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
return {
|
||||
x: (sx - r.left) / this._zoom + this._vx,
|
||||
y: (sy - r.top) / this._zoom + this._vy,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set view to center on a point at current zoom. */
|
||||
panTo(gx, gy, animate = true) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
this._vx = gx - (r.width / this._zoom) / 2;
|
||||
this._vy = gy - (r.height / this._zoom) / 2;
|
||||
this._applyTransform(animate);
|
||||
}
|
||||
|
||||
/** Fit all content within the viewport with padding. */
|
||||
fitAll(bounds, animate = true) {
|
||||
if (!bounds) return;
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
const pad = 60;
|
||||
const bw = bounds.width + pad * 2;
|
||||
const bh = bounds.height + pad * 2;
|
||||
const zx = r.width / bw;
|
||||
const zy = r.height / bh;
|
||||
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(zx, zy)));
|
||||
this._vx = bounds.x - pad - (r.width / this._zoom - bw) / 2;
|
||||
this._vy = bounds.y - pad - (r.height / this._zoom - bh) / 2;
|
||||
this._applyTransform(animate);
|
||||
if (this._onZoomChange) this._onZoomChange(this._zoom);
|
||||
}
|
||||
|
||||
/** Set zoom level centered on screen point. */
|
||||
zoomTo(level, cx, cy) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
const mx = cx !== undefined ? cx : r.width / 2 + r.left;
|
||||
const my = cy !== undefined ? cy : r.height / 2 + r.top;
|
||||
const gx = (mx - r.left) / this._zoom + this._vx;
|
||||
const gy = (my - r.top) / this._zoom + this._vy;
|
||||
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
|
||||
this._vx = gx - (mx - r.left) / this._zoom;
|
||||
this._vy = gy - (my - r.top) / this._zoom;
|
||||
this._applyTransform(false);
|
||||
if (this._onZoomChange) this._onZoomChange(this._zoom);
|
||||
}
|
||||
|
||||
/** Set zoom and center on a graph-space point in one animated step. */
|
||||
zoomToPoint(level, gx, gy) {
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
|
||||
this._vx = gx - (r.width / this._zoom) / 2;
|
||||
this._vy = gy - (r.height / this._zoom) / 2;
|
||||
this._applyTransform(true);
|
||||
if (this._onZoomChange) this._onZoomChange(this._zoom);
|
||||
}
|
||||
|
||||
zoomIn() { this.zoomTo(this._zoom * 1.25); }
|
||||
zoomOut() { this.zoomTo(this._zoom / 1.25); }
|
||||
|
||||
destroy() {
|
||||
for (const [el, ev, fn, opts] of this._listeners) {
|
||||
el.removeEventListener(ev, fn, opts);
|
||||
}
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
_on(el, ev, fn, opts) {
|
||||
el.addEventListener(ev, fn, opts);
|
||||
this._listeners.push([el, ev, fn, opts]);
|
||||
}
|
||||
|
||||
_bind() {
|
||||
this._on(this.svg, 'wheel', this._onWheel.bind(this), { passive: false });
|
||||
this._on(this.svg, 'pointerdown', this._onPointerDown.bind(this));
|
||||
this._on(window, 'pointermove', this._onPointerMove.bind(this));
|
||||
this._on(window, 'pointerup', this._onPointerUp.bind(this));
|
||||
}
|
||||
|
||||
_onWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * ZOOM_SENSITIVITY;
|
||||
const newZoom = this._zoom * (1 + delta);
|
||||
this.zoomTo(newZoom, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
_onPointerDown(e) {
|
||||
// Middle button or Ctrl/Meta+left → immediate pan
|
||||
if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
this._startPan(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Left-click on SVG background or edge (not on a node) → pending pan
|
||||
if (e.button === 0 && !this.blockPan) {
|
||||
const onNode = e.target.closest('.graph-node');
|
||||
if (!onNode) {
|
||||
this._panPending = true;
|
||||
this._panStart = { x: e.clientX, y: e.clientY };
|
||||
this._panViewStart = { x: this._vx, y: this._vy };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onPointerMove(e) {
|
||||
// Check dead-zone for pending left-click pan
|
||||
if (this._panPending && !this._panning) {
|
||||
const dx = e.clientX - this._panStart.x;
|
||||
const dy = e.clientY - this._panStart.y;
|
||||
if (Math.abs(dx) > PAN_DEAD_ZONE || Math.abs(dy) > PAN_DEAD_ZONE) {
|
||||
this._panning = true;
|
||||
this.svg.classList.add('panning');
|
||||
this.svg.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._panning) return;
|
||||
const dx = (e.clientX - this._panStart.x) / this._zoom;
|
||||
const dy = (e.clientY - this._panStart.y) / this._zoom;
|
||||
this._vx = this._panViewStart.x - dx;
|
||||
this._vy = this._panViewStart.y - dy;
|
||||
this._applyTransform(false);
|
||||
}
|
||||
|
||||
_onPointerUp() {
|
||||
this._panPending = false;
|
||||
if (this._panning) {
|
||||
this._panning = false;
|
||||
this._justPanned = true;
|
||||
this.svg.classList.remove('panning');
|
||||
// Clear justPanned after the click event fires (next microtask + rAF)
|
||||
requestAnimationFrame(() => { this._justPanned = false; });
|
||||
}
|
||||
}
|
||||
|
||||
_startPan(e) {
|
||||
this._panning = true;
|
||||
this._panPending = false;
|
||||
this._panStart = { x: e.clientX, y: e.clientY };
|
||||
this._panViewStart = { x: this._vx, y: this._vy };
|
||||
this.svg.classList.add('panning');
|
||||
this.svg.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
_clampView() {
|
||||
if (!this._bounds) return;
|
||||
const r = this.svg.getBoundingClientRect();
|
||||
const vw = r.width / this._zoom;
|
||||
const vh = r.height / this._zoom;
|
||||
const mx = vw * BOUNDS_MARGIN_FACTOR;
|
||||
const my = vh * BOUNDS_MARGIN_FACTOR;
|
||||
const b = this._bounds;
|
||||
this._vx = Math.max(b.x - mx, Math.min(b.x + b.width - vw + mx, this._vx));
|
||||
this._vy = Math.max(b.y - my, Math.min(b.y + b.height - vh + my, this._vy));
|
||||
}
|
||||
|
||||
_applyTransform(animate = false) {
|
||||
this._clampView();
|
||||
const t = `scale(${this._zoom}) translate(${-this._vx}px, ${-this._vy}px)`;
|
||||
if (animate) {
|
||||
this.root.style.transition = 'transform 0.35s ease';
|
||||
this.root.style.transform = t;
|
||||
setTimeout(() => { this.root.style.transition = ''; }, 360);
|
||||
} else {
|
||||
this.root.style.transition = '';
|
||||
this.root.style.transform = t;
|
||||
}
|
||||
if (this._onViewChange) this._onViewChange(this.getViewport());
|
||||
}
|
||||
}
|
||||
169
server/src/wled_controller/static/js/core/graph-edges.js
Normal file
169
server/src/wled_controller/static/js/core/graph-edges.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* SVG edge rendering for the graph editor — bezier curves with arrowheads.
|
||||
*/
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svgEl(tag, attrs = {}) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all edges into the given SVG group.
|
||||
* @param {SVGGElement} group
|
||||
* @param {Array} edges - [{from, to, type, points, fromNode, toNode}]
|
||||
*/
|
||||
export function renderEdges(group, edges) {
|
||||
while (group.firstChild) group.firstChild.remove();
|
||||
|
||||
// Defs for arrowheads
|
||||
const defs = svgEl('defs');
|
||||
const arrowTypes = new Set(edges.map(e => e.type));
|
||||
for (const type of arrowTypes) {
|
||||
defs.appendChild(_createArrowMarker(type));
|
||||
}
|
||||
group.appendChild(defs);
|
||||
|
||||
for (const edge of edges) {
|
||||
const path = _renderEdge(edge);
|
||||
group.appendChild(path);
|
||||
}
|
||||
}
|
||||
|
||||
function _createArrowMarker(type) {
|
||||
const marker = svgEl('marker', {
|
||||
id: `arrow-${type}`,
|
||||
viewBox: '0 0 10 10',
|
||||
refX: 9,
|
||||
refY: 5,
|
||||
markerWidth: 8,
|
||||
markerHeight: 8,
|
||||
orient: 'auto-start-reverse',
|
||||
});
|
||||
const path = svgEl('path', {
|
||||
d: 'M 0 0 L 10 5 L 0 10 z',
|
||||
class: `graph-edge-arrow graph-edge-${type}`,
|
||||
});
|
||||
marker.appendChild(path);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function _renderEdge(edge) {
|
||||
const { from, to, type, points, fromNode, toNode, field } = edge;
|
||||
const cssClass = `graph-edge graph-edge-${type}`;
|
||||
const d = points ? _pointsToPath(points) : _defaultBezier(fromNode, toNode);
|
||||
|
||||
const path = svgEl('path', {
|
||||
class: cssClass,
|
||||
d,
|
||||
'marker-end': `url(#arrow-${type})`,
|
||||
'data-from': from,
|
||||
'data-to': to,
|
||||
'data-field': field || '',
|
||||
});
|
||||
|
||||
// Tooltip
|
||||
const title = svgEl('title');
|
||||
title.textContent = field ? field.replace(/_/g, ' ') : `${from} → ${to}`;
|
||||
path.appendChild(title);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ELK layout points to SVG path string.
|
||||
* Uses Catmull-Rom-to-Cubic conversion for smooth curves through all points.
|
||||
*/
|
||||
function _pointsToPath(points) {
|
||||
if (points.length < 2) return '';
|
||||
if (points.length === 2) {
|
||||
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
|
||||
}
|
||||
|
||||
// For 3+ points, build a smooth cubic bezier through all of them
|
||||
let d = `M ${points[0].x} ${points[0].y}`;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(0, i - 1)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||
|
||||
// Catmull-Rom to cubic bezier control points
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
|
||||
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback bezier when no ELK routing is available.
|
||||
*/
|
||||
function _defaultBezier(fromNode, toNode) {
|
||||
const x1 = fromNode.x + fromNode.width;
|
||||
const y1 = fromNode.y + fromNode.height / 2;
|
||||
const x2 = toNode.x;
|
||||
const y2 = toNode.y + toNode.height / 2;
|
||||
const dx = Math.abs(x2 - x1) * 0.4;
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1} ${x2 - dx} ${y2} ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight edges that connect to a specific node (upstream chain).
|
||||
*/
|
||||
export function highlightChain(edgeGroup, nodeId, edges) {
|
||||
// Find all ancestors recursively
|
||||
const upstream = new Set();
|
||||
const stack = [nodeId];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const e of edges) {
|
||||
if (e.to === current && !upstream.has(e.from)) {
|
||||
upstream.add(e.from);
|
||||
stack.push(e.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
upstream.add(nodeId);
|
||||
|
||||
// Downstream too
|
||||
const downstream = new Set();
|
||||
const dStack = [nodeId];
|
||||
while (dStack.length) {
|
||||
const current = dStack.pop();
|
||||
for (const e of edges) {
|
||||
if (e.from === current && !downstream.has(e.to)) {
|
||||
downstream.add(e.to);
|
||||
dStack.push(e.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chain = new Set([...upstream, ...downstream]);
|
||||
|
||||
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
|
||||
const from = path.getAttribute('data-from');
|
||||
const to = path.getAttribute('data-to');
|
||||
const inChain = chain.has(from) && chain.has(to);
|
||||
path.classList.toggle('highlighted', inChain);
|
||||
path.classList.toggle('dimmed', !inChain);
|
||||
});
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all highlight/dim classes from edges.
|
||||
*/
|
||||
export function clearEdgeHighlights(edgeGroup) {
|
||||
edgeGroup.querySelectorAll('.graph-edge').forEach(path => {
|
||||
path.classList.remove('highlighted', 'dimmed');
|
||||
});
|
||||
}
|
||||
320
server/src/wled_controller/static/js/core/graph-layout.js
Normal file
320
server/src/wled_controller/static/js/core/graph-layout.js
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Graph layout via ELK.js — converts entity data into positioned nodes/edges.
|
||||
*/
|
||||
|
||||
/* global ELK */
|
||||
|
||||
const NODE_WIDTH = 190;
|
||||
const NODE_HEIGHT = 56;
|
||||
|
||||
const ELK_OPTIONS = {
|
||||
'elk.algorithm': 'layered',
|
||||
'elk.direction': 'RIGHT',
|
||||
'elk.spacing.nodeNode': '40',
|
||||
'elk.layered.spacing.nodeNodeBetweenLayers': '80',
|
||||
'elk.layered.spacing.edgeNodeBetweenLayers': '30',
|
||||
'elk.spacing.edgeEdge': '15',
|
||||
'elk.spacing.edgeNode': '20',
|
||||
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
||||
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
||||
'elk.edgeRouting': 'SPLINES',
|
||||
'elk.layered.mergeEdges': 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build ELK graph from entity model and run layout.
|
||||
* @param {Object} entities - { devices, captureTemplates, pictureSources, ... }
|
||||
* @returns {Promise<{nodes: Map, edges: Array, bounds: {x,y,width,height}}>}
|
||||
*/
|
||||
export async function computeLayout(entities) {
|
||||
const elk = new ELK();
|
||||
const { nodes: nodeList, edges: edgeList } = buildGraph(entities);
|
||||
|
||||
const elkGraph = {
|
||||
id: 'root',
|
||||
layoutOptions: ELK_OPTIONS,
|
||||
children: nodeList.map(n => ({
|
||||
id: n.id,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
})),
|
||||
edges: edgeList.map((e, i) => ({
|
||||
id: `e${i}`,
|
||||
sources: [e.from],
|
||||
targets: [e.to],
|
||||
})),
|
||||
};
|
||||
|
||||
const layout = await elk.layout(elkGraph);
|
||||
|
||||
const nodeMap = new Map();
|
||||
for (const child of layout.children) {
|
||||
const src = nodeList.find(n => n.id === child.id);
|
||||
if (src) {
|
||||
nodeMap.set(child.id, {
|
||||
...src,
|
||||
x: child.x,
|
||||
y: child.y,
|
||||
width: child.width,
|
||||
height: child.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build edge paths from layout
|
||||
const edges = [];
|
||||
for (let i = 0; i < edgeList.length; i++) {
|
||||
const layoutEdge = layout.edges?.[i];
|
||||
const srcEdge = edgeList[i];
|
||||
const fromNode = nodeMap.get(srcEdge.from);
|
||||
const toNode = nodeMap.get(srcEdge.to);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
let points = null;
|
||||
if (layoutEdge?.sections?.[0]) {
|
||||
const sec = layoutEdge.sections[0];
|
||||
points = [sec.startPoint, ...(sec.bendPoints || []), sec.endPoint];
|
||||
}
|
||||
|
||||
edges.push({
|
||||
...srcEdge,
|
||||
points,
|
||||
fromNode,
|
||||
toNode,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of nodeMap.values()) {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x + n.width);
|
||||
maxY = Math.max(maxY, n.y + n.height);
|
||||
}
|
||||
|
||||
const bounds = nodeMap.size > 0
|
||||
? { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
: { x: 0, y: 0, width: 400, height: 300 };
|
||||
|
||||
return { nodes: nodeMap, edges, bounds };
|
||||
}
|
||||
|
||||
/* ── Entity color mapping ── */
|
||||
|
||||
export const ENTITY_COLORS = {
|
||||
device: '#78909C',
|
||||
capture_template: '#8D6E63',
|
||||
picture_source: '#42A5F5',
|
||||
pp_template: '#AB47BC',
|
||||
audio_source: '#EF5350',
|
||||
audio_template: '#E57373',
|
||||
color_strip_source: '#66BB6A',
|
||||
value_source: '#FFA726',
|
||||
sync_clock: '#26C6DA',
|
||||
output_target: '#FFCA28',
|
||||
scene_preset: '#CE93D8',
|
||||
automation: '#A5D6A7',
|
||||
pattern_template: '#BCAAA4',
|
||||
};
|
||||
|
||||
export const ENTITY_LABELS = {
|
||||
device: 'Device',
|
||||
capture_template: 'Capture Template',
|
||||
picture_source: 'Picture Source',
|
||||
pp_template: 'PP Template',
|
||||
audio_source: 'Audio Source',
|
||||
audio_template: 'Audio Template',
|
||||
color_strip_source: 'Color Strip Source',
|
||||
value_source: 'Value Source',
|
||||
sync_clock: 'Sync Clock',
|
||||
output_target: 'Output Target',
|
||||
scene_preset: 'Scene Preset',
|
||||
automation: 'Automation',
|
||||
pattern_template: 'Pattern Template',
|
||||
};
|
||||
|
||||
/* ── Edge type (for CSS class) ── */
|
||||
|
||||
function edgeType(fromKind, toKind, field) {
|
||||
if (field === 'clock_id') return 'clock';
|
||||
if (fromKind === 'device') return 'device';
|
||||
if (fromKind === 'picture_source' || toKind === 'picture_source') return 'picture';
|
||||
if (fromKind === 'color_strip_source') return 'colorstrip';
|
||||
if (fromKind === 'value_source') return 'value';
|
||||
if (fromKind === 'audio_source' || fromKind === 'audio_template') return 'audio';
|
||||
if (fromKind === 'capture_template' || fromKind === 'pp_template' || fromKind === 'pattern_template') return 'template';
|
||||
if (fromKind === 'scene_preset') return 'scene';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/* ── Graph builder ── */
|
||||
|
||||
function buildGraph(e) {
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const nodeIds = new Set();
|
||||
|
||||
function addNode(id, kind, name, subtype, extra = {}) {
|
||||
if (!id || nodeIds.has(id)) return;
|
||||
nodeIds.add(id);
|
||||
nodes.push({ id, kind, name: name || id, subtype: subtype || '', ...extra });
|
||||
}
|
||||
|
||||
function addEdge(from, to, field, label = '') {
|
||||
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
|
||||
const type = edgeType(
|
||||
nodes.find(n => n.id === from)?.kind,
|
||||
nodes.find(n => n.id === to)?.kind,
|
||||
field
|
||||
);
|
||||
edges.push({ from, to, field, label, type });
|
||||
}
|
||||
|
||||
// 1. Devices
|
||||
for (const d of e.devices || []) {
|
||||
addNode(d.id, 'device', d.name, d.device_type);
|
||||
}
|
||||
|
||||
// 2. Capture templates
|
||||
for (const t of e.captureTemplates || []) {
|
||||
addNode(t.id, 'capture_template', t.name, t.engine_type);
|
||||
}
|
||||
|
||||
// 3. PP templates
|
||||
for (const t of e.ppTemplates || []) {
|
||||
addNode(t.id, 'pp_template', t.name, '');
|
||||
}
|
||||
|
||||
// 4. Audio templates
|
||||
for (const t of e.audioTemplates || []) {
|
||||
addNode(t.id, 'audio_template', t.name, t.engine_type);
|
||||
}
|
||||
|
||||
// 5. Pattern templates
|
||||
for (const t of e.patternTemplates || []) {
|
||||
addNode(t.id, 'pattern_template', t.name, '');
|
||||
}
|
||||
|
||||
// 6. Sync clocks
|
||||
for (const c of e.syncClocks || []) {
|
||||
addNode(c.id, 'sync_clock', c.name, '');
|
||||
}
|
||||
|
||||
// 7. Picture sources
|
||||
for (const s of e.pictureSources || []) {
|
||||
addNode(s.id, 'picture_source', s.name, s.stream_type);
|
||||
}
|
||||
|
||||
// 8. Audio sources
|
||||
for (const s of e.audioSources || []) {
|
||||
addNode(s.id, 'audio_source', s.name, s.source_type);
|
||||
}
|
||||
|
||||
// 9. Value sources
|
||||
for (const s of e.valueSources || []) {
|
||||
addNode(s.id, 'value_source', s.name, s.source_type);
|
||||
}
|
||||
|
||||
// 10. Color strip sources
|
||||
for (const s of e.colorStripSources || []) {
|
||||
addNode(s.id, 'color_strip_source', s.name, s.source_type);
|
||||
}
|
||||
|
||||
// 11. Output targets
|
||||
for (const t of e.outputTargets || []) {
|
||||
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false });
|
||||
}
|
||||
|
||||
// 12. Scene presets
|
||||
for (const s of e.scenePresets || []) {
|
||||
addNode(s.id, 'scene_preset', s.name, '');
|
||||
}
|
||||
|
||||
// 13. Automations
|
||||
for (const a of e.automations || []) {
|
||||
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false });
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
// Picture source edges
|
||||
for (const s of e.pictureSources || []) {
|
||||
if (s.capture_template_id) addEdge(s.capture_template_id, s.id, 'capture_template_id');
|
||||
if (s.source_stream_id) addEdge(s.source_stream_id, s.id, 'source_stream_id');
|
||||
if (s.postprocessing_template_id) addEdge(s.postprocessing_template_id, s.id, 'postprocessing_template_id');
|
||||
}
|
||||
|
||||
// Audio source edges
|
||||
for (const s of e.audioSources || []) {
|
||||
if (s.audio_template_id) addEdge(s.audio_template_id, s.id, 'audio_template_id');
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
}
|
||||
|
||||
// Value source edges
|
||||
for (const s of e.valueSources || []) {
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
||||
}
|
||||
|
||||
// Color strip source edges
|
||||
for (const s of e.colorStripSources || []) {
|
||||
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
if (s.clock_id) addEdge(s.clock_id, s.id, 'clock_id');
|
||||
|
||||
// Composite layers
|
||||
if (s.layers) {
|
||||
for (const layer of s.layers) {
|
||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
|
||||
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
|
||||
}
|
||||
}
|
||||
|
||||
// Mapped zones
|
||||
if (s.zones) {
|
||||
for (const zone of s.zones) {
|
||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced picture calibration lines
|
||||
if (s.calibration?.lines) {
|
||||
for (const line of s.calibration.lines) {
|
||||
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output target edges
|
||||
for (const t of e.outputTargets || []) {
|
||||
if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
|
||||
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
|
||||
if (t.brightness_value_source_id) addEdge(t.brightness_value_source_id, t.id, 'brightness_value_source_id');
|
||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
||||
// KC target settings
|
||||
if (t.settings) {
|
||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||
if (t.settings.brightness_value_source_id) addEdge(t.settings.brightness_value_source_id, t.id, 'settings.brightness_value_source_id');
|
||||
}
|
||||
}
|
||||
|
||||
// Scene preset edges
|
||||
for (const s of e.scenePresets || []) {
|
||||
if (s.targets) {
|
||||
for (const snap of s.targets) {
|
||||
if (snap.target_id) addEdge(snap.target_id, s.id, 'target_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Automation edges
|
||||
for (const a of e.automations || []) {
|
||||
if (a.scene_preset_id) addEdge(a.scene_preset_id, a.id, 'scene_preset_id');
|
||||
if (a.deactivation_scene_preset_id) addEdge(a.deactivation_scene_preset_id, a.id, 'deactivation_scene_preset_id');
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export { NODE_WIDTH, NODE_HEIGHT };
|
||||
255
server/src/wled_controller/static/js/core/graph-nodes.js
Normal file
255
server/src/wled_controller/static/js/core/graph-nodes.js
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* SVG node rendering for the graph editor.
|
||||
*/
|
||||
|
||||
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT } from './graph-layout.js';
|
||||
import * as P from './icon-paths.js';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// ── Entity kind → default icon path data ──
|
||||
const KIND_ICONS = {
|
||||
device: P.monitor,
|
||||
capture_template: P.camera,
|
||||
pp_template: P.wrench,
|
||||
audio_template: P.music,
|
||||
pattern_template: P.fileText,
|
||||
picture_source: P.tv,
|
||||
audio_source: P.music,
|
||||
value_source: P.hash,
|
||||
color_strip_source: P.film,
|
||||
sync_clock: P.clock,
|
||||
output_target: P.zap,
|
||||
scene_preset: P.sparkles,
|
||||
automation: P.clipboardList,
|
||||
};
|
||||
|
||||
// ── Subtype-specific icon overrides ──
|
||||
const SUBTYPE_ICONS = {
|
||||
color_strip_source: {
|
||||
picture_advanced: P.monitor, static: P.palette, color_cycle: P.refreshCw,
|
||||
gradient: P.rainbow, effect: P.zap, composite: P.link,
|
||||
mapped: P.mapPin, mapped_zones: P.mapPin,
|
||||
audio: P.music, audio_visualization: P.music,
|
||||
api_input: P.send, notification: P.bellRing, daylight: P.sun, candlelight: P.flame,
|
||||
},
|
||||
picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image },
|
||||
value_source: {
|
||||
static: P.layoutDashboard, animated: P.refreshCw, audio: P.music,
|
||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||
},
|
||||
audio_source: { mono: P.mic, multichannel: P.volume2 },
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette },
|
||||
};
|
||||
|
||||
function svgEl(tag, attrs = {}) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Truncate text to fit within maxWidth (approximate). */
|
||||
function truncate(text, maxChars = 22) {
|
||||
if (!text) return '';
|
||||
return text.length > maxChars ? text.slice(0, maxChars - 1) + '\u2026' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all nodes into the given SVG group.
|
||||
* @param {SVGGElement} group
|
||||
* @param {Map} nodeMap - id → {id, kind, name, subtype, x, y, width, height, running}
|
||||
* @param {Object} callbacks - { onNodeClick, onNodeDblClick, onDeleteNode, onEditNode, onTestNode }
|
||||
*/
|
||||
export function renderNodes(group, nodeMap, callbacks = {}) {
|
||||
// Clear existing
|
||||
while (group.firstChild) group.firstChild.remove();
|
||||
|
||||
for (const node of nodeMap.values()) {
|
||||
const g = renderNode(node, callbacks);
|
||||
group.appendChild(g);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single node.
|
||||
*/
|
||||
function renderNode(node, callbacks) {
|
||||
const { id, kind, name, subtype, x, y, width, height, running } = node;
|
||||
const color = ENTITY_COLORS[kind] || '#666';
|
||||
|
||||
const g = svgEl('g', {
|
||||
class: `graph-node${running ? ' running' : ''}`,
|
||||
'data-id': id,
|
||||
'data-kind': kind,
|
||||
transform: `translate(${x}, ${y})`,
|
||||
});
|
||||
|
||||
// Body rect
|
||||
const body = svgEl('rect', {
|
||||
class: 'graph-node-body',
|
||||
x: 0, y: 0,
|
||||
width, height,
|
||||
rx: 8, ry: 8,
|
||||
});
|
||||
g.appendChild(body);
|
||||
|
||||
// Color bar (left strip)
|
||||
const bar = svgEl('rect', {
|
||||
class: 'graph-node-color-bar',
|
||||
x: 0, y: 0,
|
||||
width: 6, height,
|
||||
rx: 8, ry: 8,
|
||||
fill: color,
|
||||
});
|
||||
g.appendChild(bar);
|
||||
// Cover the right side rounded corners of the bar
|
||||
const barCover = svgEl('rect', {
|
||||
x: 3, y: 0,
|
||||
width: 4, height,
|
||||
fill: color,
|
||||
});
|
||||
g.appendChild(barCover);
|
||||
|
||||
// Entity icon (right side)
|
||||
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
|
||||
if (iconPaths) {
|
||||
const iconG = svgEl('g', {
|
||||
class: 'graph-node-icon',
|
||||
transform: `translate(${width - 28}, ${height / 2 - 8}) scale(0.667)`,
|
||||
});
|
||||
iconG.innerHTML = iconPaths;
|
||||
g.appendChild(iconG);
|
||||
}
|
||||
|
||||
// Running dot
|
||||
if (running) {
|
||||
const dot = svgEl('circle', {
|
||||
class: 'graph-node-running-dot',
|
||||
cx: width - 14, cy: 14,
|
||||
r: 4,
|
||||
});
|
||||
g.appendChild(dot);
|
||||
}
|
||||
|
||||
// Title (shift left edge for icon to have room)
|
||||
const title = svgEl('text', {
|
||||
class: 'graph-node-title',
|
||||
x: 16, y: 24,
|
||||
});
|
||||
title.textContent = truncate(name, 18);
|
||||
g.appendChild(title);
|
||||
|
||||
// Subtitle (type)
|
||||
if (subtype) {
|
||||
const sub = svgEl('text', {
|
||||
class: 'graph-node-subtitle',
|
||||
x: 16, y: 42,
|
||||
});
|
||||
sub.textContent = subtype.replace(/_/g, ' ');
|
||||
g.appendChild(sub);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = `${name} (${kind.replace(/_/g, ' ')})`;
|
||||
g.appendChild(tip);
|
||||
|
||||
// Hover overlay (action buttons)
|
||||
const overlay = _createOverlay(node, width, callbacks);
|
||||
g.appendChild(overlay);
|
||||
|
||||
// Click handler
|
||||
g.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (callbacks.onNodeClick) callbacks.onNodeClick(node, e);
|
||||
});
|
||||
|
||||
g.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
if (callbacks.onNodeDblClick) callbacks.onNodeDblClick(node, e);
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
function _createOverlay(node, nodeWidth, callbacks) {
|
||||
const overlay = svgEl('g', { class: 'graph-node-overlay' });
|
||||
const btnSize = 24;
|
||||
const btnGap = 2;
|
||||
const btns = [
|
||||
{ icon: '\u270E', action: 'edit', cls: '' }, // ✎
|
||||
{ icon: '\u2716', action: 'delete', cls: 'danger' }, // ✖
|
||||
];
|
||||
const totalW = btns.length * (btnSize + btnGap) - btnGap + 8;
|
||||
const ox = nodeWidth - totalW - 4;
|
||||
const oy = -btnSize - 6;
|
||||
|
||||
// Invisible bridge rect so hover doesn't drop when moving to overlay
|
||||
overlay.appendChild(svgEl('rect', {
|
||||
x: ox, y: oy,
|
||||
width: totalW, height: btnSize + 10,
|
||||
fill: 'transparent',
|
||||
'pointer-events': 'all',
|
||||
}));
|
||||
|
||||
// Background
|
||||
overlay.appendChild(svgEl('rect', {
|
||||
class: 'graph-node-overlay-bg',
|
||||
x: ox, y: oy,
|
||||
width: totalW, height: btnSize + 4,
|
||||
}));
|
||||
|
||||
btns.forEach((btn, i) => {
|
||||
const bx = ox + 4 + i * (btnSize + btnGap);
|
||||
const by = oy + 2;
|
||||
const bg = svgEl('g', { class: `graph-node-overlay-btn ${btn.cls}` });
|
||||
bg.appendChild(svgEl('rect', { x: bx, y: by, width: btnSize, height: btnSize }));
|
||||
const txt = svgEl('text', { x: bx + btnSize / 2, y: by + btnSize / 2 });
|
||||
txt.textContent = btn.icon;
|
||||
bg.appendChild(txt);
|
||||
bg.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (btn.action === 'edit' && callbacks.onEditNode) callbacks.onEditNode(node);
|
||||
if (btn.action === 'delete' && callbacks.onDeleteNode) callbacks.onDeleteNode(node);
|
||||
});
|
||||
overlay.appendChild(bg);
|
||||
});
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a single node (add class, scroll to).
|
||||
*/
|
||||
export function highlightNode(group, nodeId, cls = 'search-match') {
|
||||
// Remove existing highlights
|
||||
group.querySelectorAll(`.graph-node.${cls}`).forEach(n => n.classList.remove(cls));
|
||||
const node = group.querySelector(`.graph-node[data-id="${nodeId}"]`);
|
||||
if (node) node.classList.add(cls);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark orphan nodes (no incoming or outgoing edges).
|
||||
*/
|
||||
export function markOrphans(group, nodeMap, edges) {
|
||||
const connected = new Set();
|
||||
for (const e of edges) {
|
||||
connected.add(e.from);
|
||||
connected.add(e.to);
|
||||
}
|
||||
for (const node of nodeMap.values()) {
|
||||
const el = group.querySelector(`.graph-node[data-id="${node.id}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.toggle('orphan', !connected.has(node.id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selection state on nodes.
|
||||
*/
|
||||
export function updateSelection(group, selectedIds) {
|
||||
group.querySelectorAll('.graph-node').forEach(n => {
|
||||
n.classList.toggle('selected', selectedIds.has(n.getAttribute('data-id')));
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const TAB_SVGS = {
|
||||
automations: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg>`,
|
||||
targets: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`,
|
||||
streams: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg>`,
|
||||
graph: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg>`,
|
||||
};
|
||||
|
||||
let _el = null;
|
||||
|
||||
656
server/src/wled_controller/static/js/features/graph-editor.js
Normal file
656
server/src/wled_controller/static/js/features/graph-editor.js
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Graph editor — visual entity graph with autolayout, pan/zoom, search.
|
||||
*/
|
||||
|
||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||
import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
|
||||
import { renderEdges, highlightChain, clearEdgeHighlights } from '../core/graph-edges.js';
|
||||
import {
|
||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||
streamsCache, audioSourcesCache, audioTemplatesCache,
|
||||
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
|
||||
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
|
||||
automationsCacheObj,
|
||||
} from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
|
||||
let _canvas = null;
|
||||
let _nodeMap = null;
|
||||
let _edges = null;
|
||||
let _bounds = null;
|
||||
let _selectedIds = new Set();
|
||||
let _initialized = false;
|
||||
let _legendVisible = false;
|
||||
let _minimapVisible = true;
|
||||
let _searchVisible = false;
|
||||
let _searchIndex = -1;
|
||||
let _searchItems = [];
|
||||
let _loading = false;
|
||||
|
||||
// Minimap position/size persisted in localStorage
|
||||
const _MM_KEY = 'graph_minimap';
|
||||
function _loadMinimapRect() {
|
||||
try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; }
|
||||
}
|
||||
function _saveMinimapRect(r) {
|
||||
localStorage.setItem(_MM_KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
// Toolbar position persisted in localStorage
|
||||
const _TB_KEY = 'graph_toolbar';
|
||||
function _loadToolbarPos() {
|
||||
try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
|
||||
}
|
||||
function _saveToolbarPos(r) {
|
||||
localStorage.setItem(_TB_KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export async function loadGraphEditor() {
|
||||
const container = document.getElementById('graph-editor-content');
|
||||
if (!container) return;
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
|
||||
// First load: replace with spinner. Re-layout: overlay spinner on top.
|
||||
if (!_initialized) {
|
||||
container.innerHTML = '<div class="loading-spinner"></div>';
|
||||
} else {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'graph-loading-overlay';
|
||||
overlay.innerHTML = '<div class="loading-spinner"></div>';
|
||||
const gc = container.querySelector('.graph-container');
|
||||
if (gc) gc.appendChild(overlay);
|
||||
}
|
||||
|
||||
try {
|
||||
const entities = await _fetchAllEntities();
|
||||
const { nodes, edges, bounds } = await computeLayout(entities);
|
||||
_nodeMap = nodes;
|
||||
_edges = edges;
|
||||
_bounds = bounds;
|
||||
_renderGraph(container);
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openGraphSearch() {
|
||||
if (!_nodeMap) return;
|
||||
const panel = document.querySelector('.graph-search');
|
||||
if (!panel) return;
|
||||
|
||||
_searchItems = [];
|
||||
for (const node of _nodeMap.values()) _searchItems.push(node);
|
||||
|
||||
_searchIndex = -1;
|
||||
_searchVisible = true;
|
||||
panel.classList.add('visible');
|
||||
const input = panel.querySelector('.graph-search-input');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
_renderSearchResults('');
|
||||
}
|
||||
|
||||
export function closeGraphSearch() {
|
||||
_searchVisible = false;
|
||||
const panel = document.querySelector('.graph-search');
|
||||
if (panel) panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
export function toggleGraphLegend() {
|
||||
_legendVisible = !_legendVisible;
|
||||
const legend = document.querySelector('.graph-legend');
|
||||
if (legend) legend.classList.toggle('visible', _legendVisible);
|
||||
}
|
||||
|
||||
export function toggleGraphMinimap() {
|
||||
_minimapVisible = !_minimapVisible;
|
||||
const mm = document.querySelector('.graph-minimap');
|
||||
if (mm) mm.classList.toggle('visible', _minimapVisible);
|
||||
}
|
||||
|
||||
export function graphFitAll() {
|
||||
if (_canvas && _bounds) _canvas.fitAll(_bounds);
|
||||
}
|
||||
|
||||
export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
|
||||
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
|
||||
|
||||
export async function graphRelayout() {
|
||||
await loadGraphEditor();
|
||||
}
|
||||
|
||||
/* ── Data fetching ── */
|
||||
|
||||
async function _fetchAllEntities() {
|
||||
const [
|
||||
devices, captureTemplates, ppTemplates, pictureSources,
|
||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
||||
] = await Promise.all([
|
||||
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
|
||||
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
|
||||
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
|
||||
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
|
||||
automationsCacheObj.fetch(),
|
||||
]);
|
||||
return {
|
||||
devices, captureTemplates, ppTemplates, pictureSources,
|
||||
audioSources, audioTemplates, valueSources, colorStripSources,
|
||||
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Rendering ── */
|
||||
|
||||
function _renderGraph(container) {
|
||||
container.innerHTML = _graphHTML();
|
||||
|
||||
const svgEl = container.querySelector('.graph-svg');
|
||||
_canvas = new GraphCanvas(svgEl);
|
||||
|
||||
const nodeGroup = svgEl.querySelector('.graph-nodes');
|
||||
const edgeGroup = svgEl.querySelector('.graph-edges');
|
||||
|
||||
renderEdges(edgeGroup, _edges);
|
||||
renderNodes(nodeGroup, _nodeMap, {
|
||||
onNodeClick: _onNodeClick,
|
||||
onNodeDblClick: _onNodeDblClick,
|
||||
onEditNode: _onEditNode,
|
||||
onDeleteNode: _onDeleteNode,
|
||||
});
|
||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
||||
|
||||
// Set bounds for view clamping, then fit
|
||||
if (_bounds) _canvas.setBounds(_bounds);
|
||||
requestAnimationFrame(() => {
|
||||
if (_canvas && _bounds) _canvas.fitAll(_bounds, false);
|
||||
});
|
||||
|
||||
_canvas.onZoomChange = (z) => {
|
||||
const label = container.querySelector('.graph-zoom-label');
|
||||
if (label) label.textContent = `${Math.round(z * 100)}%`;
|
||||
};
|
||||
|
||||
_canvas.onViewChange = (vp) => {
|
||||
_updateMinimapViewport(container.querySelector('.graph-minimap'), vp);
|
||||
};
|
||||
|
||||
_renderLegend(container.querySelector('.graph-legend'));
|
||||
_initMinimap(container.querySelector('.graph-minimap'));
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
|
||||
const searchInput = container.querySelector('.graph-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
|
||||
searchInput.addEventListener('keydown', _onSearchKeydown);
|
||||
}
|
||||
|
||||
// Deselect on click on empty space (not after a pan gesture)
|
||||
svgEl.addEventListener('click', (e) => {
|
||||
if (_canvas.wasPanning) return;
|
||||
if (!e.target.closest('.graph-node')) {
|
||||
_deselect(nodeGroup, edgeGroup);
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click empty → fit all
|
||||
svgEl.addEventListener('dblclick', (e) => {
|
||||
if (!e.target.closest('.graph-node')) graphFitAll();
|
||||
});
|
||||
|
||||
// Prevent text selection on SVG drag
|
||||
svgEl.addEventListener('mousedown', (e) => {
|
||||
// Prevent default only on the SVG background / edges, not on inputs
|
||||
if (!e.target.closest('input, textarea, select')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('keydown', _onKeydown);
|
||||
container.setAttribute('tabindex', '0');
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
function _deselect(nodeGroup, edgeGroup) {
|
||||
_selectedIds.clear();
|
||||
if (nodeGroup) {
|
||||
updateSelection(nodeGroup, _selectedIds);
|
||||
nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
||||
}
|
||||
if (edgeGroup) clearEdgeHighlights(edgeGroup);
|
||||
}
|
||||
|
||||
function _graphHTML() {
|
||||
const mmRect = _loadMinimapRect();
|
||||
// Default: bottom-right corner with 12px margin (computed after render via _initMinimap)
|
||||
const mmStyle = mmRect ? `left:${mmRect.left}px;top:${mmRect.top}px;width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||
const tbPos = _loadToolbarPos();
|
||||
const tbStyle = tbPos ? `left:${tbPos.left}px;top:${tbPos.top}px;` : '';
|
||||
|
||||
return `
|
||||
<div class="graph-container">
|
||||
<div class="graph-toolbar" style="${tbStyle}">
|
||||
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
||||
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="graphZoomIn()" title="${t('graph.zoom_in')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>
|
||||
</button>
|
||||
<span class="graph-zoom-label">100%</span>
|
||||
<button class="btn-icon" onclick="graphZoomOut()" title="${t('graph.zoom_out')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
|
||||
</button>
|
||||
<span class="graph-toolbar-sep"></span>
|
||||
<button class="btn-icon" onclick="openGraphSearch()" title="${t('graph.search')} (/)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
|
||||
</button>
|
||||
<span class="graph-toolbar-sep"></span>
|
||||
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="graph-legend">
|
||||
<div class="graph-legend-title">${t('graph.legend')}</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-minimap${_minimapVisible ? ' visible' : ''}" style="${mmStyle}">
|
||||
<div class="graph-minimap-header"><span>${t('graph.minimap')}</span></div>
|
||||
<div class="graph-minimap-resize graph-minimap-resize-br"></div>
|
||||
<div class="graph-minimap-resize graph-minimap-resize-bl"></div>
|
||||
<svg></svg>
|
||||
</div>
|
||||
|
||||
<div class="graph-search">
|
||||
<input class="graph-search-input" placeholder="${t('graph.search_placeholder')}" autocomplete="off">
|
||||
<div class="graph-search-results"></div>
|
||||
</div>
|
||||
|
||||
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="var(--primary-color)"/>
|
||||
<stop offset="50%" stop-color="var(--success-color)"/>
|
||||
<stop offset="100%" stop-color="var(--primary-color)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
|
||||
<g class="graph-root">
|
||||
<g class="graph-edges"></g>
|
||||
<g class="graph-nodes"></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Legend ── */
|
||||
|
||||
function _renderLegend(legendEl) {
|
||||
if (!legendEl) return;
|
||||
let html = `<div class="graph-legend-title">${t('graph.legend')}</div>`;
|
||||
for (const [kind, color] of Object.entries(ENTITY_COLORS)) {
|
||||
const label = ENTITY_LABELS[kind] || kind;
|
||||
html += `<div class="graph-legend-item">
|
||||
<span class="graph-legend-dot" style="background:${color}"></span>
|
||||
<span>${label}</span>
|
||||
</div>`;
|
||||
}
|
||||
legendEl.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ── Minimap (draggable header & resize handle) ── */
|
||||
|
||||
function _initMinimap(mmEl) {
|
||||
if (!mmEl || !_nodeMap || !_bounds) return;
|
||||
const svg = mmEl.querySelector('svg');
|
||||
if (!svg) return;
|
||||
const container = mmEl.closest('.graph-container');
|
||||
|
||||
const pad = 10;
|
||||
const vb = `${_bounds.x - pad} ${_bounds.y - pad} ${_bounds.width + pad * 2} ${_bounds.height + pad * 2}`;
|
||||
svg.setAttribute('viewBox', vb);
|
||||
|
||||
let html = '';
|
||||
for (const node of _nodeMap.values()) {
|
||||
const color = ENTITY_COLORS[node.kind] || '#666';
|
||||
html += `<rect class="graph-minimap-node" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
|
||||
}
|
||||
// Add viewport rect (updated live via _updateMinimapViewport)
|
||||
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
||||
svg.innerHTML = html;
|
||||
|
||||
// Set default position (bottom-right corner) if no saved position
|
||||
if (!mmEl.style.left) {
|
||||
const cr = container.getBoundingClientRect();
|
||||
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
|
||||
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
|
||||
mmEl.style.width = '200px';
|
||||
mmEl.style.height = '130px';
|
||||
}
|
||||
|
||||
// Initial viewport update
|
||||
if (_canvas) {
|
||||
_updateMinimapViewport(mmEl, _canvas.getViewport());
|
||||
}
|
||||
|
||||
// Helper to clamp minimap within container
|
||||
function _clampMinimap() {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
||||
let l = parseFloat(mmEl.style.left) || 0;
|
||||
let t = parseFloat(mmEl.style.top) || 0;
|
||||
l = Math.max(0, Math.min(cr.width - mw, l));
|
||||
t = Math.max(0, Math.min(cr.height - mh, t));
|
||||
mmEl.style.left = l + 'px';
|
||||
mmEl.style.top = t + 'px';
|
||||
}
|
||||
|
||||
// ── Click on minimap SVG → pan main canvas to that point ──
|
||||
let mmDraggingViewport = false;
|
||||
svg.addEventListener('pointerdown', (e) => {
|
||||
if (!_canvas || !_bounds) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
mmDraggingViewport = true;
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
_panToMinimapPoint(svg, e);
|
||||
});
|
||||
svg.addEventListener('pointermove', (e) => {
|
||||
if (!mmDraggingViewport) return;
|
||||
_panToMinimapPoint(svg, e);
|
||||
});
|
||||
svg.addEventListener('pointerup', () => { mmDraggingViewport = false; });
|
||||
|
||||
// ── Drag via header ──
|
||||
const header = mmEl.querySelector('.graph-minimap-header');
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
header.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: mmEl.offsetLeft, top: mmEl.offsetTop };
|
||||
header.classList.add('dragging');
|
||||
header.setPointerCapture(e.pointerId);
|
||||
});
|
||||
header.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - mw, l));
|
||||
t = Math.max(0, Math.min(cr.height - mh, t));
|
||||
mmEl.style.left = l + 'px';
|
||||
mmEl.style.top = t + 'px';
|
||||
});
|
||||
header.addEventListener('pointerup', () => {
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapRect(_mmRect(mmEl)); }
|
||||
});
|
||||
|
||||
// ── Resize handles ──
|
||||
_initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br');
|
||||
_initResizeHandle(mmEl.querySelector('.graph-minimap-resize-bl'), 'bl');
|
||||
|
||||
function _initResizeHandle(rh, corner) {
|
||||
if (!rh) return;
|
||||
let rs = null, rss = null;
|
||||
rh.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
rs = { x: e.clientX, y: e.clientY };
|
||||
rss = { w: mmEl.offsetWidth, h: mmEl.offsetHeight, left: mmEl.offsetLeft };
|
||||
rh.setPointerCapture(e.pointerId);
|
||||
});
|
||||
rh.addEventListener('pointermove', (e) => {
|
||||
if (!rs) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const dy = e.clientY - rs.y;
|
||||
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
|
||||
mmEl.style.height = newH + 'px';
|
||||
|
||||
if (corner === 'br') {
|
||||
// Bottom-right: grow width rightward, left stays fixed
|
||||
const dx = e.clientX - rs.x;
|
||||
const maxW = cr.width - mmEl.offsetLeft - 4;
|
||||
mmEl.style.width = Math.max(120, Math.min(maxW, rss.w + dx)) + 'px';
|
||||
} else {
|
||||
// Bottom-left: grow width leftward, right edge stays fixed
|
||||
const dx = rs.x - e.clientX;
|
||||
const newW = Math.max(120, Math.min(cr.width - 20, rss.w + dx));
|
||||
const newLeft = rss.left - (newW - rss.w);
|
||||
mmEl.style.width = newW + 'px';
|
||||
mmEl.style.left = Math.max(0, newLeft) + 'px';
|
||||
}
|
||||
_clampMinimap();
|
||||
});
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapRect(_mmRect(mmEl)); } });
|
||||
}
|
||||
}
|
||||
|
||||
function _panToMinimapPoint(svg, e) {
|
||||
if (!_canvas || !_bounds) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const pad = 10;
|
||||
const bx = _bounds.x - pad, by = _bounds.y - pad;
|
||||
const bw = _bounds.width + pad * 2, bh = _bounds.height + pad * 2;
|
||||
const gx = bx + ((e.clientX - svgRect.left) / svgRect.width) * bw;
|
||||
const gy = by + ((e.clientY - svgRect.top) / svgRect.height) * bh;
|
||||
_canvas.panTo(gx, gy, false);
|
||||
}
|
||||
|
||||
function _updateMinimapViewport(mmEl, vp) {
|
||||
if (!mmEl) return;
|
||||
const rect = mmEl.querySelector('.graph-minimap-viewport');
|
||||
if (!rect) return;
|
||||
rect.setAttribute('x', vp.x);
|
||||
rect.setAttribute('y', vp.y);
|
||||
rect.setAttribute('width', vp.width);
|
||||
rect.setAttribute('height', vp.height);
|
||||
}
|
||||
|
||||
function _mmRect(mmEl) {
|
||||
return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight };
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
|
||||
function _initToolbarDrag(tbEl) {
|
||||
if (!tbEl) return;
|
||||
const container = tbEl.closest('.graph-container');
|
||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
||||
if (!handle) return;
|
||||
|
||||
let dragStart = null, dragStartPos = null;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
dragStart = { x: e.clientX, y: e.clientY };
|
||||
dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!dragStart) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
|
||||
let l = dragStartPos.left + (e.clientX - dragStart.x);
|
||||
let t = dragStartPos.top + (e.clientY - dragStart.y);
|
||||
l = Math.max(0, Math.min(cr.width - tw, l));
|
||||
t = Math.max(0, Math.min(cr.height - th, t));
|
||||
tbEl.style.left = l + 'px';
|
||||
tbEl.style.top = t + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (dragStart) {
|
||||
dragStart = null;
|
||||
_saveToolbarPos({ left: tbEl.offsetLeft, top: tbEl.offsetTop });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
|
||||
function _renderSearchResults(query) {
|
||||
const results = document.querySelector('.graph-search-results');
|
||||
if (!results) return;
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? _searchItems.filter(n => n.name.toLowerCase().includes(q) || n.kind.includes(q) || (n.subtype || '').includes(q))
|
||||
: _searchItems.slice(0, 20);
|
||||
|
||||
_searchIndex = filtered.length > 0 ? 0 : -1;
|
||||
|
||||
results.innerHTML = filtered.map((n, i) => {
|
||||
const color = ENTITY_COLORS[n.kind] || '#666';
|
||||
return `<div class="graph-search-item${i === _searchIndex ? ' active' : ''}" data-id="${n.id}">
|
||||
<span class="graph-search-item-dot" style="background:${color}"></span>
|
||||
<span class="graph-search-item-name">${_escHtml(n.name)}</span>
|
||||
<span class="graph-search-item-type">${n.kind.replace(/_/g, ' ')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
results.querySelectorAll('.graph-search-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
_navigateToNode(item.getAttribute('data-id'));
|
||||
closeGraphSearch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _onSearchKeydown(e) {
|
||||
const results = document.querySelectorAll('.graph-search-item');
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _searchIndex = Math.min(_searchIndex + 1, results.length - 1); _updateSearchActive(results); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); _searchIndex = Math.max(_searchIndex - 1, 0); _updateSearchActive(results); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); if (results[_searchIndex]) { _navigateToNode(results[_searchIndex].getAttribute('data-id')); closeGraphSearch(); } }
|
||||
else if (e.key === 'Escape') { closeGraphSearch(); }
|
||||
}
|
||||
|
||||
function _updateSearchActive(items) {
|
||||
items.forEach((el, i) => el.classList.toggle('active', i === _searchIndex));
|
||||
if (items[_searchIndex]) items[_searchIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function _navigateToNode(nodeId) {
|
||||
const node = _nodeMap?.get(nodeId);
|
||||
if (!node || !_canvas) return;
|
||||
_canvas.panTo(node.x + node.width / 2, node.y + node.height / 2, true);
|
||||
|
||||
const nodeGroup = document.querySelector('.graph-nodes');
|
||||
if (nodeGroup) { highlightNode(nodeGroup, nodeId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
|
||||
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
if (edgeGroup && _edges) { highlightChain(edgeGroup, nodeId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
|
||||
}
|
||||
|
||||
/* ── Node callbacks ── */
|
||||
|
||||
function _onNodeClick(node, e) {
|
||||
const nodeGroup = document.querySelector('.graph-nodes');
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (_selectedIds.has(node.id)) _selectedIds.delete(node.id);
|
||||
else _selectedIds.add(node.id);
|
||||
} else {
|
||||
_selectedIds.clear();
|
||||
_selectedIds.add(node.id);
|
||||
}
|
||||
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
|
||||
|
||||
if (_selectedIds.size === 1 && edgeGroup && _edges) {
|
||||
const chain = highlightChain(edgeGroup, node.id, _edges);
|
||||
if (nodeGroup) {
|
||||
nodeGroup.querySelectorAll('.graph-node').forEach(n => {
|
||||
n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
|
||||
});
|
||||
}
|
||||
} else if (edgeGroup) {
|
||||
clearEdgeHighlights(edgeGroup);
|
||||
if (nodeGroup) nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
|
||||
}
|
||||
}
|
||||
|
||||
function _onNodeDblClick(node) {
|
||||
// Zoom to node and center it in one step
|
||||
if (_canvas) {
|
||||
_canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function _onEditNode(node) {
|
||||
const fnMap = {
|
||||
device: () => window.showSettings?.(node.id),
|
||||
capture_template: () => window.editTemplate?.(node.id),
|
||||
pp_template: () => window.editPPTemplate?.(node.id),
|
||||
audio_template: () => window.editAudioTemplate?.(node.id),
|
||||
pattern_template: () => window.showPatternTemplateEditor?.(node.id),
|
||||
picture_source: () => window.editStream?.(node.id),
|
||||
audio_source: () => window.editAudioSource?.(node.id),
|
||||
value_source: () => window.editValueSource?.(node.id),
|
||||
color_strip_source: () => window.showCSSEditor?.(node.id),
|
||||
sync_clock: () => {},
|
||||
output_target: () => window.showTargetEditor?.(node.id),
|
||||
scene_preset: () => window.editScenePreset?.(node.id),
|
||||
automation: () => window.openAutomationEditor?.(node.id),
|
||||
};
|
||||
fnMap[node.kind]?.();
|
||||
}
|
||||
|
||||
function _onDeleteNode(node) {
|
||||
const fnMap = {
|
||||
device: () => window.removeDevice?.(node.id),
|
||||
capture_template: () => window.deleteTemplate?.(node.id),
|
||||
pp_template: () => window.deletePPTemplate?.(node.id),
|
||||
audio_template: () => window.deleteAudioTemplate?.(node.id),
|
||||
pattern_template: () => window.deletePatternTemplate?.(node.id),
|
||||
picture_source: () => window.deleteStream?.(node.id),
|
||||
audio_source: () => window.deleteAudioSource?.(node.id),
|
||||
value_source: () => window.deleteValueSource?.(node.id),
|
||||
color_strip_source: () => window.deleteColorStrip?.(node.id),
|
||||
output_target: () => window.deleteTarget?.(node.id),
|
||||
scene_preset: () => window.deleteScenePreset?.(node.id),
|
||||
automation: () => window.deleteAutomation?.(node.id),
|
||||
};
|
||||
fnMap[node.kind]?.();
|
||||
}
|
||||
|
||||
/* ── Keyboard ── */
|
||||
|
||||
function _onKeydown(e) {
|
||||
if (e.key === '/' && !_searchVisible) { e.preventDefault(); openGraphSearch(); }
|
||||
if (e.key === 'Escape') {
|
||||
if (_searchVisible) { closeGraphSearch(); }
|
||||
else {
|
||||
const ng = document.querySelector('.graph-nodes');
|
||||
const eg = document.querySelector('.graph-edges');
|
||||
_deselect(ng, eg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function _escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (_initialized && _nodeMap) {
|
||||
const container = document.getElementById('graph-editor-content');
|
||||
if (container) _renderGraph(container);
|
||||
}
|
||||
});
|
||||
@@ -70,6 +70,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else if (name === 'automations') {
|
||||
if (typeof window.loadAutomations === 'function') window.loadAutomations();
|
||||
} else if (name === 'graph') {
|
||||
if (typeof window.loadGraphEditor === 'function') window.loadGraphEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1373,5 +1373,16 @@
|
||||
"sync_clock.reset_done": "Clock reset to zero",
|
||||
"sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.",
|
||||
"color_strip.clock": "Sync Clock:",
|
||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock."
|
||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||
"graph.title": "Graph",
|
||||
"graph.fit_all": "Fit all nodes",
|
||||
"graph.zoom_in": "Zoom in",
|
||||
"graph.zoom_out": "Zoom out",
|
||||
"graph.search": "Search nodes",
|
||||
"graph.search_placeholder": "Search entities...",
|
||||
"graph.legend": "Legend",
|
||||
"graph.minimap": "Minimap",
|
||||
"graph.relayout": "Re-layout",
|
||||
"graph.empty": "No entities yet",
|
||||
"graph.empty.hint": "Create devices, sources, and targets to see them here."
|
||||
}
|
||||
|
||||
@@ -1373,5 +1373,16 @@
|
||||
"sync_clock.reset_done": "Часы сброшены на ноль",
|
||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
|
||||
"color_strip.clock": "Часы синхронизации:",
|
||||
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах."
|
||||
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
|
||||
"graph.title": "Граф",
|
||||
"graph.fit_all": "Показать все узлы",
|
||||
"graph.zoom_in": "Приблизить",
|
||||
"graph.zoom_out": "Отдалить",
|
||||
"graph.search": "Поиск узлов",
|
||||
"graph.search_placeholder": "Поиск сущностей...",
|
||||
"graph.legend": "Легенда",
|
||||
"graph.minimap": "Миникарта",
|
||||
"graph.relayout": "Перестроить",
|
||||
"graph.empty": "Ещё нет сущностей",
|
||||
"graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь."
|
||||
}
|
||||
|
||||
@@ -1373,5 +1373,16 @@
|
||||
"sync_clock.reset_done": "时钟已重置为零",
|
||||
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
|
||||
"color_strip.clock": "同步时钟:",
|
||||
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。"
|
||||
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
|
||||
"graph.title": "图表",
|
||||
"graph.fit_all": "显示所有节点",
|
||||
"graph.zoom_in": "放大",
|
||||
"graph.zoom_out": "缩小",
|
||||
"graph.search": "搜索节点",
|
||||
"graph.search_placeholder": "搜索实体...",
|
||||
"graph.legend": "图例",
|
||||
"graph.minimap": "小地图",
|
||||
"graph.relayout": "重新布局",
|
||||
"graph.empty": "暂无实体",
|
||||
"graph.empty.hint": "创建设备、源和目标后即可在此查看。"
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@
|
||||
<link rel="stylesheet" href="/static/css/automations.css">
|
||||
<link rel="stylesheet" href="/static/css/tree-nav.css">
|
||||
<link rel="stylesheet" href="/static/css/tutorials.css">
|
||||
<link rel="stylesheet" href="/static/css/graph-editor.css">
|
||||
<link rel="stylesheet" href="/static/css/mobile.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/elkjs@0.9.3/lib/elk.bundled.js"></script>
|
||||
</head>
|
||||
<body style="visibility: hidden;">
|
||||
<canvas id="bg-anim-canvas"></canvas>
|
||||
@@ -51,6 +53,7 @@
|
||||
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span><span class="tab-badge" id="tab-badge-automations" style="display:none"></span></button>
|
||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
||||
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
||||
@@ -139,6 +142,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-graph" role="tabpanel" aria-labelledby="tab-btn-graph">
|
||||
<div id="graph-editor-content">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// Apply saved tab immediately during parse to prevent visible jump
|
||||
@@ -147,6 +156,7 @@
|
||||
var saved = hash ? hash.split('/')[0] : localStorage.getItem('activeTab');
|
||||
if (saved === 'devices') saved = 'targets';
|
||||
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
|
||||
/* graph tab is valid */
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
|
||||
})();
|
||||
@@ -382,6 +392,7 @@
|
||||
document.getElementById('automations-content').innerHTML = loginMsg;
|
||||
document.getElementById('targets-panel-content').innerHTML = loginMsg;
|
||||
document.getElementById('streams-list').innerHTML = loginMsg;
|
||||
document.getElementById('graph-editor-content').innerHTML = loginMsg;
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
|
||||
Reference in New Issue
Block a user