diff --git a/contexts/frontend.md b/contexts/frontend.md index e79b54e..c6fcee9 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -156,3 +156,9 @@ document.addEventListener('languageChanged', () => { ``` Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern. + +## Visual Graph Editor + +See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions. + +**IMPORTANT:** When adding or modifying entity types, subtypes, or connection fields, the graph editor files **must** be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in `graph-editor.md` for the complete checklist. diff --git a/contexts/graph-editor.md b/contexts/graph-editor.md new file mode 100644 index 0000000..81968dc --- /dev/null +++ b/contexts/graph-editor.md @@ -0,0 +1,93 @@ +# Visual Graph Editor + +**Read this file when working on the graph editor** (`static/js/features/graph-editor.js` and related modules). + +## Architecture + +The graph editor renders all entities (devices, templates, sources, clocks, targets, scenes, automations) as SVG nodes connected by edges in a left-to-right layered layout. + +### Core modules + +| File | Responsibility | +|---|---| +| `js/features/graph-editor.js` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap | +| `js/core/graph-layout.js` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps | +| `js/core/graph-nodes.js` | SVG node rendering, overlay buttons, per-node color overrides | +| `js/core/graph-edges.js` | SVG edge rendering (bezier curves, arrowheads, flow dots) | +| `js/core/graph-canvas.js` | Pan/zoom controller with `zoomToPoint()` rAF animation | +| `js/core/graph-connections.js` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic | +| `css/graph-editor.css` | All graph-specific styles | + +### Data flow + +1. `loadGraphEditor()` → `_fetchAllEntities()` fetches all caches in parallel +2. `computeLayout(entities)` builds ELK graph, runs layout → returns `{nodes: Map, edges: Array, bounds}` +3. `computePorts(nodeMap, edges)` assigns port positions and annotates edges with `fromPortY`/`toPortY` +4. Manual position overrides (`_manualPositions`) applied after layout +5. `renderEdges()` + `renderNodes()` paint SVG elements +6. `GraphCanvas` handles pan/zoom via CSS `transform: scale() translate()` + +### Edge rendering + +Edges always use `_defaultBezier()` (port-aware cubic bezier) — ELK edge routing is ignored because it lacks port awareness, causing misaligned bend points. ELK is only used for node positioning. + +### Port system + +Nodes have input ports (left) and output ports (right), colored by edge type. Port types are ordered vertically: `template > picture > colorstrip > value > audio > clock > scene > device > default`. + +## Keeping the graph in sync with entity types + +**CRITICAL:** When adding or modifying entity types in the system, these graph files MUST be updated: + +### Adding a new entity type + +1. **`graph-layout.js`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops) +2. **`graph-layout.js`** — `edgeType()` function if the new type needs a distinct edge color +3. **`graph-nodes.js`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons) +4. **`graph-nodes.js`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test +5. **`graph-connections.js`** — `CONNECTION_MAP` for drag-connect edge creation +6. **`graph-editor.js`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function) +7. **`graph-editor.js`** — `ALL_CACHES` array (for new-entity-focus watcher) +8. **`graph-editor.js`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`) +9. **`core/state.js`** — Add/export the new DataCache +10. **`app.js`** — Import and window-export the add/edit/clone functions + +### Adding a new field/connection to an existing entity + +1. **`graph-layout.js`** — `buildGraph()` edges section: add `addEdge()` call +2. **`graph-connections.js`** — `CONNECTION_MAP`: add the field entry +3. **`graph-edges.js`** — `EDGE_COLORS` if a new edge type is needed + +### Adding a new entity subtype + +1. **`graph-nodes.js`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype +2. **`graph-layout.js`** — `buildGraph()` — ensure `subtype` is extracted from the entity data + +## Features & keyboard shortcuts + +| Key | Action | +|---|---| +| `/` | Open search | +| `F` | Toggle filter | +| `F11` | Toggle fullscreen | +| `+` | Add entity menu | +| `Escape` | Close filter → close search → deselect all | +| `Delete` | Delete selected edge or node | +| `Arrows / WASD` | Spatial navigation between nodes | +| `Ctrl+A` | Select all nodes | + +## Node color overrides + +Per-node colors stored in `localStorage` key `graph_node_colors`. The `getNodeColor(nodeId, kind)` function returns the override or falls back to `ENTITY_COLORS[kind]`. The color bar on the left side of each node is clickable to open a native color picker. + +## Filter system + +The filter bar (toggled with F or toolbar button) filters nodes by name/kind/subtype. Non-matching nodes get the `.graph-filtered-out` CSS class (low opacity, no pointer events). Edges where either endpoint is filtered also dim. Minimap nodes for filtered-out entities become nearly invisible (opacity 0.07). + +## Minimap + +Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage. + +## New entity focus + +When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation. diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index a3f6ddf..59dc12b 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -678,6 +678,77 @@ color: var(--text-muted); } +/* ── Filter bar ── */ + +.graph-filter { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + display: none; + align-items: center; + gap: 6px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 6px 10px; + z-index: 30; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + min-width: 260px; +} + +.graph-filter.visible { + display: flex; +} + +.graph-filter-icon { + flex-shrink: 0; + stroke: var(--text-muted); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.graph-filter-input { + flex: 1; + border: none; + background: transparent; + color: var(--text-color); + font-size: 0.85rem; + outline: none; + min-width: 0; +} + +.graph-filter-clear { + background: none; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} + +.graph-filter-clear:hover { + color: var(--text-color); +} + +.graph-filter-btn.active { + color: var(--primary-color); +} + +/* ── Filtered-out state ── */ + +.graph-node.graph-filtered-out { + opacity: 0.12; + pointer-events: none; +} + +.graph-edge.graph-filtered-out { + opacity: 0.06; +} + /* ── Loading overlay for relayout ── */ /* ── Add entity menu ── */ diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 9634973..5abcbdb 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -160,7 +160,7 @@ import { // Layer 5.5: graph editor import { loadGraphEditor, openGraphSearch, closeGraphSearch, - toggleGraphLegend, toggleGraphMinimap, + toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphToggleFullscreen, graphAddEntity, } from './features/graph-editor.js'; @@ -469,6 +469,7 @@ Object.assign(window, { closeGraphSearch, toggleGraphLegend, toggleGraphMinimap, + toggleGraphFilter, graphFitAll, graphZoomIn, graphZoomOut, 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 631e7c2..dc932f0 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -30,6 +30,8 @@ let _searchVisible = false; let _searchIndex = -1; let _searchItems = []; let _loading = false; +let _filterVisible = false; +let _filterQuery = ''; // current active filter text // Node drag state let _dragState = null; // { nodeId, el, startClient, startNode, dragging } @@ -53,7 +55,7 @@ let _edgeContextMenu = null; // Selected edge for Delete key detach let _selectedEdge = null; // { from, to, field, targetKind } -// Minimap position/size persisted in localStorage +// Minimap position/size persisted in localStorage (with anchor corner) const _MM_KEY = 'graph_minimap'; function _loadMinimapRect() { try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; } @@ -61,6 +63,46 @@ function _loadMinimapRect() { function _saveMinimapRect(r) { localStorage.setItem(_MM_KEY, JSON.stringify(r)); } +/** + * Anchor-based positioning: detect closest corner, store offset from that corner, + * and reposition from that corner on resize. Works for minimap, toolbar, etc. + */ +function _anchorCorner(el, container) { + const cr = container.getBoundingClientRect(); + const cx = el.offsetLeft + el.offsetWidth / 2; + const cy = el.offsetTop + el.offsetHeight / 2; + return (cy > cr.height / 2 ? 'b' : 't') + (cx > cr.width / 2 ? 'r' : 'l'); +} +function _saveAnchored(el, container, saveFn) { + const cr = container.getBoundingClientRect(); + const anchor = _anchorCorner(el, container); + const data = { + width: el.offsetWidth, + height: el.offsetHeight, + anchor, + offsetX: anchor.includes('r') ? cr.width - el.offsetLeft - el.offsetWidth : el.offsetLeft, + offsetY: anchor.includes('b') ? cr.height - el.offsetTop - el.offsetHeight : el.offsetTop, + }; + saveFn(data); + return data; +} +function _applyAnchor(el, container, saved) { + if (!saved?.anchor) return; + const cr = container.getBoundingClientRect(); + const w = saved.width || el.offsetWidth; + const h = saved.height || el.offsetHeight; + const ox = Math.max(0, saved.offsetX || 0); + const oy = Math.max(0, saved.offsetY || 0); + let l = saved.anchor.includes('r') ? cr.width - w - ox : ox; + let t = saved.anchor.includes('b') ? cr.height - h - oy : oy; + l = Math.max(0, Math.min(cr.width - el.offsetWidth, l)); + t = Math.max(0, Math.min(cr.height - el.offsetHeight, t)); + el.style.left = l + 'px'; + el.style.top = t + 'px'; +} +/** Shorthand wrappers for minimap / toolbar. */ +function _saveMinimapAnchored(el, container) { return _saveAnchored(el, container, _saveMinimapRect); } +function _saveToolbarAnchored(el, container) { return _saveAnchored(el, container, _saveToolbarPos); } // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; @@ -142,6 +184,65 @@ export function toggleGraphMinimap() { if (mm) mm.classList.toggle('visible', _minimapVisible); } +export function toggleGraphFilter() { + _filterVisible = !_filterVisible; + const bar = document.querySelector('.graph-filter'); + if (!bar) return; + bar.classList.toggle('visible', _filterVisible); + if (_filterVisible) { + const input = bar.querySelector('.graph-filter-input'); + if (input) { input.value = _filterQuery; input.focus(); } + } else { + _applyFilter(''); + } +} + +function _applyFilter(query) { + _filterQuery = query; + const q = query.toLowerCase().trim(); + const nodeGroup = document.querySelector('.graph-nodes'); + const edgeGroup = document.querySelector('.graph-edges'); + const mm = document.querySelector('.graph-minimap'); + + if (!_nodeMap) return; + + // Build set of matching node IDs + const matchIds = new Set(); + for (const node of _nodeMap.values()) { + if (!q || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q)) { + matchIds.add(node.id); + } + } + + // Apply filtered-out class to nodes + if (nodeGroup) { + nodeGroup.querySelectorAll('.graph-node').forEach(el => { + el.classList.toggle('graph-filtered-out', !!q && !matchIds.has(el.getAttribute('data-id'))); + }); + } + + // Dim edges where either endpoint is filtered out + if (edgeGroup) { + edgeGroup.querySelectorAll('.graph-edge').forEach(el => { + const from = el.getAttribute('data-from'); + const to = el.getAttribute('data-to'); + el.classList.toggle('graph-filtered-out', !!q && (!matchIds.has(from) || !matchIds.has(to))); + }); + } + + // Dim minimap nodes + if (mm) { + mm.querySelectorAll('.graph-minimap-node').forEach(el => { + const id = el.getAttribute('data-id'); + el.setAttribute('opacity', (!q || matchIds.has(id)) ? '0.7' : '0.07'); + }); + } + + // Update filter button active state + const btn = document.querySelector('.graph-filter-btn'); + if (btn) btn.classList.toggle('active', !!q); +} + export function graphFitAll() { if (_canvas && _bounds) _canvas.fitAll(_bounds); } @@ -400,6 +501,7 @@ function _renderGraph(container) { _renderLegend(container.querySelector('.graph-legend')); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); + _initResizeClamp(container); _initNodeDrag(nodeGroup, edgeGroup); _initPortDrag(svgEl, nodeGroup, edgeGroup); _initRubberBand(svgEl); @@ -427,6 +529,28 @@ function _renderGraph(container) { searchInput.addEventListener('keydown', _onSearchKeydown); } + const filterInput = container.querySelector('.graph-filter-input'); + if (filterInput) { + filterInput.addEventListener('input', (e) => _applyFilter(e.target.value)); + filterInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') toggleGraphFilter(); + }); + } + const filterClear = container.querySelector('.graph-filter-clear'); + if (filterClear) { + filterClear.addEventListener('click', () => { + if (filterInput) filterInput.value = ''; + _applyFilter(''); + }); + } + + // Restore active filter if re-rendering + if (_filterQuery && _filterVisible) { + const bar = container.querySelector('.graph-filter'); + if (bar) bar.classList.add('visible'); + _applyFilter(_filterQuery); + } + // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { _dismissEdgeContextMenu(); @@ -467,10 +591,11 @@ function _deselect(nodeGroup, 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;` : ''; + // 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 ? `left:${tbPos.left}px;top:${tbPos.top}px;` : ''; + const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : ''; return `