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

@@ -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');