Files
portal/docs/superpowers/specs/2026-05-15-graph-interactive-highlighting-design.md
T
Дмитрий 47e16a8c22 docs(graph): iter3 closure — spec + plan + smoke evidence + cspell terms
iter3 «Automation Graph — interactive highlighting» закрыт.
8 implementation commits 1d56155..1fdc130 (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>
2026-05-15 07:04:57 +03:00

335 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.**