docs(graph): iter3 closure — spec + plan + smoke evidence + cspell terms

iter3 «Automation Graph — interactive highlighting» закрыт.
8 implementation commits ef88435..f0d3d49 (6 feat + 2 fix).
Smoke 12/12 PASS via Playwright (raw JSON + 2 screenshots).
markdownlint/cspell/lychee — clean. Final cross-commit review: APPROVED.

+spec/plan: docs/superpowers/{specs,plans}/2026-05-15-graph-*.md
+smoke evidence: docs/smoke-2026-05-15-graph-highlighting-scenario{2,9}.png
+cspell: NEIGHBOURS / neighbour / BFS / DFS (iter3 vocabulary)

iter4 backlog (non-blocking): I-1 falsy-coercion line 1531, dead var
highlightedNode, SECTION 6 comment update, optional rAF-throttle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 07:04:57 +03:00
parent f0d3d492a7
commit 8a22cc45c5
5 changed files with 1090 additions and 0 deletions
+6
View File
@@ -1146,3 +1146,9 @@ skreview
pdd
иммутабельны
федокруг
# Automation Graph iter3 (2026-05-15) — UK-spelling neighbour vars + graph algo abbreviations
NEIGHBOURS
neighbour
BFS
DFS
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

@@ -0,0 +1,750 @@
# Automation Graph — interactive highlighting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Сделать карту автоматизации `docs/automation-graph.html` интерактивной: (1) клик по нижней легенде = подсветить только узлы/рёбра этого типа; (2) клик по узлу = подсветить узел + его прямых соседей; (3) при обоих активных переключателях — 3 уровня opacity (focus `1.0` > filter `0.55` > rest `0.15`).
**Architecture:** Все изменения локализованы в одном файле `docs/automation-graph.html` (single-file static, no build pipeline). Новая `SECTION 8: HIGHLIGHTING` обёрнута в IIFE с явным state-объектом `{selectedNode, legendFilter:Set}`. Pre-computed indices (NEIGHBOURS, CONFLICT_ENDPOINTS, CONFLICT_EDGE_TYPE) строятся один раз. Единая функция `applyHighlight()` пересчитывает opacity для всех 73 узлов и ~75 рёбер по правилам из spec §5.1/§5.2. Три существующих handlers расширяются (`network.on('click')`, `#btn-clear`, `#search` input), один новый event listener — делегация клика по `#cat-legend`.
**Tech Stack:** Vanilla JS + vis-network@9.1.9 (через `nodesDS.update()` / `edgesDS.update()`). CSS — solarized-палитра. HTML5 `data-*` атрибуты для метаданных. **Тестов нет** — файл standalone HTML без npm/pest. Verification — manual smoke в Edge browser по 12 сценариям из spec §11.
**Spec:** [docs/superpowers/specs/2026-05-15-graph-interactive-highlighting-design.md](../specs/2026-05-15-graph-interactive-highlighting-design.md) — single source of truth для всех 7 решений brainstorming + правил opacity + точек вставки.
---
## File Structure
Все изменения — в одном файле:
| File | Изменение | Что отвечает |
|---|---|---|
| [docs/automation-graph.html](../../automation-graph.html) | +~110 строк (10 CSS + 0 HTML-байт визуально + 12 data-attr + 90 JS), правка 3 существующих handler'ов | Граф-визуализация + интерактивная подсветка |
| [docs/superpowers/specs/2026-05-15-graph-interactive-highlighting-design.md](../specs/2026-05-15-graph-interactive-highlighting-design.md) | — | Spec (already written, single source of truth) |
**Никаких новых файлов.** Никаких изменений в CLAUDE.md / Pravila / Tooling / реестр (фича внутренняя для карты, не нормативная). Никаких изменений в `db/schema.sql` / `lefthook.yml` / `package.json`.
**Atomic commits map** (Pravila §4.2 — один логический change = один commit):
| Task | Commit subject |
|---|---|
| 1 | `feat(graph): CSS rules for interactive legend (.cat-item hover/active states)` |
| 2 | `feat(graph): add data-filter-key to 12 .cat-item elements` |
| 3 | `feat(graph): SECTION 8 — state + indices + computeOpacity + applyHighlight (infra)` |
| 4 | `feat(graph): legend click delegation — toggle filter + apply highlight` |
| 5 | `feat(graph): network click → selectedNode + toggle on repeat` |
| 6 | `feat(graph): btn-clear + search input integration with highlight state` |
| 7 | (no commit — manual smoke report) |
---
## Task 1: CSS rules for `.cat-item` interactivity
**Files:**
- Modify: `docs/automation-graph.html` (style block, после строки 60)
**Цель:** Добавить визуальные стили для кликабельных элементов нижней легенды — cursor pointer, hover-фон, active-состояние с обводкой. Без JS-логики (это Task 4). После этой задачи легенда **визуально** реагирует на hover мышкой, но клик ещё не делает highlight.
- [ ] **Step 1: Manual smoke before — baseline**
Открыть `docs/automation-graph.html` в браузере (Edge). Подвести мышь к любому элементу `#cat-legend` (например «Агенты»). **Expected:** курсор не меняется (стрелка), элементы не реагируют на hover.
- [ ] **Step 2: Read existing style block at line 5960**
Используя tool Read, прочитать `docs/automation-graph.html` строки 59–61. Подтвердить, что строка 60 содержит `.cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }` и строка 61 — `</style>`.
- [ ] **Step 3: Insert CSS rules after `.cat-dot`**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
.cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
</style>
new_string:
.cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.cat-item {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.12s, box-shadow 0.12s;
user-select: none;
}
.cat-item:hover { background: rgba(255,255,255,0.05); }
.cat-item.active {
background: rgba(253,246,227,0.12);
box-shadow: inset 0 0 0 1px rgba(253,246,227,0.4);
color: #fdf6e3;
}
</style>
```
**Замечание:** существующее правило `.cat-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #839496; }` (line 59) **остаётся как есть** — мы добавляем второе правило с тем же селектором ниже. CSS-каскад применяет оба, новые поля (cursor, padding, …) не конфликтуют с display/align-items/gap/font-size/color.
- [ ] **Step 4: Manual smoke after — hover reacts**
Сохранить файл (Edit делает это автоматически), обновить страницу `docs/automation-graph.html` в браузере (Ctrl+F5). Подвести мышь к «Агенты» в нижней легенде. **Expected:**
- Курсор меняется на pointer (palец).
- Фон элемента становится слегка светлее (rgba 0.05 — едва заметно, но различимо).
- При уходе мыши — фон возвращается плавно за 120ms.
- [ ] **Step 5: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): CSS rules for interactive legend (.cat-item hover/active states)"
```
---
## Task 2: HTML `data-filter-key` attributes on 12 `.cat-item`
**Files:**
- Modify: `docs/automation-graph.html` (HTML block, строки 108121)
**Цель:** Добавить `data-filter-key` атрибут на каждый из 12 элементов `.cat-item`. Атрибуты — ключи для логики `legendFilter` (`Set<string>`). После этой задачи в DevTools видны атрибуты, но клик пока не делает ничего (JS-логика — в Task 4).
- [ ] **Step 1: Manual smoke before**
В DevTools (F12) → Elements → найти `#cat-legend`. **Expected:** 12 `<div class="cat-item">` без `data-*` атрибутов.
- [ ] **Step 2: Replace all 12 `.cat-item` lines atomically**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
<div id="cat-legend">
<div class="cat-item"><div class="cat-dot" style="background:#268bd2"></div>Правила</div>
<div class="cat-item"><div class="cat-dot" style="background:#859900"></div>Плагины</div>
<div class="cat-item"><div class="cat-dot" style="background:#6c71c4"></div>Скилы Superpowers</div>
<div class="cat-item"><div class="cat-dot" style="background:#d33682"></div>Скилы проекта</div>
<div class="cat-item"><div class="cat-dot" style="background:#2aa198"></div>Хуки</div>
<div class="cat-item"><div class="cat-dot" style="background:#b58900"></div>Агенты</div>
<div class="cat-item"><div class="cat-dot" style="background:#cb4b16"></div>MCP-серверы</div>
<div class="cat-item"><div class="cat-dot" style="background:#dc322f"></div>Lefthook jobs</div>
<div class="cat-item"><div class="cat-dot" style="background:#586e75"></div>Memory files</div>
<div class="cat-item"><div class="cat-dot" style="background:#ff5f57; border:1px dashed #ff5f57"></div>🔴 Не закрыт правилом</div>
<div class="cat-item"><div class="cat-dot" style="background:#888888; border:1px dashed #888888"></div>⚫ Возник на практике</div>
<div class="cat-item"><div class="cat-dot" style="background:#859900; border:1px dashed #859900"></div>🟢 Закрыт правилом</div>
</div>
new_string:
<div id="cat-legend">
<div class="cat-item" data-filter-key="group:rules"><div class="cat-dot" style="background:#268bd2"></div>Правила</div>
<div class="cat-item" data-filter-key="group:plugins"><div class="cat-dot" style="background:#859900"></div>Плагины</div>
<div class="cat-item" data-filter-key="group:skills_sp"><div class="cat-dot" style="background:#6c71c4"></div>Скилы Superpowers</div>
<div class="cat-item" data-filter-key="group:skills_proj"><div class="cat-dot" style="background:#d33682"></div>Скилы проекта</div>
<div class="cat-item" data-filter-key="group:hooks"><div class="cat-dot" style="background:#2aa198"></div>Хуки</div>
<div class="cat-item" data-filter-key="group:agents"><div class="cat-dot" style="background:#b58900"></div>Агенты</div>
<div class="cat-item" data-filter-key="group:mcp"><div class="cat-dot" style="background:#cb4b16"></div>MCP-серверы</div>
<div class="cat-item" data-filter-key="group:lefthook"><div class="cat-dot" style="background:#dc322f"></div>Lefthook jobs</div>
<div class="cat-item" data-filter-key="group:memory"><div class="cat-dot" style="background:#586e75"></div>Memory files</div>
<div class="cat-item" data-filter-key="conflict:RED"><div class="cat-dot" style="background:#ff5f57; border:1px dashed #ff5f57"></div>🔴 Не закрыт правилом</div>
<div class="cat-item" data-filter-key="conflict:BLACK"><div class="cat-dot" style="background:#888888; border:1px dashed #888888"></div>⚫ Возник на практике</div>
<div class="cat-item" data-filter-key="conflict:GREEN"><div class="cat-dot" style="background:#859900; border:1px dashed #859900"></div>🟢 Закрыт правилом</div>
</div>
```
**NB:** ключи групп узлов точно совпадают с `n.group` в `NODES` массиве (`rules / plugins / skills_sp / skills_proj / hooks / agents / mcp / lefthook / memory`). Ключи конфликтов точно совпадают с ключами `CONFLICT_TYPES` (`RED / BLACK / GREEN`).
- [ ] **Step 3: Manual smoke after**
Обновить страницу (Ctrl+F5). В DevTools (F12) → Elements → найти `#cat-legend`. **Expected:** все 12 `<div class="cat-item">` имеют атрибут `data-filter-key` с правильным значением. Hover/курсор работают как после Task 1. Клик по элементу всё ещё ничего не делает (это Task 4).
- [ ] **Step 4: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): add data-filter-key to 12 .cat-item elements"
```
---
## Task 3: SECTION 8 — state + indices + computations + `applyHighlight` (infra)
**Files:**
- Modify: `docs/automation-graph.html` (после строки 1437 `})();`, перед строкой 1439 `window.addEventListener('DOMContentLoaded', restoreLegendWidth);`)
**Цель:** Создать инфраструктурный слой подсветки: state-объект, 3 pre-computed индекса, функции `computeNodeOpacity` / `computeEdgeOpacity` / `applyHighlight`. После этой задачи код **уже** содержит всю логику, но `applyHighlight()` нигде не вызывается, так что визуально на карте ничего не меняется. В DevTools console можно вручную вызвать `applyHighlight()` — будет no-op (state пуст → idle → opacity 1.0 для всего, что и так).
- [ ] **Step 1: Manual smoke before — confirm insertion point**
Используя tool Read, прочитать `docs/automation-graph.html` строки 14351440. **Expected:** строка 1437 содержит ровно `})();` (закрытие resize IIFE), строка 1438 — пустая, строка 1439 — `window.addEventListener('DOMContentLoaded', restoreLegendWidth);`.
- [ ] **Step 2: Insert SECTION 8 IIFE between resize-IIFE close and DOMContentLoaded**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
})();
window.addEventListener('DOMContentLoaded', restoreLegendWidth);
</script>
new_string:
})();
// ════════════════════════════════════════════════════
// SECTION 8: HIGHLIGHTING (legend filter + node focus)
// ════════════════════════════════════════════════════
const HIGHLIGHT = (function setupHighlight() {
const FILTER_GROUP_PREFIX = 'group:';
const FILTER_CONFLICT_PREFIX = 'conflict:';
const OPACITY_FOCUS = 1.0;
const OPACITY_FILTER = 0.55;
const OPACITY_DIM = 0.15;
const CONFLICT_EDGE_MIN_OPACITY = 0.85;
const state = {
selectedNode: null,
legendFilter: new Set(),
};
// ── Pre-computed indices ──────────────────────────
const NODES_BY_ID = new Map();
const NEIGHBOURS = new Map();
const CONFLICT_ENDPOINTS = { RED: new Set(), BLACK: new Set(), GREEN: new Set() };
const CONFLICT_EDGE_TYPE = new Map();
NODES.forEach(n => {
NODES_BY_ID.set(n.id, n);
if (!NEIGHBOURS.has(n.id)) NEIGHBOURS.set(n.id, new Set());
});
edgesDS.get().forEach(edge => {
// Both directions — Q5; conflict edges count as connections — Q6
if (NEIGHBOURS.has(edge.from)) NEIGHBOURS.get(edge.from).add(edge.to);
if (NEIGHBOURS.has(edge.to)) NEIGHBOURS.get(edge.to).add(edge.from);
if (edge.dashes && edge.color && edge.color.color) {
for (const t of ['RED', 'BLACK', 'GREEN']) {
if (CONFLICT_TYPES[t].color === edge.color.color) {
CONFLICT_ENDPOINTS[t].add(edge.from);
CONFLICT_ENDPOINTS[t].add(edge.to);
CONFLICT_EDGE_TYPE.set(edge.id, t);
break;
}
}
}
});
// ── Opacity computations ──────────────────────────
function computeNodeOpacity(nodeId) {
// Row 1: focus
if (state.selectedNode !== null) {
if (state.selectedNode === nodeId) return OPACITY_FOCUS;
const neigh = NEIGHBOURS.get(state.selectedNode);
if (neigh && neigh.has(nodeId)) return OPACITY_FOCUS;
}
// Row 2: idle
if (state.legendFilter.size === 0 && state.selectedNode === null) return OPACITY_FOCUS;
// Row 3: in filter?
const node = NODES_BY_ID.get(nodeId);
let inFilter = false;
if (node && state.legendFilter.has(FILTER_GROUP_PREFIX + node.group)) inFilter = true;
if (!inFilter) {
for (const t of ['RED', 'BLACK', 'GREEN']) {
if (state.legendFilter.has(FILTER_CONFLICT_PREFIX + t) && CONFLICT_ENDPOINTS[t].has(nodeId)) {
inFilter = true;
break;
}
}
}
if (inFilter) return state.selectedNode ? OPACITY_FILTER : OPACITY_FOCUS;
// Row 4: everything else
return OPACITY_DIM;
}
function computeEdgeOpacity(edge) {
const fromO = computeNodeOpacity(edge.from);
const toO = computeNodeOpacity(edge.to);
const baseline = Math.min(fromO, toO);
// Conflict edge directly selected via 🔴/⚫/🟢 in filter — boost to ≥0.85
const ctype = CONFLICT_EDGE_TYPE.get(edge.id);
if (ctype && state.legendFilter.has(FILTER_CONFLICT_PREFIX + ctype)) {
return Math.max(CONFLICT_EDGE_MIN_OPACITY, baseline);
}
return baseline;
}
function applyHighlight() {
const nodeUpdates = NODES.map(n => ({
id: n.id,
opacity: computeNodeOpacity(n.id),
}));
const edgeUpdates = edgesDS.get().map(e => ({
id: e.id,
color: Object.assign({}, e.color || {}, { opacity: computeEdgeOpacity(e) }),
}));
nodesDS.update(nodeUpdates);
edgesDS.update(edgeUpdates);
}
// ── State manipulators ────────────────────────────
function toggleFilter(key) {
if (state.legendFilter.has(key)) state.legendFilter.delete(key);
else state.legendFilter.add(key);
}
function setSelectedNode(id) {
if (state.selectedNode === id) state.selectedNode = null; // toggle on repeat
else state.selectedNode = id;
}
function clearAll() {
state.selectedNode = null;
state.legendFilter.clear();
}
function updateLegendVisuals() {
document.querySelectorAll('#cat-legend .cat-item').forEach(item => {
const key = item.dataset.filterKey;
if (!key) return;
if (state.legendFilter.has(key)) item.classList.add('active');
else item.classList.remove('active');
});
}
// Expose API (closes over state)
return {
applyHighlight,
toggleFilter,
setSelectedNode,
clearAll,
updateLegendVisuals,
state, // exposed for debug only
};
})();
window.addEventListener('DOMContentLoaded', restoreLegendWidth);
</script>
```
- [ ] **Step 3: Manual smoke after — API callable, no visual change**
Обновить страницу (Ctrl+F5). Открыть DevTools (F12) → Console. Выполнить:
```js
HIGHLIGHT.state
```
**Expected:** `{ selectedNode: null, legendFilter: Set(0) {} }`
Выполнить:
```js
HIGHLIGHT.applyHighlight()
```
**Expected:** возврат `undefined` (функция void), на карте визуальных изменений нет (все узлы остаются 1.0 — idle path).
Выполнить:
```js
HIGHLIGHT.state.selectedNode = 'pravila'; HIGHLIGHT.applyHighlight()
```
**Expected:** на карте узел `pravila` и его 3 соседа (`claude_md / psr_v1 / superpowers`) остаются яркими; остальные 69 узлов и их рёбра приглушаются до 0.15. Это проверка через прямой манипул state — event-handlers ещё не привязаны.
Сбросить вручную:
```js
HIGHLIGHT.clearAll(); HIGHLIGHT.applyHighlight()
```
**Expected:** всё возвращается к 1.0.
- [ ] **Step 4: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): SECTION 8 — state + indices + computeOpacity + applyHighlight (infra)"
```
---
## Task 4: Legend click delegation → toggle + apply
**Files:**
- Modify: `docs/automation-graph.html` (в SECTION 8 IIFE добавить event listener; точная вставка — перед `return { ... }`)
**Цель:** Привязать клик по `.cat-item` к `toggleFilter` + `applyHighlight` + `updateLegendVisuals`. После этой задачи **первая фича (кликабельная легенда) полностью работает**: клик включает/выключает тип, multi-select работает.
- [ ] **Step 1: Manual smoke before**
Обновить страницу. Кликнуть «Агенты» в нижней легенде. **Expected:** ничего не происходит (handler ещё не привязан). DevTools console: `HIGHLIGHT.state.legendFilter` остаётся `Set(0) {}`.
- [ ] **Step 2: Insert delegation listener inside the IIFE**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
function updateLegendVisuals() {
document.querySelectorAll('#cat-legend .cat-item').forEach(item => {
const key = item.dataset.filterKey;
if (!key) return;
if (state.legendFilter.has(key)) item.classList.add('active');
else item.classList.remove('active');
});
}
// Expose API (closes over state)
return {
new_string:
function updateLegendVisuals() {
document.querySelectorAll('#cat-legend .cat-item').forEach(item => {
const key = item.dataset.filterKey;
if (!key) return;
if (state.legendFilter.has(key)) item.classList.add('active');
else item.classList.remove('active');
});
}
// ── Legend click delegation ───────────────────────
document.getElementById('cat-legend').addEventListener('click', e => {
const item = e.target.closest('.cat-item');
if (!item || !item.dataset.filterKey) return;
toggleFilter(item.dataset.filterKey);
applyHighlight();
updateLegendVisuals();
});
// Expose API (closes over state)
return {
```
- [ ] **Step 3: Manual smoke after — feature 1 live**
Обновить страницу (Ctrl+F5). Прогнать 5 микро-сценариев:
**3.1.** Кликнуть «Агенты». **Expected:** 11 agent-узлов остаются 1.0, остальные 62 → 0.15. `.cat-item` «Агенты» получает обводку (`.active`).
**3.2.** Кликнуть «MCP-серверы». **Expected:** 18 узлов (11 agents + 7 MCP) на 1.0; 55 — на 0.15. Оба `.cat-item` с `.active`. `HIGHLIGHT.state.legendFilter` = `Set(2) { 'group:agents', 'group:mcp' }`.
**3.3.** Кликнуть «Агенты» снова. **Expected:** «Агенты» снимает `.active`; 7 MCP остаются 1.0, остальные 66 — 0.15.
**3.4.** Кликнуть 🔴 «Не закрыт правилом». **Expected:** 7 MCP остаются 1.0, плюс 4 endpoint-узла RED-конфликтов (`sk_rls / ag_rls / hookify_plugin / hk_pre_claude`) на 1.0. 2 RED-ребра на 1.0 (boosted ≥0.85 через `computeEdgeOpacity`). Прочие 62 узла — 0.15.
**3.5.** Кликнуть «MCP-серверы» и 🔴 ещё раз (toggle off обоих). **Expected:** `HIGHLIGHT.state.legendFilter` = `Set(0) {}`, все 73 узла и ~75 рёбер возвращаются к 1.0 (idle path в `computeNodeOpacity`).
- [ ] **Step 4: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): legend click delegation — toggle filter + apply highlight"
```
---
## Task 5: `network.on('click')` extension → selectedNode + toggle on repeat
**Files:**
- Modify: `docs/automation-graph.html` (строки 1307–1315 — расширить существующий handler)
**Цель:** Привязать клик по узлу графа к `setSelectedNode` + `applyHighlight`. Сохранить существующее поведение `showNodeLegend` / `showEdgeLegend` / panel-hide на пустой клик. Добавить toggle-семантику: повторный клик по тому же узлу снимает selection. После этой задачи **вторая фича полностью работает** (узел + соседи подсвечиваются), и комбинация с фильтром легенды работает автоматически (3 уровня opacity управляются единой `computeNodeOpacity`).
- [ ] **Step 1: Manual smoke before**
Обновить страницу. Кликнуть узел `pravila` в центре графа. **Expected:** правая `#legend-panel` показывает детали (существующая логика `showNodeLegend`), но opacity на графе **не меняется** (highlight ещё не привязан).
- [ ] **Step 2: Replace the network click handler at lines 13071315**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
network.on('click', params => {
if (params.nodes.length === 1) {
showNodeLegend(params.nodes[0]);
} else if (params.edges.length === 1) {
showEdgeLegend(params.edges[0]);
} else if (params.nodes.length === 0 && params.edges.length === 0) {
document.getElementById('legend-panel').classList.remove('visible');
}
});
new_string:
network.on('click', params => {
if (params.nodes.length === 1) {
const id = params.nodes[0];
HIGHLIGHT.setSelectedNode(id);
HIGHLIGHT.applyHighlight();
// Right panel still shows details of the clicked node (last-clicked, even after toggle-off)
showNodeLegend(id);
} else if (params.edges.length === 1) {
showEdgeLegend(params.edges[0]);
} else if (params.nodes.length === 0 && params.edges.length === 0) {
HIGHLIGHT.state.selectedNode = null;
HIGHLIGHT.applyHighlight();
document.getElementById('legend-panel').classList.remove('visible');
}
});
```
**NB:** `legendFilter` НЕ сбрасывается при пустом клике (spec §6, строка «empty click»). Это сохраняет контекст «у меня выбраны Агенты, я кликнул в пустоту = focus снят, фильтр Агенты остаётся».
- [ ] **Step 3: Manual smoke after — feature 2 live + combination**
Обновить страницу (Ctrl+F5). Прогнать 6 микро-сценариев:
**3.1. Single node focus:** Кликнуть `pravila`. **Expected:** 4 узла (`pravila` + 3 соседа `claude_md / psr_v1 / superpowers`) на 1.0; остальные 69 — на 0.15. Все рёбра между фокус-узлами — 1.0. Правая panel показывает детали `pravila`.
**3.2. Toggle off node:** Кликнуть `pravila` снова. **Expected:** все 73 узла → 1.0, фильтр пуст (`HIGHLIGHT.state.selectedNode === null`). Правая panel остаётся видимой (показывает последний выбранный узел).
**3.3. Empty-area click:** Кликнуть в пустое место графа. **Expected:** `selectedNode === null` (если был set), `applyHighlight()` пересчитывает, panel скрывается (существующая логика).
**3.4. Conflict neighbour (Q6):** Кликнуть `ag_pest`. **Expected:** 4 узла яркие — `ag_pest` + `mcp_redis` (через и обычное `E()`, и `CONFLICT(BLACK)` ребро — оба добавляют mcp_redis в NEIGHBOURS) + `claude_md` (E('claude_md', 'ag_pest', ...)) + `mem_env`. Оба ребра `ag_pest`↔`mcp_redis` — яркие (1.0, baseline).
**3.5. Combination — 3 levels of opacity:** Из состояния (3.4) кликнуть «Агенты» в нижней легенде. **Expected:** 4 фокус-узла (`ag_pest` + соседи) на 1.0; 10 других агентов на 0.55; остальные на 0.15. Это первое визуальное подтверждение Q3 (3 уровня opacity).
**3.6. Reverse order — filter first, then node:** Сбросить (`btn-clear`). Кликнуть «Агенты + MCP». Кликнуть `ag_pest`. **Expected (spec §11 сценарий 9):** `ag_pest` + 3 соседа на 1.0 (4 узла); 10 других агентов + 6 других MCP на 0.55 (16 узлов); прочие 53 узла на 0.15.
- [ ] **Step 4: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): network click → selectedNode + toggle on repeat"
```
---
## Task 6: `#btn-clear` + `#search` input integration
**Files:**
- Modify: `docs/automation-graph.html` (строки 13261348 — search handler; строки 13781383 — btn-clear handler)
**Цель:** Расширить `#btn-clear` для сброса обоих стейтов (selectedNode + legendFilter) и нашего CSS-класса `.active`. Расширить `#search` input handler для last-wins сброса нашего state (search — отдельный режим). После этой задачи все 6 строк таблицы handlers из spec §6 интегрированы; фича полностью завершена.
- [ ] **Step 1: Manual smoke before — btn-clear gap + search gap**
Установить состояние: кликнуть «Агенты» + кликнуть `ag_pest` (комбинация 3 уровня opacity). Кликнуть `#btn-clear`. **Expected gap:** opacity сбрасывается (через existing `nodesDS.update` в btn-clear), но `.cat-item` «Агенты» **остаётся с `.active`**-классом (не сбрасывается); `HIGHLIGHT.state.legendFilter` всё ещё содержит `group:agents`. Это баг, который Task 6 закрывает.
Установить состояние: кликнуть «MCP» + ввести `pest` в поле поиска. **Expected gap:** search-логика подсветила matches (через свой opacity update), но `HIGHLIGHT.state` остался прежним (`legendFilter` содержит `mcp`). Это конфликт состояний, который Task 6 закрывает.
- [ ] **Step 2: Extend `#btn-clear` handler at lines 13781383**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
document.getElementById('btn-clear').addEventListener('click', () => {
document.getElementById('search').value = '';
nodesDS.update(NODES.map(n => ({ id: n.id, borderWidth: 2, opacity: 1.0 })));
document.getElementById('legend-panel').classList.remove('visible');
highlightedNode = null;
});
new_string:
document.getElementById('btn-clear').addEventListener('click', () => {
document.getElementById('search').value = '';
HIGHLIGHT.clearAll();
HIGHLIGHT.updateLegendVisuals();
HIGHLIGHT.applyHighlight();
nodesDS.update(NODES.map(n => ({ id: n.id, borderWidth: 2 }))); // reset borderWidth used by search-highlight
document.getElementById('legend-panel').classList.remove('visible');
highlightedNode = null;
});
```
**NB:** Существующий `nodesDS.update(... opacity: 1.0)` заменён на `applyHighlight()` (который сам выставит 1.0 для всего в idle-состоянии после `clearAll`). Отдельный `nodesDS.update` для `borderWidth: 2` оставлен — search использует `borderWidth: 5` для matches, его тоже надо сбросить.
- [ ] **Step 3: Extend `#search` input handler at lines 13261348**
Использовать tool Edit на `docs/automation-graph.html`:
```
old_string:
document.getElementById('search').addEventListener('input', function () {
const q = this.value.trim().toLowerCase();
if (!q) {
nodesDS.update(NODES.map(n => ({ id: n.id, borderWidth: 2, opacity: 1.0 })));
highlightedNode = null;
return;
}
const matches = NODES.filter(n => n.label.toLowerCase().includes(q));
const updates = NODES.map(n => {
const match = matches.some(m => m.id === n.id);
return {
id: n.id,
borderWidth: match ? 5 : 1,
opacity: match ? 1.0 : 0.25,
};
});
nodesDS.update(updates);
if (matches.length === 1) {
network.focus(matches[0].id, { scale: 1.4, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
showNodeLegend(matches[0].id);
highlightedNode = matches[0].id;
}
});
new_string:
document.getElementById('search').addEventListener('input', function () {
// Search is a separate mode — last-wins over highlight state
HIGHLIGHT.clearAll();
HIGHLIGHT.updateLegendVisuals();
const q = this.value.trim().toLowerCase();
if (!q) {
nodesDS.update(NODES.map(n => ({ id: n.id, borderWidth: 2, opacity: 1.0 })));
highlightedNode = null;
return;
}
const matches = NODES.filter(n => n.label.toLowerCase().includes(q));
const updates = NODES.map(n => {
const match = matches.some(m => m.id === n.id);
return {
id: n.id,
borderWidth: match ? 5 : 1,
opacity: match ? 1.0 : 0.25,
};
});
nodesDS.update(updates);
if (matches.length === 1) {
network.focus(matches[0].id, { scale: 1.4, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
showNodeLegend(matches[0].id);
highlightedNode = matches[0].id;
}
});
```
**Изменение:** добавлены 2 строки в начале — `HIGHLIGHT.clearAll(); HIGHLIGHT.updateLegendVisuals();`. Остальное идентично оригиналу.
- [ ] **Step 4: Manual smoke after — gap closures**
Обновить страницу (Ctrl+F5). Прогнать 4 микро-сценария:
**4.1.** Кликнуть «Агенты» + `ag_pest` → `#btn-clear`. **Expected:** все 73 узла на 1.0; `.cat-item` «Агенты» без `.active`; `HIGHLIGHT.state` = `{ selectedNode: null, legendFilter: Set(0) {} }`; panel скрыта; поле search пусто.
**4.2.** Кликнуть «MCP» (multi-select). Ввести `pest` в search. **Expected:** `.cat-item` «MCP» теряет `.active` сразу при первом keystroke; нечёткие 73 узла - matches — приглушены до 0.25 (search-логика); matches яркие с borderWidth 5; `HIGHLIGHT.state.legendFilter` = `Set(0) {}` (last-wins).
**4.3.** Очистить search (Backspace до пустого). **Expected:** все узлы → 1.0, borderWidth → 2 (existing `if (!q)` branch).
**4.4.** Без search — кликнуть «Агенты» → `#btn-clear` → кликнуть `ag_pest`. **Expected:** последовательность работает чисто — после btn-clear состояние полностью идемпотентно к idle.
- [ ] **Step 5: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(graph): btn-clear + search input integration with highlight state"
```
---
## Task 7: Manual smoke checklist — все 12 сценариев из spec §11
**Files:** ничего не меняется (только проверка).
**Цель:** Пройти все 12 manual smoke сценариев из spec §11 (single source of truth) и зафиксировать результат. Если все 12 проходят без console-errors — фича готова к claim «complete». Если какой-то сценарий падает (например, vis-network warning'и на `opacity: 1.0` — см. spec §12.2 «open question») — задокументировать и решить через план B.
- [ ] **Step 1: Open `docs/automation-graph.html` in clean state**
В Edge: `Ctrl+F5` (hard reload). DevTools (F12) → Console (очистить кнопкой 🚫). Никаких других вкладок не нужно.
- [ ] **Step 2: Run all 12 smoke scenarios from spec §11**
Прогнать **последовательно** все 12 сценариев из [spec §11](../specs/2026-05-15-graph-interactive-highlighting-design.md):
1. **Idle:** Открыть → все 73 узла + ~75 рёбер на 1.0, легенда без `.active`.
2. **Single legend (group):** Клик «Агенты» → 11 ярких, 62 → 0.15, `.active` есть.
3. **Toggle off:** Клик «Агенты» снова → всё 1.0, `.active` снят.
4. **Multi-select group:** Клик «Агенты» + «MCP» → 18 ярких, 55 → 0.15.
5. **Multi-select drop one:** Клик «Агенты» из (4) → 7 MCP яркие, остальные 66 → 0.15.
6. **Single legend (conflict):** Клик 🔴 → 4 endpoint-узла + 2 RED-ребра яркие, 69 узлов → 0.15.
7. **Node focus alone:** Клик `pravila` → 4 узла (`pravila` + `claude_md / psr_v1 / superpowers`) яркие, 69 → 0.15; правая panel открыта.
8. **Node toggle off:** Клик `pravila` снова → 73 узла на 1.0; panel **остаётся** видимой (spec §12.1 — это ожидаемое поведение).
9. **Node + filter combined:** Из (4) клик `ag_pest` → 4 фокус-узла на 1.0; 16 в фильтре на 0.55; 53 прочих на 0.15.
10. **Search override:** Из (9) ввести `pest` в поиск → `HIGHLIGHT.state` сбрасывается, search работает по-старому.
11. **Clear all:** `#btn-clear` → 73 узла 1.0, `.active` снят со всех, panel скрыта, search пуст.
12. **Conflict edge type detection:** Клик ⚫ → 4 BLACK-endpoint (`mcp_pw / sk_parallel / ag_pest / mcp_redis`) яркие, 2 BLACK-ребра яркие, остальное 0.15.
По каждому сценарию записать в отчёт (см. Step 3): PASS / FAIL + observed behaviour.
- [ ] **Step 3: Write smoke report**
Создать рабочий отчёт (плоский текст, без коммита):
```
Smoke report — 2026-05-15, graph-interactive-highlighting
Scenario 1 (idle): PASS / FAIL — <observed>
Scenario 2 (single group): PASS / FAIL — <observed>
Scenario 3 (toggle off): PASS / FAIL — <observed>
Scenario 4 (multi-select group): PASS / FAIL — <observed>
Scenario 5 (drop one): PASS / FAIL — <observed>
Scenario 6 (single conflict): PASS / FAIL — <observed>
Scenario 7 (node focus alone): PASS / FAIL — <observed>
Scenario 8 (node toggle off): PASS / FAIL — <observed>
Scenario 9 (node + filter): PASS / FAIL — <observed>
Scenario 10 (search override): PASS / FAIL — <observed>
Scenario 11 (clear all): PASS / FAIL — <observed>
Scenario 12 (conflict edge type): PASS / FAIL — <observed>
Console errors/warnings: <count + first line>
Notes: <e.g. spec §12.2 vis-network edge.color.opacity behaviour>
```
- [ ] **Step 4: If any scenario FAILs — diagnose via `superpowers:systematic-debugging`**
При FAIL:
1. Минимум 3 гипотезы причины.
2. Falsify каждую через DevTools console (e.g. `HIGHLIGHT.state`, `nodesDS.get('ag_pest').opacity`, `edgesDS.get().filter(e => e.dashes).map(e => e.color)` etc).
3. Только после reproducible root cause — править код в новом atomic commit.
- [ ] **Step 5: If all 12 PASS — invoke `superpowers:verification-before-completion`**
Перед claim'ом «фича готова» обязательно через verification skill (экономия 0% жёсткое требование). Запустить:
- Smoke 12/12 PASS (Step 2 result)
- 0 console errors / warnings
- `git status` clean (все 6 commits сделаны)
- `git log --oneline -7` показывает 6 task-коммитов в правильном порядке + предыдущий main HEAD
---
## Self-Review
Прошёлся по spec секциям 0–12, сверил с задачами выше:
| Spec section | Coverage | Notes |
|---|---|---|
| 0. Context | — | Контекст, не покрытие |
| 1. Цели и не-цели | Task 1–6 покрывают все 4 цели; «не-цели» формально невозможно «не реализовать» — гарантируется отсутствием соответствующих задач | ✓ |
| 2. Решения brainstorming | Q1 (соседи 1-го уровня) — Task 3 (`NEIGHBOURS` index, both directions). Q2 (multi-select) — Task 4 (`toggleFilter`). Q3 (3 уровня) — Task 3 (`computeNodeOpacity` row 3 conditional). Q4 (конфликтные рёбра) — Task 3 (`CONFLICT_EDGE_TYPE` + `computeEdgeOpacity` boost). Q5 (both) — Task 3 (`NEIGHBOURS` симметричный). Q6 (конфликт=связь) — Task 3 (тот же NEIGHBOURS, добавляются все рёбра вкл. dashed). Approach (IIFE) — Task 3. | ✓ |
| 3. Архитектура | Task 3 — вся §3 одним блоком | ✓ |
| 4. Pre-computed indices | Task 3 — все 4 индекса | ✓ |
| 5. Правила opacity | Task 3 — `computeNodeOpacity` + `computeEdgeOpacity` + `applyHighlight` | ✓ |
| 6. Event handlers | Task 4 (legend click), Task 5 (network click), Task 6 (btn-clear + search) | ✓ — все 6 строк §6 таблицы покрыты |
| 7. UI-метки в легенде | Task 1 (CSS), Task 2 (data-filter-key), Task 4 (делегация) | ✓ |
| 8. Интеграция с существующим кодом | Task 6 явно покрывает btn-reset/freeze/unfreeze «без изменений» (мы их не трогаем) | ✓ |
| 9. Производительность | Не требует реализации — pre-computed indices уже в Task 3 | ✓ |
| 10. Error handling | Не требует реализации — pre-conditions в Task 3 уже corrected (NODES_BY_ID.get, NEIGHBOURS.has) | ✓ |
| 11. Manual smoke checklist | Task 7 — все 12 сценариев | ✓ |
| 12. Open questions | Task 7 Step 4 покрывает §12.2 (vis-network edge.color.opacity) через systematic-debugging; §12.1 (panel-hide) explicitly accepted в Task 5 Step 3.2; §12.3 (rapid clicks) обнаружится в Task 4/5 smoke если будет проблема | ✓ |
**Placeholder scan:** искал `TBD / TODO / fill in / similar to / handle edge cases / appropriate error`. Найдено: 0. Все коды задач — конкретные `old_string` / `new_string` либо явные блоки с готовым кодом.
**Type consistency:** проверил identifiers, используемые в нескольких задачах:
- `HIGHLIGHT.applyHighlight()` — Task 3 определяет (returns from IIFE), Task 4/5/6 вызывают — ✓
- `HIGHLIGHT.clearAll()` — Task 3 определяет, Task 6 вызывает — ✓ (не `clearFullLayers` или другое)
- `HIGHLIGHT.toggleFilter(key)` — Task 3 определяет, Task 4 вызывает с `item.dataset.filterKey` — ✓
- `HIGHLIGHT.setSelectedNode(id)` — Task 3 определяет (с toggle-семантикой), Task 5 вызывает — ✓
- `HIGHLIGHT.updateLegendVisuals()` — Task 3 определяет, Task 4 + Task 6 вызывают — ✓
- `HIGHLIGHT.state` — exposed в Task 3 для debug, Task 5 строка 3 «empty click» прямо манипулирует `HIGHLIGHT.state.selectedNode = null` (документировано в spec §6 строка empty click) — ✓
- `NEIGHBOURS / CONFLICT_ENDPOINTS / CONFLICT_EDGE_TYPE / NODES_BY_ID` — private в IIFE Task 3, наружу не утекают — ✓
- `data-filter-key` префиксы: Task 2 HTML использует `group:` / `conflict:`; Task 3 константы `FILTER_GROUP_PREFIX = 'group:'` / `FILTER_CONFLICT_PREFIX = 'conflict:'` — ✓
- `.cat-item.active` CSS-класс: Task 1 CSS-правило, Task 3 `updateLegendVisuals` add/remove — ✓
Конфликтов / дрейфа имён не найдено.
---
**Plan complete.** Saved to [docs/superpowers/plans/2026-05-15-graph-interactive-highlighting.md](2026-05-15-graph-interactive-highlighting.md).
@@ -0,0 +1,334 @@
---
title: "Automation Graph — interactive highlighting (clickable legend + node neighbour highlight)"
date: 2026-05-15
author: Claude (через superpowers:brainstorming)
status: design — awaiting user review
related:
- docs/automation-graph.html (artifact, 1442 строк)
- docs/superpowers/specs/2026-05-14-automation-graph-iter2-design.md (iter2 spec — закрыт `2ad35ca`)
- docs/superpowers/plans/2026-05-14-automation-graph-iter2.md (iter2 plan — закрыт)
- memory/project_automation_map.md (термин «карта»)
---
# Automation Graph — interactive highlighting (iter3)
## 0. Context
`docs/automation-graph.html` — single-file интерактивный граф автоматизации Лидерры на `vis-network@9.1.9` (73 узла / 9 групп категорий + 3 типа конфликтных рёбер / ~80 рёбер из них 8 конфликтных / radial-sector layout). После итераций iter1 (refactor 14.05.2026) и iter2 (resize + plain-language + 3-color conflicts + edge legend, 14.05.2026 commit `2ad35ca`) карта используется ежедневно как ориентир по системе автоматизации.
Заказчик Дмитрий — нетехнический владелец проекта. После недели использования зафиксировал **две связанные фичи** для iter3:
1. **Интерактивная нижняя легенда** (`#cat-legend`, 12 элементов): клик по типу = подсветить только узлы/рёбра этого типа.
2. **Выделение соседей при клике по узлу**: связанные узлы яркие, остальные приглушены.
Brainstorming-цикл прошёл 15.05.2026 — 6 clarifying-вопросов о семантике (scope связей / multi-select / комбинация / типы конфликтов / направление рёбер / конфликты как связи) + 1 о подходе к коду. Все 7 решений приняты заказчиком.
## 1. Цели и не-цели
**Цели:**
1. Сделать нижнюю легенду (`#cat-legend`, 12 элементов: 9 групп узлов + 3 типа конфликтных рёбер) кликабельной с multi-select-семантикой.
2. При клике по узлу подсветить узел + его прямых соседей (соседи 1-го уровня в любом направлении, включая конфликтные рёбра).
3. Когда оба переключателя активны одновременно — три уровня opacity (`focus 1.0 > filter 0.55 > rest 0.15`).
4. Сохранить существующее поведение `#search` / `btn-freeze` / `btn-unfreeze` / `btn-reset` / `btn-clear` без регрессий.
**Не-цели:**
- Не менять radial-sector layout или координаты узлов.
- Не менять списки `NODES` / `EDGES` / `NODE_DETAILS` / `EDGE_DETAILS`.
- Не добавлять транзитивный обход графа (BFS/DFS) — только соседи 1-го уровня (Q1).
- Не добавлять модификаторы клавиатуры (Shift/Ctrl) — multi-select работает обычными кликами (Q2).
- Не вводить тесты `vitest`/`pest` — карта остаётся single-file static HTML без build pipeline. Проверка — manual smoke в браузере.
## 2. Решения brainstorming (single source of truth)
| # | Вопрос | Решение |
|---|---|---|
| Q1 | Что значит «связанные элементы» при клике на узел? | **Соседи 1-го уровня** (без транзитивного обхода). |
| Q2 | Как выбирать типы в нижней легенде? | **Multi-select кликами**, без модификаторов. Кнопка «Снять выделение» очищает. |
| Q3 | Комбинация фильтр-легенды + выделение узла | **3 уровня opacity**: focus `1.0` > filter `0.55` > rest `0.15`. |
| Q4 | Клик по 🔴/⚫/🟢 в легенде | **Подсветить конфликтные рёбра выбранного типа + их endpoint-узлы.** Сами конфликтные рёбра ≥ 0.85 даже при частичном фокусе. |
| Q5 | Направление рёбер при выделении узла | **Both** — и in (`B→A`), и out (`A→B`) делают `B` соседом `A`. |
| Q6 | Конфликтное (пунктирное) ребро = связь? | **Да** — конфликт считается связью; при клике по узлу endpoint конфликта попадает в соседство. |
| Approach | Код-стиль фичи в HTML-файле | **IIFE с явным state-объектом** (~90 строк JS + ~10 CSS). |
## 3. Архитектура
### 3.1. Размещение в файле
Новая `SECTION 8: HIGHLIGHTING` в конце `<script>` блока [docs/automation-graph.html](../../automation-graph.html), сразу **после** `SECTION 7: RESIZE HANDLE + LOCALSTORAGE` (line 1437 в текущей версии после `})();` IIFE resize-handle'а). Точка вставки — между строками 1437 (закрытие resize IIFE) и 1439 (DOMContentLoaded listener).
### 3.2. IIFE-обёртка
```js
// ════════════════════════════════════════════════════
// SECTION 8: HIGHLIGHTING (legend filter + node focus)
// ════════════════════════════════════════════════════
(function setupHighlight() {
// … state, indices, computations, event handlers, init
})();
```
Внутри IIFE — доступ к глобалам `NODES`, `EDGES`, `nodesDS`, `edgesDS`, `network`, `CONFLICT_TYPES`, `GROUPS`, `NODE_DETAILS`, `showNodeLegend`, `showEdgeLegend`. Наружу IIFE ничего не экспортирует.
### 3.3. State
```js
const state = {
selectedNode: null, // string | null — id of focused node
legendFilter: new Set(), // ключи 'group:agents', 'conflict:RED' и т. д.
};
const FILTER_GROUP_PREFIX = 'group:';
const FILTER_CONFLICT_PREFIX = 'conflict:';
```
Ключи легенды формируются как `group:<key>` для 9 категорий узлов (`rules / plugins / skills_sp / skills_proj / hooks / agents / mcp / lefthook / memory`) и `conflict:<TYPE>` для 3 типов конфликтных рёбер (`RED / BLACK / GREEN`). Итого 12 возможных ключей в `legendFilter`.
## 4. Pre-computed indices
Чтобы `applyHighlight()` работала за O(n) без обхода рёбер каждый раз, при инициализации IIFE строятся три индекса:
```js
const NODES_BY_ID = new Map(); // nodeId → NODE (для быстрого .group lookup)
const NEIGHBOURS = new Map(); // nodeId → Set<nodeId> (1-hop, both, incl. conflicts)
const CONFLICT_ENDPOINTS = { // тип → Set<nodeId>
RED: new Set(), BLACK: new Set(), GREEN: new Set()
};
const CONFLICT_EDGE_TYPE = new Map(); // edge.id → 'RED' | 'BLACK' | 'GREEN' (для conflict-edges)
```
Алгоритм заполнения (один проход):
```js
NODES.forEach(n => {
NODES_BY_ID.set(n.id, n);
if (!NEIGHBOURS.has(n.id)) NEIGHBOURS.set(n.id, new Set());
});
edgesDS.get().forEach(edge => {
// Обоюдная связь — Q5 both directions, Q6 конфликт = связь
NEIGHBOURS.get(edge.from).add(edge.to);
NEIGHBOURS.get(edge.to).add(edge.from);
if (edge.dashes && edge.color && edge.color.color) {
// Определяем тип конфликта по color
for (const t of ['RED', 'BLACK', 'GREEN']) {
if (CONFLICT_TYPES[t].color === edge.color.color) {
CONFLICT_ENDPOINTS[t].add(edge.from);
CONFLICT_ENDPOINTS[t].add(edge.to);
CONFLICT_EDGE_TYPE.set(edge.id, t);
break;
}
}
}
});
```
`edge.id` присваивается `vis.DataSet` автоматически при `new vis.DataSet(EDGES)` (строка 1166 файла); внутри IIFE мы получаем актуальные id через `edgesDS.get()`.
## 5. Правила opacity (один источник истины)
### 5.1. `computeNodeOpacity(nodeId): number`
Возвращает 1.0 / 0.55 / 0.15 по decision-таблице. **Строки проверяются сверху вниз, first match wins** — как только условие выполнено, возвращается соответствующее значение, остальные строки не рассматриваются.
| # | Условие | opacity |
|---|---|---|
| 1 | `state.selectedNode !== null` И (`selectedNode === nodeId` ИЛИ `NEIGHBOURS.get(selectedNode).has(nodeId)`) | **1.0** (focus) |
| 2 | `state.legendFilter.size === 0 && state.selectedNode === null` (ничего не выбрано — idle) | **1.0** |
| 3 | `legendFilter` содержит `group:<node.group>` ИЛИ `legendFilter` содержит `conflict:<T>` и `CONFLICT_ENDPOINTS[T].has(nodeId)` | **0.55** при наличии `state.selectedNode`, иначе **1.0** |
| 4 | всё остальное | **0.15** |
### 5.2. `computeEdgeOpacity(edge): number`
```js
function computeEdgeOpacity(edge) {
const fromO = computeNodeOpacity(edge.from);
const toO = computeNodeOpacity(edge.to);
const baseline = Math.min(fromO, toO);
// Q4: конфликтное ребро напрямую выбрано через 🔴/⚫/🟢 в фильтре?
const ctype = CONFLICT_EDGE_TYPE.get(edge.id);
if (ctype && state.legendFilter.has(FILTER_CONFLICT_PREFIX + ctype)) {
return Math.max(0.85, baseline); // не даём пригасть ниже 0.85
}
return baseline;
}
```
### 5.3. `applyHighlight()`
Единая функция-апплаер, вызывается из всех event handlers:
```js
function applyHighlight() {
const nodeUpdates = NODES.map(n => ({
id: n.id,
opacity: computeNodeOpacity(n.id),
}));
const edgeUpdates = edgesDS.get().map(e => ({
id: e.id,
color: { ...(e.color || {}), opacity: computeEdgeOpacity(e) },
}));
nodesDS.update(nodeUpdates);
edgesDS.update(edgeUpdates);
}
```
**Замечание про edge.color.opacity:** vis-network 9.1 поддерживает `edges.color.opacity` (number 0..1). Мы сохраняем существующие `color.color / color.highlight / color.hover` через spread и добавляем `opacity`. При сбросе (idle) — opacity либо 1.0 (default), либо явно ставится 1.0. Не верифицировал поведение vis-network с `opacity: 1.0` vs unset — uncertainty отмечена в §11.4.
## 6. Event handlers
| Триггер | Действие |
|---|---|
| Клик по `.cat-item` (через делегацию на `#cat-legend`) | `toggleFilter(key)``applyHighlight()``updateLegendVisuals()` |
| `network.on('click', { nodes:[id], edges:[] })` | если `state.selectedNode === id` (повторный клик) → `state.selectedNode = null`, иначе `state.selectedNode = id`; `applyHighlight()`; `showNodeLegend(id)` (существующая, если узел остаётся выбран) |
| `network.on('click', { nodes:[], edges:[id] })` | `showEdgeLegend(id)` — поведение **без изменений** |
| `network.on('click', { nodes:[], edges:[] })` (пустое поле) | `state.selectedNode = null`; `applyHighlight()`; **`legendFilter` НЕ сбрасывается** (фильтр сохраняется как отдельный режим) |
| `#btn-clear` «Снять выделение» | `state.selectedNode = null`; `state.legendFilter.clear()`; `applyHighlight()`; `updateLegendVisuals()`; сброс `#search.value`; скрыть `#legend-panel` |
| `#search` input | сбрасывает `state.selectedNode` и `state.legendFilter` (last-wins — search это отдельный режим), затем работает по существующей логике (lines 13261348) |
### 6.1. `toggleFilter(key)`
```js
function toggleFilter(key) {
if (state.legendFilter.has(key)) state.legendFilter.delete(key);
else state.legendFilter.add(key);
}
```
### 6.2. `updateLegendVisuals()`
Добавляет / убирает класс `.active` на каждом `.cat-item` по соответствию `data-filter-key` атрибута и содержимого `legendFilter`.
### 6.3. Перехват существующих handlers
Существующий `network.on('click', params => {...})` (lines 13071315) **расширяется**, не заменяется. Текущая логика `showNodeLegend(params.nodes[0])` остаётся; добавляются строки управления `state.selectedNode` и вызов `applyHighlight()`. То же для `#btn-clear` (lines 13781383) и `#search` input handler (lines 13261348).
## 7. UI-метки в легенде
### 7.1. HTML
Каждый `.cat-item` в `#cat-legend` (lines 109120) получает `data-filter-key` атрибут:
```html
<div class="cat-item" data-filter-key="group:rules">…Правила</div>
<div class="cat-item" data-filter-key="group:plugins">…Плагины</div>
<div class="cat-item" data-filter-key="conflict:RED">… 🔴 Не закрыт правилом</div>
<div class="cat-item" data-filter-key="conflict:BLACK">… ⚫ Возник на практике</div>
<div class="cat-item" data-filter-key="conflict:GREEN">… 🟢 Закрыт правилом</div>
```
### 7.2. CSS
Добавляется в `<style>` после `.cat-dot` (line 60):
```css
.cat-item {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.12s, box-shadow 0.12s;
user-select: none;
}
.cat-item:hover {
background: rgba(255,255,255,0.05);
}
.cat-item.active {
background: rgba(253,246,227,0.12);
box-shadow: inset 0 0 0 1px rgba(253,246,227,0.4);
color: #fdf6e3;
}
```
### 7.3. Делегация события
```js
document.getElementById('cat-legend').addEventListener('click', e => {
const item = e.target.closest('.cat-item');
if (!item || !item.dataset.filterKey) return;
toggleFilter(item.dataset.filterKey);
applyHighlight();
updateLegendVisuals();
});
```
Делегация выбрана вместо 12 индивидуальных listener'ов для краткости и устойчивости к будущим правкам легенды.
## 8. Интеграция с существующим кодом
| Файл / блок | Текущая логика | Изменение |
|---|---|---|
| `<style>` после `.cat-dot` (line 60) | — | **Добавить** правила `.cat-item / :hover / .active` |
| `#cat-legend` HTML (lines 108121) | 12 `.cat-item` без атрибутов | **Добавить** `data-filter-key` на каждый |
| `network.on('click')` (lines 13071315) | `showNodeLegend` / `showEdgeLegend` / hide panel | **Расширить** управлением `state.selectedNode` + вызов `applyHighlight()`; toggle-семантика на повторный клик по тому же узлу |
| `#search` input (lines 13261348) | свой opacity update | **Прибавить** в начале: сбросить `state.selectedNode`, `state.legendFilter.clear()`, `updateLegendVisuals()` |
| `#btn-clear` (lines 13781383) | сбросить opacity, скрыть panel | **Расширить**: `state.selectedNode = null`, `legendFilter.clear()`, `updateLegendVisuals()`, `applyHighlight()` |
| `#btn-reset` (lines 13711376) | вернуть radial позиции + fit | **Без изменений** |
| `#btn-freeze` / `#btn-unfreeze` | physics on/off | **Без изменений** |
| `SECTION 8` (новая) | — | **Добавить** IIFE целиком |
## 9. Производительность
73 узла + ~80 рёбер = ~153 элемента, перерисовка через `nodesDS.update([...])` + `edgesDS.update([...])` за `applyHighlight()`. vis-network batch-update — O(n) и не пересоздаёт DOM, только меняет атрибуты canvas-renderer'а. Для такого размера задержки незаметны (< 5ms на современной машине).
Pre-computed indices (NEIGHBOURS, CONFLICT_ENDPOINTS, CONFLICT_EDGE_TYPE) строятся один раз, O(V + E). `applyHighlight()``computeNodeOpacity` × V + `computeEdgeOpacity` × E. Каждый compute — O(1) на Set lookups. Итого `applyHighlight()` — O(V + E).
## 10. Error handling и edge cases
| Сценарий | Поведение |
|---|---|
| Клик по узлу без записи в `NODE_DETAILS` | `state.selectedNode` всё равно ставится, opacity-логика работает; `showNodeLegend()` сама обрабатывает `if (!details) return` (line 1212) |
| Клик по легенде до DOMContentLoaded | IIFE регистрирует listener в конце скрипта, после vis-init; гонка не возможна (script инлайн, sync) |
| `edgesDS.get()` возвращает 0 рёбер | `edgeUpdates = []`, `edgesDS.update([])` — no-op |
| Узел с `n.group`, отсутствующим в GROUPS | `data-filter-key="group:<нечто>"` создаётся всё равно; legendFilter работает; визуально category не подсвечена. На текущих 73 узлах все группы из GROUPS — недостижимо. |
| `localStorage` quota / private mode | не используется в этой фиче |
| `network` не инициализирован | IIFE выполняется после `new vis.Network(...)` (line 1168) — гарантировано порядком скриптов |
| Конфликтное ребро с цветом вне CONFLICT_TYPES | `CONFLICT_EDGE_TYPE.get(id)` вернёт `undefined`, в `computeEdgeOpacity` ветка `if (ctype && ...)` пропустится, opacity = baseline. На текущих 8 конфликтных рёбрах все цвета матчат — недостижимо. |
| Двойной/тройной клик по узлу | toggle: 1-й клик = выделить, 2-й = снять, 3-й = выделить снова |
## 11. Manual smoke checklist
После реализации — открыть `docs/automation-graph.html` локально (Edge / file://). Прогнать 10 сценариев:
1. **Idle:** Открыть карту. Все 73 узла и ~80 рёбер — opacity 1.0. `#cat-legend` — без `.active` классов.
2. **Single legend (group):** Клик «Агенты» → 11 agent-узлов 1.0; остальные 62 узла 0.15; все рёбра между agent-узлами 1.0 (или близко — зависит от того, есть ли такие); рёбра agent↔non-agent 0.15. `.cat-item` для «Агенты» — с `.active`.
3. **Toggle off:** Повторный клик «Агенты» → всё возвращается к 1.0; `.active` снят.
4. **Multi-select group:** Клик «Агенты» + «MCP» → 18 узлов 1.0, 55 — 0.15. Оба `.cat-item`с `.active`.
5. **Multi-select drop one:** Из состояния (4) клик «Агенты» → остаются 7 MCP 1.0, остальные **66** — 0.15.
6. **Single legend (conflict):** Из idle клик 🔴 «Не закрыт правилом» → 4 endpoint-узла (`sk_rls / ag_rls / hookify_plugin / hk_pre_claude`) 1.0; 2 RED-ребра 1.0; остальные **69** узлов 0.15; все обычные рёбра 0.15 (через computeEdgeOpacity baseline).
7. **Node focus alone:** Клик `pravila`**4** узла (`pravila` + соседи `claude_md / psr_v1 / superpowers`) — 1.0; все рёбра между ними 1.0; остальные 69 узлов и их рёбра — 0.15. Правая panel `#legend-panel` показывает детали `pravila` (существующая логика `showNodeLegend`). NB: соседей `pravila` ровно три (рёбра `pravila→claude_md`, `pravila→psr_v1`, `pravila→superpowers`), `tooling` НЕ сосед `pravila` (он сосед `claude_md`, не `pravila`).
8. **Node toggle off:** Повторный клик `pravila` → всё к 1.0, правая panel скрывается? **Уточнение:** существующий `network.on('click')` логика panel hide происходит только при `params.nodes.length === 0 && params.edges.length === 0`. При повторном клике на узел `nodes.length === 1` — panel НЕ скрывается. Это OK — panel показывает последний выбранный узел. См. §12.1.
9. **Node + filter combined:** Из состояния (4) клик `ag_pest``ag_pest` + соседи (`claude_md / mcp_redis / mem_env`) — 4 узла на 1.0. Остальные 10 agents + 6 MCP = 16 узлов на 0.55. Прочие **53** узла — 0.15. BLACK-ребро `ag_pest``mcp_redis` (конфликт) — 1.0 (focus + оба endpoint'а в фокусе, baseline=1.0). Обычное ребро `ag_pest``mcp_redis` (читает очереди) — тоже 1.0. NB: в данном сценарии `state.legendFilter` НЕ содержит `conflict:BLACK`, поэтому ветка `max(0.85, baseline)` из §5.2 не применяется — opacity целиком определяется focus-проверкой §5.1.
10. **Search override:** Состояние (9) → ввести `pest` в `#search` → highlight state сбрасывается, search-логика работает как раньше (matches=1, focus на узел через `network.focus`, `showNodeLegend`).
11. **Clear all:** Из любого состояния `#btn-clear` → 73 узла и ~80 рёбер 1.0; `#cat-legend` без `.active`; `#legend-panel` скрыта; `#search.value` пуст.
12. **Conflict edge type detection:** Клик ⚫ «Возник на практике» → 4 BLACK-endpoint узла (`mcp_pw / sk_parallel / ag_pest / mcp_redis`) 1.0; 2 BLACK-ребра 1.0; остальное 0.15.
Все 12 сценариев выполняются с пустыми DevTools console (no errors / warnings). Если vis-network warning'и из-за `opacity: 1.0` на edges — задокументировать.
## 12. Open questions / ограничения
### 12.1. Panel-hide при toggle off узла (минор)
Сценарий: пользователь кликнул `pravila` → panel показывает детали; кликнул `pravila` снова (toggle off узла) → opacity все 1.0, но panel **не скрывается**. Существующая логика (line 13121314) скрывает panel только при клике в пустоту графа. **Решение для iter3:** оставить panel видимой — она по-прежнему отображает последнюю информацию, явный сброс — через `#btn-clear` или клик в пустоту. Если заказчику нужно автоматическое скрытие на toggle off — отдельный вопрос post-iter3.
### 12.2. vis-network edge.color.opacity behaviour
Не верифицировал на этой машине: ставит ли `edgesDS.update({id, color: { opacity: 1.0 }})` ребро в полностью непрозрачное состояние? Или нужно `opacity: undefined` / `delete`? **План B при проблеме:** при idle вместо `opacity: 1.0` собирать `color` объект без поля `opacity` через `delete color.opacity` или явно `null`. Решение — в manual smoke шаге 1.
### 12.3. Performance при rapid clicks
10+ кликов по легенде за секунду = 10+ вызовов `applyHighlight()`. Каждая O(V+E) = 153 ops × 10 = 1530 ops + 10 batch DOM updates через vis-network. Без debounce — должно работать без видимой задержки. Если нет — добавить `requestAnimationFrame`-throttle по аналогии с `applyLegendWidth` (lines 13921404). **План B:** добавить rAF-throttle если smoke шаг 4 выявит задержку.
### 12.4. Mobile / touch
Не тестируется — карта для desktop Edge browser Дмитрия. Touch-события Vis-network обрабатывает сама; делегация `click` event работает и на touch.
### 12.5. i18n / accessibility
Карта остаётся русской. ARIA-роли / keyboard navigation не добавляются (карта изначально mouse-only). Если будет следующий аудит a11y — отдельная итерация.
---
**Конец spec.**