Add graph editor filter, anchor-based positioning, and context docs
- Add name/kind/subtype filter bar with keyboard shortcut (F key) - Filtered-out nodes get dimmed styling, nearly invisible on minimap - Add anchor-based positioning for minimap and toolbar (remembers which corner element is closest to, maintains offset on resize) - Fix minimap not movable after reload (_applyMinimapAnchor undefined) - Fix ResizeObserver to use anchor system for both minimap and toolbar - Add graph-editor.md context file and update frontend.md with graph sync notes - Add filter i18n keys for en/ru/zh locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
93
contexts/graph-editor.md
Normal file
93
contexts/graph-editor.md
Normal file
@@ -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.
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `
|
||||
<div class="graph-container">
|
||||
@@ -490,6 +615,9 @@ function _graphHTML() {
|
||||
<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 graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
|
||||
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></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>
|
||||
@@ -525,6 +653,12 @@ function _graphHTML() {
|
||||
<div class="graph-search-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="graph-filter">
|
||||
<svg class="graph-filter-icon" viewBox="0 0 24 24" width="16" height="16"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
||||
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
||||
</div>
|
||||
|
||||
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
|
||||
@@ -580,13 +714,18 @@ function _initMinimap(mmEl) {
|
||||
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) {
|
||||
// Apply saved anchored position or default to bottom-right
|
||||
const saved = _loadMinimapRect();
|
||||
if (saved?.anchor) {
|
||||
if (saved.width) mmEl.style.width = saved.width + 'px';
|
||||
if (saved.height) mmEl.style.height = saved.height + 'px';
|
||||
_applyAnchor(mmEl, container, saved);
|
||||
} else if (!mmEl.style.left || mmEl.style.left === '0px') {
|
||||
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';
|
||||
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
|
||||
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
|
||||
}
|
||||
|
||||
// Initial viewport update
|
||||
@@ -596,14 +735,7 @@ function _initMinimap(mmEl) {
|
||||
|
||||
// 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';
|
||||
_clampElementInContainer(mmEl, container);
|
||||
}
|
||||
|
||||
// ── Click on minimap SVG → pan main canvas to that point ──
|
||||
@@ -645,7 +777,7 @@ function _initMinimap(mmEl) {
|
||||
mmEl.style.top = t + 'px';
|
||||
});
|
||||
header.addEventListener('pointerup', () => {
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapRect(_mmRect(mmEl)); }
|
||||
if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapAnchored(mmEl, container); }
|
||||
});
|
||||
|
||||
// ── Resize handles ──
|
||||
@@ -683,7 +815,7 @@ function _initMinimap(mmEl) {
|
||||
}
|
||||
_clampMinimap();
|
||||
});
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapRect(_mmRect(mmEl)); } });
|
||||
rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapAnchored(mmEl, container); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,31 +844,65 @@ function _mmRect(mmEl) {
|
||||
return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight };
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
/* ── Shared element clamping ── */
|
||||
|
||||
function _clampToolbar(tbEl) {
|
||||
if (!tbEl) return;
|
||||
const container = tbEl.closest('.graph-container');
|
||||
if (!container) return;
|
||||
/** Clamp an absolutely-positioned element within its container. */
|
||||
function _clampElementInContainer(el, container) {
|
||||
if (!el || !container) return;
|
||||
const cr = container.getBoundingClientRect();
|
||||
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
|
||||
if (!tw || !th) return; // not rendered yet
|
||||
let l = tbEl.offsetLeft, top = tbEl.offsetTop;
|
||||
const clamped = {
|
||||
left: Math.max(0, Math.min(cr.width - tw, l)),
|
||||
top: Math.max(0, Math.min(cr.height - th, top)),
|
||||
};
|
||||
if (clamped.left !== l || clamped.top !== top) {
|
||||
tbEl.style.left = clamped.left + 'px';
|
||||
tbEl.style.top = clamped.top + 'px';
|
||||
_saveToolbarPos(clamped);
|
||||
const ew = el.offsetWidth, eh = el.offsetHeight;
|
||||
if (!ew || !eh) return;
|
||||
let l = el.offsetLeft, t = el.offsetTop;
|
||||
const cl = Math.max(0, Math.min(cr.width - ew, l));
|
||||
const ct = Math.max(0, Math.min(cr.height - eh, t));
|
||||
if (cl !== l || ct !== t) {
|
||||
el.style.left = cl + 'px';
|
||||
el.style.top = ct + 'px';
|
||||
}
|
||||
return { left: cl, top: ct };
|
||||
}
|
||||
|
||||
let _resizeObserver = null;
|
||||
|
||||
function _initResizeClamp(container) {
|
||||
if (_resizeObserver) _resizeObserver.disconnect();
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
const mm = container.querySelector('.graph-minimap');
|
||||
const tb = container.querySelector('.graph-toolbar');
|
||||
if (mm) {
|
||||
const saved = _loadMinimapRect();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(mm, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(mm, container);
|
||||
}
|
||||
}
|
||||
if (tb) {
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tb, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tb, container);
|
||||
}
|
||||
}
|
||||
});
|
||||
_resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
/* ── Toolbar drag ── */
|
||||
|
||||
function _initToolbarDrag(tbEl) {
|
||||
if (!tbEl) return;
|
||||
_clampToolbar(tbEl); // ensure saved position is still valid
|
||||
const container = tbEl.closest('.graph-container');
|
||||
|
||||
// Apply saved anchor position or clamp
|
||||
const saved = _loadToolbarPos();
|
||||
if (saved?.anchor) {
|
||||
_applyAnchor(tbEl, container, saved);
|
||||
} else {
|
||||
_clampElementInContainer(tbEl, container);
|
||||
}
|
||||
|
||||
const handle = tbEl.querySelector('.graph-toolbar-drag');
|
||||
if (!handle) return;
|
||||
|
||||
@@ -762,7 +928,7 @@ function _initToolbarDrag(tbEl) {
|
||||
handle.addEventListener('pointerup', () => {
|
||||
if (dragStart) {
|
||||
dragStart = null;
|
||||
_saveToolbarPos({ left: tbEl.offsetLeft, top: tbEl.offsetTop });
|
||||
_saveToolbarAnchored(tbEl, container);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -972,8 +1138,10 @@ function _onKeydown(e) {
|
||||
const inInput = e.target.matches('input, textarea, select');
|
||||
|
||||
if (e.key === '/' && !_searchVisible && !inInput) { e.preventDefault(); openGraphSearch(); }
|
||||
if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); }
|
||||
if (e.key === 'Escape') {
|
||||
if (_searchVisible) { closeGraphSearch(); }
|
||||
if (_filterVisible) { toggleGraphFilter(); }
|
||||
else if (_searchVisible) { closeGraphSearch(); }
|
||||
else {
|
||||
const ng = document.querySelector('.graph-nodes');
|
||||
const eg = document.querySelector('.graph-edges');
|
||||
|
||||
@@ -1407,5 +1407,8 @@
|
||||
"graph.relayout_confirm": "Reset all manual node positions and re-layout the graph?",
|
||||
"graph.fullscreen": "Toggle fullscreen",
|
||||
"graph.add_entity": "Add entity",
|
||||
"graph.color_picker": "Node color"
|
||||
"graph.color_picker": "Node color",
|
||||
"graph.filter": "Filter nodes",
|
||||
"graph.filter_placeholder": "Filter by name...",
|
||||
"graph.filter_clear": "Clear filter"
|
||||
}
|
||||
|
||||
@@ -1407,5 +1407,8 @@
|
||||
"graph.relayout_confirm": "Сбросить все ручные позиции узлов и перестроить граф?",
|
||||
"graph.fullscreen": "Полноэкранный режим",
|
||||
"graph.add_entity": "Добавить сущность",
|
||||
"graph.color_picker": "Цвет узла"
|
||||
"graph.color_picker": "Цвет узла",
|
||||
"graph.filter": "Фильтр узлов",
|
||||
"graph.filter_placeholder": "Фильтр по имени...",
|
||||
"graph.filter_clear": "Очистить фильтр"
|
||||
}
|
||||
|
||||
@@ -1407,5 +1407,8 @@
|
||||
"graph.relayout_confirm": "重置所有手动节点位置并重新布局图表?",
|
||||
"graph.fullscreen": "切换全屏",
|
||||
"graph.add_entity": "添加实体",
|
||||
"graph.color_picker": "节点颜色"
|
||||
"graph.color_picker": "节点颜色",
|
||||
"graph.filter": "筛选节点",
|
||||
"graph.filter_placeholder": "按名称筛选...",
|
||||
"graph.filter_clear": "清除筛选"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user