From 0bbaf81e268afed2e67c997af6dbbf261e7cacf3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 15 Mar 2026 19:58:45 +0300 Subject: [PATCH] Major graph editor improvements: standalone features, touch, docking, UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph standalone features: - Clone button on all entity nodes (copy icon, watches for new entity) - Scene preset activation button (play icon, calls /activate API) - Automation enable/disable via start/stop toggle (PUT enabled) - Add missing entity types: sync_clock, scene_preset, pattern_template - Fix edit/delete handlers for cspt, sync_clock - CSPT added to test/preview button kinds - Bulk delete with multi-select + Delete key confirmation - Undo/redo framework with toolbar buttons (disabled when empty) - Keyboard shortcuts help panel (? key, draggable, anchor-persisted) - Enhanced search: type:device, tag:production filter syntax - Tags passed through to all graph nodes for tag-based filtering - Filter popover with grouped checkboxes replaces flat pill row Touch device support: - Pinch-to-zoom with 2-finger gesture tracking - Double-tap zoom toggle (1.0x ↔ 1.8x) - Multi-touch pointer tracking with pinch-to-pan - Overlay buttons and port labels visible on selected (tapped) nodes - Larger touch targets for ports (@media pointer: coarse) - touch-action: none on SVG canvas - 10px dead zone for touch vs 4px for mouse Visual improvements: - Port pin labels shown outside node on hover/select (outlined text) - Hybrid active edge flow: thicker + glow + animated dots - Test/preview icon changed to flask (matching card tabs) - Clone icon scaled down to 60% for compact overlay buttons - Monospace font for metric values (stable-width digits) - Hide scrollbar on graph tab (html:has override) Toolbar docking: - 8-position dock system (4 corners + 4 side midpoints) - Vertical layout when docked to left/right sides - Dock position indicators shown during drag (dots with highlight) - Snap animation on drop - Persisted dock position in localStorage Resize handling: - View center preserved on fullscreen/window resize (ResizeObserver) - All docked panels re-anchored on container resize - Zoom inertia for wheel and toolbar +/- buttons Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/wled_controller/static/css/cards.css | 5 +- .../wled_controller/static/css/dashboard.css | 5 +- .../static/css/graph-editor.css | 321 ++++++++++- server/src/wled_controller/static/js/app.js | 5 +- .../static/js/core/graph-canvas.js | 195 ++++++- .../static/js/core/graph-edges.js | 14 +- .../static/js/core/graph-layout.js | 30 +- .../static/js/core/graph-nodes.js | 57 +- .../static/js/features/graph-editor.js | 540 ++++++++++++++++-- .../wled_controller/static/locales/en.json | 36 +- .../wled_controller/static/locales/ru.json | 34 +- .../wled_controller/static/locales/zh.json | 34 +- 12 files changed, 1176 insertions(+), 100 deletions(-) diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 12c71a0..36498aa 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -804,9 +804,10 @@ ul.section-tip li { } .metric-value { - font-size: 0.9rem; - font-weight: 700; + font-size: 0.85rem; + font-weight: 600; color: var(--primary-text-color); + font-family: var(--font-mono, monospace); } .metric-label { diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index ecd1c4a..fbcdcce 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -159,10 +159,11 @@ } .dashboard-metric-value { - font-size: 0.85rem; - font-weight: 700; + font-size: 0.8rem; + font-weight: 600; color: var(--primary-text-color); line-height: 1.2; + font-family: var(--font-mono, monospace); } .dashboard-metric-label { diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 96e6e6c..ad279a7 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -1,6 +1,15 @@ /* ── Graph Editor ─────────────────────────────────────── */ /* Full viewport: hide footer & scroll-to-top when graph is active */ +#tab-graph { + overflow: hidden; +} + +/* Hide the global scrollbar when graph tab is active */ +html:has(#tab-graph.active) { + overflow: hidden; +} + #tab-graph #graph-editor-content { padding: 0; margin: 0; @@ -42,12 +51,66 @@ margin-right: 2px; padding-right: 2px; letter-spacing: -1px; + flex-shrink: 0; } -.graph-toolbar-drag:active { +.graph-toolbar-drag:active, +.graph-toolbar-drag.dragging { cursor: grabbing; } +/* Vertical toolbar (docked to left/right side) */ +.graph-toolbar.vertical { + flex-direction: column; +} + +.graph-toolbar.vertical .graph-toolbar-drag { + width: auto; + height: 16px; + border-right: none; + border-bottom: 1px solid var(--border-color); + margin-right: 0; + margin-bottom: 2px; + padding-right: 0; + padding-bottom: 2px; +} + +.graph-toolbar.vertical .graph-toolbar-sep { + width: 100%; + height: 1px; +} + +.graph-toolbar.vertical .graph-zoom-label { + text-align: center; +} + +/* Dock position indicators during toolbar drag */ +.graph-dock-indicators { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 19; +} + +.graph-dock-dot { + position: absolute; + width: 10px; + height: 10px; + margin-left: -5px; + margin-top: -5px; + border-radius: 50%; + background: var(--text-muted); + opacity: 0.25; + transition: opacity 0.15s, transform 0.15s, background 0.15s; +} + +.graph-dock-dot.nearest { + opacity: 1; + background: var(--primary-color); + transform: scale(1.6); + box-shadow: 0 0 8px var(--primary-color); +} + .graph-toolbar .btn-icon { width: 32px; height: 32px; @@ -72,6 +135,12 @@ color: var(--primary-contrast); } +.graph-toolbar .btn-icon:disabled { + opacity: 0.25; + cursor: default; + pointer-events: none; +} + .graph-toolbar-sep { width: 1px; background: var(--border-color); @@ -361,11 +430,38 @@ pointer-events: all; } -.graph-node:hover .graph-port { +.graph-node:hover .graph-port, +.graph-node.selected .graph-port { r: 5; opacity: 1; } +/* Larger touch targets for ports on touch devices */ +@media (pointer: coarse) { + .graph-port { r: 6; } + .graph-node:hover .graph-port, + .graph-node.selected .graph-port { r: 7; } +} + +/* Port labels — hidden by default, shown on node hover, positioned outside node */ +.graph-port-label { + font-size: 9px; + font-weight: 600; + fill: var(--text-color); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + paint-order: stroke fill; + stroke: var(--bg-color); + stroke-width: 3px; + stroke-linejoin: round; +} + +.graph-node:hover .graph-port-label, +.graph-node.selected .graph-port-label { + opacity: 1; +} + /* Port output cursor: draggable */ .graph-port-out { cursor: crosshair; @@ -441,18 +537,22 @@ .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; - pointer-events: none; +/* ── Active edge flow (hybrid: thicker + saturated + animated dash) ── */ + +.graph-edge.graph-edge-active { + opacity: 1; + stroke-width: 2.5; + filter: drop-shadow(0 0 3px currentColor); } -.graph-edge-flow circle { - r: 3; - opacity: 0.85; - filter: drop-shadow(0 0 2px currentColor); -} +/* Clear dash patterns on active edges so glow looks clean */ +.graph-edge-clock.graph-edge-active, +.graph-edge-template.graph-edge-active { stroke-dasharray: none; } +.graph-edge-nested.graph-edge-active { stroke-dasharray: none; } + +/* Flow dots on active edges */ +.graph-edge-flow { pointer-events: none; } +.graph-edge-flow circle { filter: drop-shadow(0 0 2px currentColor); } /* ── Drag connection preview ── */ @@ -513,7 +613,8 @@ pointer-events: none; } -.graph-node:hover .graph-node-overlay { +.graph-node:hover .graph-node-overlay, +.graph-node.selected .graph-node-overlay { display: block; } @@ -727,11 +828,8 @@ width: 100%; } -.graph-filter-pills { - display: flex; - flex-wrap: wrap; +.graph-filter-actions { gap: 4px; - align-items: center; } .graph-filter-pill { @@ -757,11 +855,116 @@ border-color: var(--pill-color, var(--primary-color)); } -.graph-filter-sep { - width: 1px; - height: 16px; - background: var(--border-color); - margin: 0 2px; +/* ── Types button + popover ── */ + +.graph-filter-types-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + border-radius: 12px; + padding: 2px 10px; + font-size: 0.7rem; + cursor: pointer; + white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; + transition: background 0.15s, color 0.15s, border-color 0.15s; + line-height: 1.4; + margin-right: auto; +} + +.graph-filter-types-btn:hover { + border-color: var(--primary-color); + color: var(--text-color); +} + +.graph-filter-types-badge { + display: none; + background: var(--primary-color); + color: #fff; + border-radius: 8px; + font-size: 0.6rem; + font-weight: 700; + min-width: 14px; + height: 14px; + line-height: 14px; + text-align: center; + padding: 0 3px; +} + +.graph-filter-types-badge.visible { + display: inline-block; +} + +.graph-filter-types-popover { + display: none; + flex-direction: column; + gap: 6px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + max-height: 320px; + overflow-y: auto; +} + +.graph-filter-types-popover.visible { + display: flex; +} + +.graph-filter-type-group { + display: flex; + flex-direction: column; + gap: 1px; +} + +.graph-filter-type-group-header { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + padding: 2px 4px; + cursor: pointer; + border-radius: 4px; + user-select: none; +} + +.graph-filter-type-group-header:hover { + background: var(--hover-bg, rgba(255,255,255,0.05)); + color: var(--text-color); +} + +.graph-filter-type-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 4px 3px 8px; + font-size: 0.75rem; + color: var(--text-color); + cursor: pointer; + border-radius: 4px; + user-select: none; +} + +.graph-filter-type-item:hover { + background: var(--hover-bg, rgba(255,255,255,0.05)); +} + +.graph-filter-type-item input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--primary-color); + cursor: pointer; + flex-shrink: 0; +} + +.graph-filter-type-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; } .graph-filter-icon { @@ -895,3 +1098,77 @@ z-index: 50; border-radius: 8px; } + +/* ── Keyboard shortcuts help panel ── */ + +.graph-help-panel { + position: absolute; + z-index: 30; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0,0,0,0.2); + width: 300px; + display: none; + flex-direction: column; + font-size: 0.8rem; +} + +.graph-help-panel.visible { + display: flex; +} + +.graph-help-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-weight: 600; + border-bottom: 1px solid var(--border-color); +} + +.graph-help-body { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 360px; + overflow-y: auto; +} + +.graph-help-row { + display: flex; + align-items: center; + gap: 10px; +} + +.graph-help-row kbd, +.graph-help-row .graph-help-mouse { + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.graph-help-row kbd { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 1px 5px; + font-size: 0.7rem; + font-family: var(--font-mono, monospace); +} + +.graph-help-row .graph-help-mouse { + color: var(--text-muted); + font-size: 0.7rem; +} + +.graph-help-row span:last-child { + color: var(--text-color); +} + +.graph-help-sep { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index b2ffead..1674aba 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -165,7 +165,7 @@ import { // Layer 5.5: graph editor import { loadGraphEditor, - toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, + toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphToggleFullscreen, graphAddEntity, } from './features/graph-editor.js'; @@ -487,6 +487,9 @@ Object.assign(window, { toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, + toggleGraphHelp, + graphUndo, + graphRedo, graphFitAll, graphZoomIn, graphZoomOut, diff --git a/server/src/wled_controller/static/js/core/graph-canvas.js b/server/src/wled_controller/static/js/core/graph-canvas.js index 8a0468f..a233418 100644 --- a/server/src/wled_controller/static/js/core/graph-canvas.js +++ b/server/src/wled_controller/static/js/core/graph-canvas.js @@ -30,6 +30,22 @@ export class GraphCanvas { 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; + // Zoom inertia state + this._zoomVelocity = 0; + this._zoomInertiaAnim = null; + this._lastWheelX = 0; + this._lastWheelY = 0; + // Touch / multi-pointer state + this._pointers = new Map(); // pointerId → {x, y} + this._pinchStartDist = 0; + this._pinchStartZoom = 1; + this._pinchMidX = 0; + this._pinchMidY = 0; + // Double-tap detection + this._lastTapTime = 0; + this._lastTapX = 0; + this._lastTapY = 0; + this._isTouch = false; this._bind(); } @@ -150,14 +166,26 @@ export class GraphCanvas { this._zoomAnim = requestAnimationFrame(step); } - zoomIn() { this.zoomTo(this._zoom * 1.25); } - zoomOut() { this.zoomTo(this._zoom / 1.25); } + zoomIn() { this._buttonZoomKick(0.06); } + zoomOut() { this._buttonZoomKick(-0.06); } + + _buttonZoomKick(impulse) { + if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } + const r = this.svg.getBoundingClientRect(); + this._lastWheelX = r.left + r.width / 2; + this._lastWheelY = r.top + r.height / 2; + this._zoomVelocity = this._zoomVelocity * 0.5 + impulse; + const newZoom = this._zoom * (1 + impulse); + this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY); + if (!this._zoomInertiaAnim) this._tickZoomInertia(); + } destroy() { for (const [el, ev, fn, opts] of this._listeners) { el.removeEventListener(ev, fn, opts); } this._listeners = []; + if (this._resizeObs) { this._resizeObs.disconnect(); this._resizeObs = null; } } // ── Private ── @@ -172,17 +200,104 @@ export class GraphCanvas { 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)); + this._on(window, 'pointercancel', this._onPointerUp.bind(this)); + // Preserve view center on container resize (fullscreen, window resize) + this._resizeObs = new ResizeObserver(() => this._onResize()); + this._resizeObs.observe(this.svg); + this._lastSvgRect = this.svg.getBoundingClientRect(); + // Prevent default touch actions on the SVG (browser pan/zoom) + this.svg.style.touchAction = 'none'; + } + + _onResize() { + const prev = this._lastSvgRect; + const next = this.svg.getBoundingClientRect(); + this._lastSvgRect = next; + if (!prev || prev.width === 0 || prev.height === 0) return; + // Compute center in graph-space using old dimensions + const cx = this._vx + (prev.width / 2) / this._zoom; + const cy = this._vy + (prev.height / 2) / this._zoom; + // Recompute vx/vy to keep same center with new dimensions + this._vx = cx - (next.width / 2) / this._zoom; + this._vy = cy - (next.height / 2) / this._zoom; + this._applyTransform(false); } _onWheel(e) { e.preventDefault(); if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; } - const delta = -e.deltaY * ZOOM_SENSITIVITY; - const newZoom = this._zoom * (1 + delta); + + // Accumulate velocity for inertia (capped to prevent runaway) + const impulse = -e.deltaY * ZOOM_SENSITIVITY; + this._zoomVelocity = this._zoomVelocity * 0.5 + impulse * 0.3; + this._lastWheelX = e.clientX; + this._lastWheelY = e.clientY; + + // Apply immediate step + const newZoom = this._zoom * (1 + impulse); this.zoomTo(newZoom, e.clientX, e.clientY); + + // Start inertia decay if not already running + if (!this._zoomInertiaAnim) this._tickZoomInertia(); + } + + _tickZoomInertia() { + const FRICTION = 0.85; + const MIN_VEL = 0.0003; + + this._zoomVelocity *= FRICTION; + + if (Math.abs(this._zoomVelocity) < MIN_VEL) { + this._zoomVelocity = 0; + this._zoomInertiaAnim = null; + return; + } + + const newZoom = this._zoom * (1 + this._zoomVelocity); + this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY); + + this._zoomInertiaAnim = requestAnimationFrame(() => this._tickZoomInertia()); + } + + _pointerDist() { + if (this._pointers.size < 2) return 0; + const pts = [...this._pointers.values()]; + const dx = pts[1].x - pts[0].x; + const dy = pts[1].y - pts[0].y; + return Math.sqrt(dx * dx + dy * dy); + } + + _pointerMid() { + if (this._pointers.size < 2) return null; + const pts = [...this._pointers.values()]; + return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }; } _onPointerDown(e) { + this._isTouch = e.pointerType === 'touch'; + const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE; + + // Track pointer for multi-touch + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + // Second finger → start pinch-to-zoom + if (this._pointers.size === 2) { + this._panning = false; + this._panPending = false; + this.svg.classList.remove('panning'); + this._pinchStartDist = this._pointerDist(); + this._pinchStartZoom = this._zoom; + const mid = this._pointerMid(); + this._pinchMidX = mid.x; + this._pinchMidY = mid.y; + this._panStart = { x: mid.x, y: mid.y }; + this._panViewStart = { x: this._vx, y: this._vy }; + return; + } + + // Ignore 3+ fingers + if (this._pointers.size > 2) return; + // Middle button or Ctrl/Meta+left → immediate pan if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) { e.preventDefault(); @@ -190,11 +305,12 @@ export class GraphCanvas { return; } - // Left-click on SVG background or edge (not on a node) → pending pan + // Left-click / single touch on SVG background → pending pan if (e.button === 0 && !this.blockPan) { const onNode = e.target.closest('.graph-node'); if (!onNode) { this._panPending = true; + this._panDeadZone = deadZone; this._panStart = { x: e.clientX, y: e.clientY }; this._panViewStart = { x: this._vx, y: this._vy }; } @@ -202,11 +318,37 @@ export class GraphCanvas { } _onPointerMove(e) { - // Check dead-zone for pending left-click pan + // Update tracked pointer position + if (this._pointers.has(e.pointerId)) { + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + } + + // Pinch-to-zoom (2 fingers) + if (this._pointers.size === 2 && this._pinchStartDist > 0) { + const dist = this._pointerDist(); + const scale = dist / this._pinchStartDist; + const newZoom = this._pinchStartZoom * scale; + const mid = this._pointerMid(); + + // Zoom around pinch midpoint + this.zoomTo(newZoom, mid.x, mid.y); + + // Pan with pinch movement + const dx = (mid.x - this._panStart.x) / this._zoom; + const dy = (mid.y - this._panStart.y) / this._zoom; + this._vx = this._panViewStart.x - dx; + this._vy = this._panViewStart.y - dy; + this._applyTransform(false); + if (this._onZoomChange) this._onZoomChange(this._zoom); + return; + } + + // Check dead-zone for pending single-finger pan + const dz = this._panDeadZone || PAN_DEAD_ZONE; 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) { + if (Math.abs(dx) > dz || Math.abs(dy) > dz) { this._panning = true; this.svg.classList.add('panning'); this.svg.setPointerCapture(e.pointerId); @@ -221,15 +363,50 @@ export class GraphCanvas { this._applyTransform(false); } - _onPointerUp() { + _onPointerUp(e) { + this._pointers.delete(e.pointerId); + + // If we were pinching and one finger lifts, reset pinch state + if (this._pinchStartDist > 0 && this._pointers.size < 2) { + this._pinchStartDist = 0; + // If one finger remains, restart pan from it + if (this._pointers.size === 1) { + const pt = [...this._pointers.values()][0]; + this._panPending = true; + this._panStart = { x: pt.x, y: pt.y }; + this._panViewStart = { x: this._vx, y: this._vy }; + } + return; + } + 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; }); } + + // Double-tap detection (touch only) + if (this._isTouch && this._pointers.size === 0) { + const now = performance.now(); + const dt = now - this._lastTapTime; + const tapDx = e.clientX - this._lastTapX; + const tapDy = e.clientY - this._lastTapY; + if (dt < 350 && Math.abs(tapDx) < 30 && Math.abs(tapDy) < 30 && !this._justPanned) { + // Double-tap → zoom in/out toggle + const r = this.svg.getBoundingClientRect(); + const gx = (e.clientX - r.left) / this._zoom + this._vx; + const gy = (e.clientY - r.top) / this._zoom + this._vy; + const targetZoom = this._zoom < 1.2 ? 1.8 : 1.0; + this.zoomToPoint(targetZoom, gx, gy, 300); + this._lastTapTime = 0; // reset so triple-tap doesn't re-trigger + return; + } + this._lastTapTime = now; + this._lastTapX = e.clientX; + this._lastTapY = e.clientY; + } } _startPan(e) { diff --git a/server/src/wled_controller/static/js/core/graph-edges.js b/server/src/wled_controller/static/js/core/graph-edges.js index 6256874..a993cd3 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -241,7 +241,10 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) { * @param {Set} runningIds - IDs of currently running nodes */ export function renderFlowDots(group, edges, runningIds) { + // Clear previous flow state group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove()); + group.querySelectorAll('.graph-edge.graph-edge-active').forEach(el => el.classList.remove('graph-edge-active')); + if (!runningIds || runningIds.size === 0) return; // Build adjacency index for O(E) BFS instead of O(N*E) @@ -277,16 +280,17 @@ export function renderFlowDots(group, edges, runningIds) { const edge = edges[idx]; const pathEl = pathIndex.get(`${edge.from}|${edge.to}|${edge.field || ''}`); if (!pathEl) continue; + + // Hybrid: static styling (thicker + glow) + pathEl.classList.add('graph-edge-active'); + + // Animated dots flowing along the path const d = pathEl.getAttribute('d'); if (!d) continue; - const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default; const flowG = svgEl('g', { class: 'graph-edge-flow' }); - - // Two dots staggered for smoother visual flow for (const beginFrac of ['0s', '1s']) { - const circle = svgEl('circle', { fill: color, opacity: '0.85' }); - circle.setAttribute('r', '3'); + const circle = svgEl('circle', { fill: color, opacity: '0.9', r: '2.5' }); const anim = document.createElementNS(SVG_NS, 'animateMotion'); anim.setAttribute('dur', '2s'); anim.setAttribute('repeatCount', 'indefinite'); diff --git a/server/src/wled_controller/static/js/core/graph-layout.js b/server/src/wled_controller/static/js/core/graph-layout.js index 6a7929b..9eb0fc7 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.js +++ b/server/src/wled_controller/static/js/core/graph-layout.js @@ -162,7 +162,7 @@ function buildGraph(e) { 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 }); + nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra }); } function addEdge(from, to, field, label = '') { @@ -179,72 +179,72 @@ function buildGraph(e) { // 1. Devices for (const d of e.devices || []) { - addNode(d.id, 'device', d.name, d.device_type); + addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags }); } // 2. Capture templates for (const t of e.captureTemplates || []) { - addNode(t.id, 'capture_template', t.name, t.engine_type); + addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags }); } // 3. PP templates for (const t of e.ppTemplates || []) { - addNode(t.id, 'pp_template', t.name, ''); + addNode(t.id, 'pp_template', t.name, '', { tags: t.tags }); } // 4. Audio templates for (const t of e.audioTemplates || []) { - addNode(t.id, 'audio_template', t.name, t.engine_type); + addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags }); } // 5. Pattern templates for (const t of e.patternTemplates || []) { - addNode(t.id, 'pattern_template', t.name, ''); + addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags }); } // 6. Sync clocks for (const c of e.syncClocks || []) { - addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false }); + addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags }); } // 7. Picture sources for (const s of e.pictureSources || []) { - addNode(s.id, 'picture_source', s.name, s.stream_type); + addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags }); } // 8. Audio sources for (const s of e.audioSources || []) { - addNode(s.id, 'audio_source', s.name, s.source_type); + addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags }); } // 9. Value sources for (const s of e.valueSources || []) { - addNode(s.id, 'value_source', s.name, s.source_type); + addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags }); } // 10. Color strip sources for (const s of e.colorStripSources || []) { - addNode(s.id, 'color_strip_source', s.name, s.source_type); + addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags }); } // 11. Output targets for (const t of e.outputTargets || []) { - addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false }); + addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags }); } // 12. Scene presets for (const s of e.scenePresets || []) { - addNode(s.id, 'scene_preset', s.name, ''); + addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags }); } // 13. Automations for (const a of e.automations || []) { - addNode(a.id, 'automation', a.name, '', { running: a.enabled || false }); + addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags }); } // 14. Color strip processing templates (CSPT) for (const t of e.csptTemplates || []) { - addNode(t.id, 'cspt', t.name, ''); + addNode(t.id, 'cspt', t.name, '', { tags: t.tags }); } // ── Edges ── diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index 72d7f0b..ac86c0a 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -9,6 +9,19 @@ import * as P from './icon-paths.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; +// ── Port type → human-readable label ── +const PORT_LABELS = { + template: 'Template', + picture: 'Picture', + colorstrip: 'Strip', + value: 'Value', + audio: 'Audio', + clock: 'Clock', + scene: 'Scene', + device: 'Device', + default: 'Ref', +}; + // ── Entity kind → default icon path data ── const KIND_ICONS = { device: P.monitor, @@ -210,9 +223,17 @@ function renderNode(node, callbacks) { 'data-port-dir': 'in', }); const tip = svgEl('title'); - tip.textContent = t; + tip.textContent = PORT_LABELS[t] || t; dot.appendChild(tip); g.appendChild(dot); + // Port label (shown on hover) — outside node, left of port + const lbl = svgEl('text', { + class: 'graph-port-label graph-port-label-in', + x: -8, y: py + 3, + 'text-anchor': 'end', + }); + lbl.textContent = PORT_LABELS[t] || t; + g.appendChild(lbl); } } @@ -230,9 +251,16 @@ function renderNode(node, callbacks) { 'data-port-dir': 'out', }); const tip = svgEl('title'); - tip.textContent = t; + tip.textContent = PORT_LABELS[t] || t; dot.appendChild(tip); g.appendChild(dot); + // Port label (shown on hover) — outside node, right of port + const lbl = svgEl('text', { + class: 'graph-port-label graph-port-label-out', + x: width + 8, y: py + 3, + }); + lbl.textContent = PORT_LABELS[t] || t; + g.appendChild(lbl); } } @@ -298,14 +326,14 @@ function renderNode(node, callbacks) { return g; } -// Entity kinds that support start/stop -const START_STOP_KINDS = new Set(['output_target', 'sync_clock']); +// Entity kinds that support start/stop (including automation enable/disable) +const START_STOP_KINDS = new Set(['output_target', 'sync_clock', 'automation']); // Entity kinds that support test/preview const TEST_KINDS = new Set([ 'capture_template', 'pp_template', 'audio_template', 'picture_source', 'audio_source', 'value_source', - 'color_strip_source', + 'color_strip_source', 'cspt', ]); function _createOverlay(node, nodeWidth, callbacks) { @@ -325,9 +353,14 @@ function _createOverlay(node, nodeWidth, callbacks) { }); } + // Scene preset activation + if (node.kind === 'scene_preset') { + btns.push({ svgPath: P.play, action: 'activate', cls: 'success' }); + } + // Test button for applicable kinds if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) { - btns.push({ svgPath: P.eye, action: 'test', cls: '' }); + btns.push({ svgPath: P.flaskConical, action: 'test', cls: '' }); } // Notification test for notification color strip sources @@ -335,6 +368,9 @@ function _createOverlay(node, nodeWidth, callbacks) { btns.push({ svgPath: P.bellRing, action: 'notify', cls: '' }); } + // Clone (smaller scale to fit the compact button) + btns.push({ svgPath: P.copy, action: 'clone', cls: '', scale: 0.6 }); + // Always: edit and delete btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎ btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖ @@ -360,8 +396,10 @@ function _createOverlay(node, nodeWidth, callbacks) { const ACTION_LABELS = { startstop: node.running ? 'Stop' : 'Start', + activate: 'Activate preset', test: 'Test / Preview', notify: 'Test notification', + clone: 'Clone', edit: 'Edit', delete: 'Delete', }; @@ -372,8 +410,11 @@ function _createOverlay(node, nodeWidth, callbacks) { const bg = svgEl('g', { class: `graph-node-overlay-btn ${btn.cls}` }); bg.appendChild(svgEl('rect', { x: bx, y: by, width: btnSize, height: btnSize })); if (btn.svgPath) { + const s = btn.scale || ((btnSize - 4) / 24); + const iconSize = 24 * s; + const pad = (btnSize - iconSize) / 2; const iconG = svgEl('g', { - transform: `translate(${bx + 2}, ${by + 2}) scale(${(btnSize - 4) / 24})`, + transform: `translate(${bx + pad}, ${by + pad}) scale(${s})`, }); iconG.innerHTML = btn.svgPath; iconG.setAttribute('fill', 'none'); @@ -397,6 +438,8 @@ function _createOverlay(node, nodeWidth, callbacks) { if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node); if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(node); if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node); + if (btn.action === 'clone' && callbacks.onCloneNode) callbacks.onCloneNode(node); + if (btn.action === 'activate' && callbacks.onActivatePreset) callbacks.onActivatePreset(node); }); overlay.appendChild(bg); }); diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index e73af9c..a269fb3 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -106,6 +106,63 @@ function _isFullscreen() { return !!document.fullscreenElement; } // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; +const _TB_MARGIN = 12; + +// 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br +function _computeDockPositions(container, el) { + const cr = container.getBoundingClientRect(); + const w = el.offsetWidth, h = el.offsetHeight; + const m = _TB_MARGIN; + return { + tl: { x: m, y: m }, + tc: { x: (cr.width - w) / 2, y: m }, + tr: { x: cr.width - w - m, y: m }, + cl: { x: m, y: (cr.height - h) / 2 }, + cr: { x: cr.width - w - m, y: (cr.height - h) / 2 }, + bl: { x: m, y: cr.height - h - m }, + bc: { x: (cr.width - w) / 2, y: cr.height - h - m }, + br: { x: cr.width - w - m, y: cr.height - h - m }, + }; +} + +function _nearestDock(container, el) { + const docks = _computeDockPositions(container, el); + const cx = el.offsetLeft + el.offsetWidth / 2; + const cy = el.offsetTop + el.offsetHeight / 2; + let best = 'tl', bestDist = Infinity; + for (const [key, pos] of Object.entries(docks)) { + const dx = (pos.x + el.offsetWidth / 2) - cx; + const dy = (pos.y + el.offsetHeight / 2) - cy; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { bestDist = dist; best = key; } + } + return best; +} + +function _isVerticalDock(dock) { + return dock === 'cl' || dock === 'cr'; +} + +function _applyToolbarDock(el, container, dock, animate = false) { + const isVert = _isVerticalDock(dock); + el.classList.toggle('vertical', isVert); + // Recompute positions after layout change + requestAnimationFrame(() => { + const docks = _computeDockPositions(container, el); + const pos = docks[dock]; + if (!pos) return; + if (animate) { + el.style.transition = 'left 0.25s ease, top 0.25s ease'; + el.style.left = pos.x + 'px'; + el.style.top = pos.y + 'px'; + setTimeout(() => { el.style.transition = ''; }, 260); + } else { + el.style.left = pos.x + 'px'; + el.style.top = pos.y + 'px'; + } + }); +} + function _loadToolbarPos() { try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; } } @@ -238,6 +295,72 @@ export function toggleGraphMinimap() { if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible); } +/* ── Filter type groups ── */ + +const _FILTER_GROUPS = [ + { key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] }, + { key: 'strip', kinds: ['color_strip_source', 'cspt'] }, + { key: 'audio', kinds: ['audio_source', 'audio_template'] }, + { key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] }, + { key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] }, +]; + +function _buildFilterGroupsHTML() { + const groupLabels = { + capture: t('graph.filter_group.capture') || 'Capture', + strip: t('graph.filter_group.strip') || 'Color Strip', + audio: t('graph.filter_group.audio') || 'Audio', + targets: t('graph.filter_group.targets') || 'Targets', + other: t('graph.filter_group.other') || 'Other', + }; + return _FILTER_GROUPS.map(g => { + const items = g.kinds.map(kind => { + const label = ENTITY_LABELS[kind] || kind; + const color = ENTITY_COLORS[kind] || '#666'; + return ``; + }).join(''); + return `
+
${groupLabels[g.key]}
+ ${items} +
`; + }).join(''); +} + +function _updateFilterBadge() { + const badge = document.querySelector('.graph-filter-types-badge'); + if (!badge) return; + const count = _filterKinds.size; + badge.textContent = count > 0 ? String(count) : ''; + badge.classList.toggle('visible', count > 0); + // Also update toolbar button + const btn = document.querySelector('.graph-filter-btn'); + if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery); +} + +function _syncPopoverCheckboxes() { + const popover = document.querySelector('.graph-filter-types-popover'); + if (!popover) return; + popover.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.checked = _filterKinds.has(cb.value); + }); +} + +export function toggleGraphFilterTypes(btn) { + const popover = document.querySelector('.graph-filter-types-popover'); + if (!popover) return; + const isOpen = popover.classList.contains('visible'); + if (isOpen) { + popover.classList.remove('visible'); + } else { + _syncPopoverCheckboxes(); + popover.classList.add('visible'); + } +} + export function toggleGraphFilter() { _filterVisible = !_filterVisible; const bar = document.querySelector('.graph-filter'); @@ -246,17 +369,20 @@ export function toggleGraphFilter() { if (_filterVisible) { const input = bar.querySelector('.graph-filter-input'); if (input) { input.value = _filterQuery; input.focus(); } - // Restore pill active states - bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { - p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); - }); + // Restore running pill states bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); + _syncPopoverCheckboxes(); + _updateFilterBadge(); } else { _filterKinds.clear(); _filterRunning = null; + // Close types popover + const popover = bar.querySelector('.graph-filter-types-popover'); + if (popover) popover.classList.remove('visible'); _applyFilter(''); + _updateFilterBadge(); } } @@ -269,18 +395,35 @@ function _applyFilter(query) { if (!_nodeMap) return; - const hasTextFilter = !!q; + // Parse structured filters: type:device, tag:foo, running:true + let textPart = q; + const parsedKinds = new Set(); + const parsedTags = []; + const tokens = q.split(/\s+/); + const plainTokens = []; + for (const tok of tokens) { + if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); } + else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); } + else { plainTokens.push(tok); } + } + textPart = plainTokens.join(' '); + + const hasTextFilter = !!textPart; + const hasParsedKinds = parsedKinds.size > 0; + const hasParsedTags = parsedTags.length > 0; const hasKindFilter = _filterKinds.size > 0; const hasRunningFilter = _filterRunning !== null; - const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter; + const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags; // Build set of matching node IDs const matchIds = new Set(); for (const node of _nodeMap.values()) { - const textMatch = !hasTextFilter || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q); + const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart); const kindMatch = !hasKindFilter || _filterKinds.has(node.kind); + const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || '')); + const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t))); const runMatch = !hasRunningFilter || (node.running === _filterRunning); - if (textMatch && kindMatch && runMatch) matchIds.add(node.id); + if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id); } // Apply filtered-out class to nodes @@ -367,6 +510,9 @@ const ADD_ENTITY_MAP = [ { kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) }, { kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) }, { kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) }, + { kind: 'sync_clock', fn: () => window.showSyncClockModal?.(), icon: _ico(P.clock) }, + { kind: 'scene_preset', fn: () => window.editScenePreset?.(), icon: _ico(P.sparkles) }, + { kind: 'pattern_template', fn: () => window.showPatternTemplateEditor?.(),icon: _ico(P.fileText) }, ]; // All caches to watch for new entity creation @@ -509,6 +655,8 @@ function _renderGraph(container) { onStartStopNode: _onStartStopNode, onTestNode: _onTestNode, onNotificationTest: _onNotificationTest, + onCloneNode: _onCloneNode, + onActivatePreset: _onActivatePreset, }); markOrphans(nodeGroup, _nodeMap, _edges); @@ -579,16 +727,39 @@ function _renderGraph(container) { }); } - // Entity type pills - container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => { - pill.addEventListener('click', () => { - const kind = pill.dataset.kind; - if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); } - else { _filterKinds.add(kind); pill.classList.add('active'); } + // Entity type checkboxes in popover + container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) _filterKinds.add(cb.value); + else _filterKinds.delete(cb.value); + _updateFilterBadge(); _applyFilter(); }); }); + // Group header toggles (click group label → toggle all in group) + container.querySelectorAll('[data-group-toggle]').forEach(header => { + header.addEventListener('click', () => { + const groupKey = header.dataset.groupToggle; + const group = _FILTER_GROUPS.find(g => g.key === groupKey); + if (!group) return; + const allActive = group.kinds.every(k => _filterKinds.has(k)); + group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); }); + _syncPopoverCheckboxes(); + _updateFilterBadge(); + _applyFilter(); + }); + }); + + // Close popover when clicking outside + container.addEventListener('click', (e) => { + const popover = container.querySelector('.graph-filter-types-popover'); + if (!popover || !popover.classList.contains('visible')) return; + if (!e.target.closest('.graph-filter-types-popover') && !e.target.closest('.graph-filter-types-btn')) { + popover.classList.remove('visible'); + } + }); + // Running/stopped pills container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => { pill.addEventListener('click', () => { @@ -598,10 +769,10 @@ function _renderGraph(container) { pill.classList.remove('active'); } else { _filterRunning = val; - // Deactivate sibling running pills, activate this one container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active')); pill.classList.add('active'); } + _updateFilterBadge(); _applyFilter(); }); }); @@ -611,13 +782,12 @@ function _renderGraph(container) { const bar = container.querySelector('.graph-filter'); if (bar) { bar.classList.add('visible'); - bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { - p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); - }); + _syncPopoverCheckboxes(); bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); } + _updateFilterBadge(); _applyFilter(_filterQuery); } @@ -663,13 +833,9 @@ function _graphHTML() { const mmRect = _loadMinimapRect(); // Only set size from saved state; position is applied in _initMinimap via anchor logic const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : ''; - // Toolbar position is applied in _initToolbarDrag via anchor logic - const tbPos = _loadToolbarPos(); - const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : ''; - return `
-
+
+ + + @@ -705,6 +878,9 @@ function _graphHTML() { +
@@ -727,14 +903,16 @@ function _graphHTML() {
-
- ${Object.entries(ENTITY_LABELS).map(([kind, label]) => - `` - ).join('')} - +
+
+
+ ${_buildFilterGroupsHTML()} +
@@ -946,18 +1124,112 @@ function _initResizeClamp(container) { if (_resizeObserver) _resizeObserver.disconnect(); _resizeObserver = new ResizeObserver(() => { _reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect); - _reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos); _reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos); + _reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos); + // Toolbar uses dock system, not anchor system + const tb = container.querySelector('.graph-toolbar'); + if (tb) { + const saved = _loadToolbarPos(); + const dock = saved?.dock || 'tl'; + _applyToolbarDock(tb, container, dock, false); + } }); _resizeObserver.observe(container); } /* ── Toolbar drag ── */ +let _dockIndicators = null; + +function _showDockIndicators(container) { + _hideDockIndicators(); + const cr = container.getBoundingClientRect(); + const m = _TB_MARGIN + 16; // offset from edges + // 8 dock positions as percentage-based fixed points + const positions = { + tl: { x: m, y: m }, + tc: { x: cr.width / 2, y: m }, + tr: { x: cr.width - m, y: m }, + cl: { x: m, y: cr.height / 2 }, + cr: { x: cr.width - m, y: cr.height / 2 }, + bl: { x: m, y: cr.height - m }, + bc: { x: cr.width / 2, y: cr.height - m }, + br: { x: cr.width - m, y: cr.height - m }, + }; + const wrap = document.createElement('div'); + wrap.className = 'graph-dock-indicators'; + for (const [key, pos] of Object.entries(positions)) { + const dot = document.createElement('div'); + dot.className = 'graph-dock-dot'; + dot.dataset.dock = key; + dot.style.left = pos.x + 'px'; + dot.style.top = pos.y + 'px'; + wrap.appendChild(dot); + } + container.appendChild(wrap); + _dockIndicators = wrap; +} + +function _updateDockHighlight(container, tbEl) { + if (!_dockIndicators) return; + const nearest = _nearestDock(container, tbEl); + _dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => { + d.classList.toggle('nearest', d.dataset.dock === nearest); + }); +} + +function _hideDockIndicators() { + if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; } +} + function _initToolbarDrag(tbEl) { if (!tbEl) return; + const container = tbEl.closest('.graph-container'); + if (!container) return; const handle = tbEl.querySelector('.graph-toolbar-drag'); - _makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos }); + if (!handle) return; + + // Restore saved dock position + const saved = _loadToolbarPos(); + const dock = saved?.dock || 'tl'; + _applyToolbarDock(tbEl, container, dock, false); + + let dragStart = null, dragStartPos = null; + + handle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + // If vertical, temporarily switch to horizontal for free dragging + tbEl.classList.remove('vertical'); + requestAnimationFrame(() => { + dragStart = { x: e.clientX, y: e.clientY }; + dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop }; + handle.classList.add('dragging'); + handle.setPointerCapture(e.pointerId); + _showDockIndicators(container); + }); + }); + handle.addEventListener('pointermove', (e) => { + if (!dragStart) return; + const cr = container.getBoundingClientRect(); + const ew = tbEl.offsetWidth, eh = 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 - ew, l)); + t = Math.max(0, Math.min(cr.height - eh, t)); + tbEl.style.left = l + 'px'; + tbEl.style.top = t + 'px'; + _updateDockHighlight(container, tbEl); + }); + handle.addEventListener('pointerup', () => { + if (!dragStart) return; + dragStart = null; + handle.classList.remove('dragging'); + _hideDockIndicators(); + // Snap to nearest dock position + const newDock = _nearestDock(container, tbEl); + _applyToolbarDock(tbEl, container, newDock, true); + _saveToolbarPos({ dock: newDock }); + }); } @@ -1021,8 +1293,9 @@ function _onEditNode(node) { audio_source: () => window.editAudioSource?.(node.id), value_source: () => window.editValueSource?.(node.id), color_strip_source: () => window.showCSSEditor?.(node.id), - sync_clock: () => {}, + sync_clock: () => window.editSyncClock?.(node.id), output_target: () => window.showTargetEditor?.(node.id), + cspt: () => window.editCSPT?.(node.id), scene_preset: () => window.editScenePreset?.(node.id), automation: () => window.openAutomationEditor?.(node.id), }; @@ -1043,10 +1316,63 @@ function _onDeleteNode(node) { output_target: () => window.deleteTarget?.(node.id), scene_preset: () => window.deleteScenePreset?.(node.id), automation: () => window.deleteAutomation?.(node.id), + cspt: () => window.deleteCSPT?.(node.id), + sync_clock: () => window.deleteSyncClock?.(node.id), }; fnMap[node.kind]?.(); } +async function _bulkDeleteSelected() { + const count = _selectedIds.size; + if (count < 2) return; + const ok = await showConfirm( + (t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count)) + ); + if (!ok) return; + for (const id of _selectedIds) { + const node = _nodeMap.get(id); + if (node) _onDeleteNode(node); + } + _selectedIds.clear(); +} + +function _onCloneNode(node) { + const fnMap = { + device: () => window.cloneDevice?.(node.id), + capture_template: () => window.cloneCaptureTemplate?.(node.id), + pp_template: () => window.clonePPTemplate?.(node.id), + audio_template: () => window.cloneAudioTemplate?.(node.id), + pattern_template: () => window.clonePatternTemplate?.(node.id), + picture_source: () => window.cloneStream?.(node.id), + audio_source: () => window.cloneAudioSource?.(node.id), + value_source: () => window.cloneValueSource?.(node.id), + color_strip_source: () => window.cloneColorStrip?.(node.id), + output_target: () => window.cloneTarget?.(node.id), + scene_preset: () => window.cloneScenePreset?.(node.id), + automation: () => window.cloneAutomation?.(node.id), + cspt: () => window.cloneCSPT?.(node.id), + sync_clock: () => window.cloneSyncClock?.(node.id), + }; + _watchForNewEntity(); + fnMap[node.kind]?.(); +} + +async function _onActivatePreset(node) { + if (node.kind !== 'scene_preset') return; + try { + const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' }); + if (resp.ok) { + showToast(t('scene_preset.activated') || 'Preset activated', 'success'); + setTimeout(() => loadGraphEditor(), 500); + } else { + const err = await resp.json().catch(() => ({})); + showToast(err.detail || 'Activation failed', 'error'); + } + } catch (e) { + showToast(e.message, 'error'); + } +} + function _onStartStopNode(node) { const newRunning = !node.running; // Optimistic update — toggle UI immediately @@ -1073,6 +1399,17 @@ function _onStartStopNode(node) { _updateNodeRunning(node.id, !newRunning); // revert } }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); + } else if (node.kind === 'automation') { + fetchWithAuth(`/automations/${node.id}`, { + method: 'PUT', + body: JSON.stringify({ enabled: newRunning }), + }).then(resp => { + if (resp.ok) { + showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success'); + } else { + _updateNodeRunning(node.id, !newRunning); + } + }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); } } @@ -1105,6 +1442,7 @@ function _onTestNode(node) { audio_source: () => window.testAudioSource?.(node.id), value_source: () => window.testValueSource?.(node.id), color_strip_source: () => window.testColorStrip?.(node.id), + cspt: () => window.testCSPT?.(node.id), output_target: () => window.testKCTarget?.(node.id), }; fnMap[node.kind]?.(); @@ -1132,7 +1470,7 @@ function _onKeydown(e) { _deselect(ng, eg); } } - // Delete key → detach selected edge or delete single selected node + // Delete key → detach selected edge or delete selected node(s) if (e.key === 'Delete' && !inInput) { if (_selectedEdge) { _detachSelectedEdge(); @@ -1140,6 +1478,8 @@ function _onKeydown(e) { const nodeId = [..._selectedIds][0]; const node = _nodeMap.get(nodeId); if (node) _onDeleteNode(node); + } else if (_selectedIds.size > 1) { + _bulkDeleteSelected(); } } // Ctrl+A → select all @@ -1156,6 +1496,16 @@ function _onKeydown(e) { if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) { graphAddEntity(); } + // ? → keyboard shortcuts help + if (e.key === '?' && !inInput) { + e.preventDefault(); + toggleGraphHelp(); + } + // Ctrl+Z / Ctrl+Shift+Z → undo/redo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) { + e.preventDefault(); + if (e.shiftKey) _redo(); else _undo(); + } // Arrow keys / WASD → spatial navigation between nodes if (_selectedIds.size <= 1 && !inInput) { const dir = _arrowDir(e); @@ -1240,7 +1590,15 @@ function _navigateDirection(dir) { const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); if (ng) updateSelection(ng, _selectedIds); - if (eg && _edges) highlightChain(eg, bestNode.id, _edges); + if (eg && _edges) { + const chain = highlightChain(eg, bestNode.id, _edges); + // Dim non-chain nodes like _onNodeClick does + if (ng) { + ng.querySelectorAll('.graph-node').forEach(n => { + n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25'; + }); + } + } if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true); } } @@ -1697,6 +2055,122 @@ async function _doConnect(targetId, targetKind, field, sourceId) { } } +/* ── Undo / Redo ── */ + +const _undoStack = []; +const _redoStack = []; +const _MAX_UNDO = 30; + +/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */ +export function pushUndoAction(action) { + _undoStack.push(action); + if (_undoStack.length > _MAX_UNDO) _undoStack.shift(); + _redoStack.length = 0; + _updateUndoRedoButtons(); +} + +function _updateUndoRedoButtons() { + const undoBtn = document.getElementById('graph-undo-btn'); + const redoBtn = document.getElementById('graph-redo-btn'); + if (undoBtn) undoBtn.disabled = _undoStack.length === 0; + if (redoBtn) redoBtn.disabled = _redoStack.length === 0; +} + +export async function graphUndo() { await _undo(); } +export async function graphRedo() { await _redo(); } + +async function _undo() { + if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; } + const action = _undoStack.pop(); + try { + await action.undo(); + _redoStack.push(action); + showToast(t('graph.undone') || `Undone: ${action.label}`, 'info'); + _updateUndoRedoButtons(); + await loadGraphEditor(); + } catch (e) { + showToast(e.message, 'error'); + _updateUndoRedoButtons(); + } +} + +async function _redo() { + if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; } + const action = _redoStack.pop(); + try { + await action.redo(); + _undoStack.push(action); + showToast(t('graph.redone') || `Redone: ${action.label}`, 'info'); + _updateUndoRedoButtons(); + await loadGraphEditor(); + } catch (e) { + showToast(e.message, 'error'); + _updateUndoRedoButtons(); + } +} + +/* ── Keyboard shortcuts help ── */ + +let _helpVisible = false; + +function _loadHelpPos() { + try { + const saved = JSON.parse(localStorage.getItem('graph_help_pos')); + return saved || { anchor: 'br', offsetX: 12, offsetY: 12 }; + } catch { return { anchor: 'br', offsetX: 12, offsetY: 12 }; } +} +function _saveHelpPos(pos) { + localStorage.setItem('graph_help_pos', JSON.stringify(pos)); +} + +export function toggleGraphHelp() { + _helpVisible = !_helpVisible; + const helpBtn = document.getElementById('graph-help-toggle'); + if (helpBtn) helpBtn.classList.toggle('active', _helpVisible); + let panel = document.querySelector('.graph-help-panel'); + if (_helpVisible) { + if (!panel) { + const container = document.querySelector('#graph-editor-content .graph-container'); + if (!container) return; + panel = document.createElement('div'); + panel.className = 'graph-help-panel visible'; + panel.innerHTML = ` +
+ ${t('graph.help_title')} +
+
+
/ ${t('graph.help.search')}
+
F ${t('graph.help.filter')}
+
+ ${t('graph.help.add')}
+
? ${t('graph.help.shortcuts')}
+
Del ${t('graph.help.delete')}
+
Ctrl+A ${t('graph.help.select_all')}
+
Ctrl+Z ${t('graph.help.undo')}
+
Ctrl+Shift+Z ${t('graph.help.redo')}
+
F11 ${t('graph.help.fullscreen')}
+
Esc ${t('graph.help.deselect')}
+
\u2190\u2191\u2192\u2193 ${t('graph.help.navigate')}
+
+
${t('graph.help.click')} ${t('graph.help.click_desc')}
+
${t('graph.help.dblclick')} ${t('graph.help.dblclick_desc')}
+
${t('graph.help.shift_click')} ${t('graph.help.shift_click_desc')}
+
${t('graph.help.shift_drag')} ${t('graph.help.shift_drag_desc')}
+
${t('graph.help.drag_node')} ${t('graph.help.drag_node_desc')}
+
${t('graph.help.drag_port')} ${t('graph.help.drag_port_desc')}
+
${t('graph.help.right_click')} ${t('graph.help.right_click_desc')}
+
`; + container.appendChild(panel); + // Make draggable with anchor persistence + const header = panel.querySelector('.graph-help-header'); + _makeDraggable(panel, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos }); + } else { + panel.classList.add('visible'); + } + } else if (panel) { + panel.classList.remove('visible'); + } +} + /* ── Edge context menu (right-click to detach) ── */ function _onEdgeContextMenu(edgePath, e, container) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 18dc609..d799dc4 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1512,7 +1512,7 @@ "graph.add_entity": "Add entity", "graph.color_picker": "Node color", "graph.filter": "Filter nodes", - "graph.filter_placeholder": "Filter by name...", + "graph.filter_placeholder": "Filter: name, type:x, tag:x", "graph.filter_clear": "Clear filter", "graph.filter_running": "Running", "graph.filter_stopped": "Stopped", @@ -1521,5 +1521,37 @@ "graph.filter_group.strip": "Color Strip", "graph.filter_group.audio": "Audio", "graph.filter_group.targets": "Targets", - "graph.filter_group.other": "Other" + "graph.filter_group.other": "Other", + "graph.bulk_delete_confirm": "Delete {count} selected entities?", + "graph.nothing_to_undo": "Nothing to undo", + "graph.nothing_to_redo": "Nothing to redo", + "graph.help_title": "Keyboard Shortcuts", + "graph.help.search": "Search", + "graph.help.filter": "Filter", + "graph.help.add": "Add entity", + "graph.help.shortcuts": "Shortcuts", + "graph.help.delete": "Delete / Detach", + "graph.help.select_all": "Select all", + "graph.help.undo": "Undo", + "graph.help.redo": "Redo", + "graph.help.fullscreen": "Fullscreen", + "graph.help.deselect": "Deselect", + "graph.help.navigate": "Navigate nodes", + "graph.help.click": "Click", + "graph.help.click_desc": "Select node", + "graph.help.dblclick": "Double-click", + "graph.help.dblclick_desc": "Zoom to node", + "graph.help.shift_click": "Shift+Click", + "graph.help.shift_click_desc": "Multi-select", + "graph.help.shift_drag": "Shift+Drag", + "graph.help.shift_drag_desc": "Rubber-band select", + "graph.help.drag_node": "Drag node", + "graph.help.drag_node_desc": "Reposition", + "graph.help.drag_port": "Drag port", + "graph.help.drag_port_desc": "Connect entities", + "graph.help.right_click": "Right-click edge", + "graph.help.right_click_desc": "Detach connection", + "automation.enabled": "Automation enabled", + "automation.disabled": "Automation disabled", + "scene_preset.activated": "Preset activated" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index c95dcf6..31d87cc 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1521,5 +1521,37 @@ "graph.filter_group.strip": "Цвет. полосы", "graph.filter_group.audio": "Аудио", "graph.filter_group.targets": "Цели", - "graph.filter_group.other": "Другое" + "graph.filter_group.other": "Другое", + "graph.bulk_delete_confirm": "Удалить {count} выбранных сущностей?", + "graph.nothing_to_undo": "Нечего отменять", + "graph.nothing_to_redo": "Нечего повторять", + "graph.help_title": "Горячие клавиши", + "graph.help.search": "Поиск", + "graph.help.filter": "Фильтр", + "graph.help.add": "Добавить сущность", + "graph.help.shortcuts": "Горячие клавиши", + "graph.help.delete": "Удалить / Отсоединить", + "graph.help.select_all": "Выбрать все", + "graph.help.undo": "Отменить", + "graph.help.redo": "Повторить", + "graph.help.fullscreen": "Полный экран", + "graph.help.deselect": "Снять выбор", + "graph.help.navigate": "Навигация по узлам", + "graph.help.click": "Клик", + "graph.help.click_desc": "Выбрать узел", + "graph.help.dblclick": "Двойной клик", + "graph.help.dblclick_desc": "Приблизить к узлу", + "graph.help.shift_click": "Shift+Клик", + "graph.help.shift_click_desc": "Множественный выбор", + "graph.help.shift_drag": "Shift+Перетащить", + "graph.help.shift_drag_desc": "Выбор рамкой", + "graph.help.drag_node": "Перетащить узел", + "graph.help.drag_node_desc": "Переместить", + "graph.help.drag_port": "Перетащить порт", + "graph.help.drag_port_desc": "Соединить сущности", + "graph.help.right_click": "ПКМ по связи", + "graph.help.right_click_desc": "Отсоединить связь", + "automation.enabled": "Автоматизация включена", + "automation.disabled": "Автоматизация выключена", + "scene_preset.activated": "Пресет активирован" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 43949e4..fc82d5f 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1521,5 +1521,37 @@ "graph.filter_group.strip": "色带", "graph.filter_group.audio": "音频", "graph.filter_group.targets": "目标", - "graph.filter_group.other": "其他" + "graph.filter_group.other": "其他", + "graph.bulk_delete_confirm": "删除 {count} 个选中的实体?", + "graph.nothing_to_undo": "没有可撤销的操作", + "graph.nothing_to_redo": "没有可重做的操作", + "graph.help_title": "键盘快捷键", + "graph.help.search": "搜索", + "graph.help.filter": "筛选", + "graph.help.add": "添加实体", + "graph.help.shortcuts": "快捷键", + "graph.help.delete": "删除 / 断开", + "graph.help.select_all": "全选", + "graph.help.undo": "撤销", + "graph.help.redo": "重做", + "graph.help.fullscreen": "全屏", + "graph.help.deselect": "取消选择", + "graph.help.navigate": "节点导航", + "graph.help.click": "单击", + "graph.help.click_desc": "选择节点", + "graph.help.dblclick": "双击", + "graph.help.dblclick_desc": "缩放到节点", + "graph.help.shift_click": "Shift+单击", + "graph.help.shift_click_desc": "多选", + "graph.help.shift_drag": "Shift+拖拽", + "graph.help.shift_drag_desc": "框选", + "graph.help.drag_node": "拖拽节点", + "graph.help.drag_node_desc": "重新定位", + "graph.help.drag_port": "拖拽端口", + "graph.help.drag_port_desc": "连接实体", + "graph.help.right_click": "右键边线", + "graph.help.right_click_desc": "断开连接", + "automation.enabled": "自动化已启用", + "automation.disabled": "自动化已禁用", + "scene_preset.activated": "预设已激活" }