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:
2026-03-13 18:39:14 +03:00
parent e163575bac
commit 39981fbc45
8 changed files with 389 additions and 41 deletions

View File

@@ -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
View 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.

View File

@@ -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 ── */

View File

@@ -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,

View File

@@ -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')}">&times;</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');

View File

@@ -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"
}

View File

@@ -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": "Очистить фильтр"
}

View File

@@ -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": "清除筛选"
}