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>
25 KiB
title, date, author, status, related
| title | date | author | status | related | ||||
|---|---|---|---|---|---|---|---|---|
| Automation Graph — interactive highlighting (clickable legend + node neighbour highlight) | 2026-05-15 | Claude (через superpowers:brainstorming) | design — awaiting user review |
|
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:
- Интерактивная нижняя легенда (
#cat-legend, 12 элементов): клик по типу = подсветить только узлы/рёбра этого типа. - Выделение соседей при клике по узлу: связанные узлы яркие, остальные приглушены.
Brainstorming-цикл прошёл 15.05.2026 — 6 clarifying-вопросов о семантике (scope связей / multi-select / комбинация / типы конфликтов / направление рёбер / конфликты как связи) + 1 о подходе к коду. Все 7 решений приняты заказчиком.
1. Цели и не-цели
Цели:
- Сделать нижнюю легенду (
#cat-legend, 12 элементов: 9 групп узлов + 3 типа конфликтных рёбер) кликабельной с multi-select-семантикой. - При клике по узлу подсветить узел + его прямых соседей (соседи 1-го уровня в любом направлении, включая конфликтные рёбра).
- Когда оба переключателя активны одновременно — три уровня opacity (
focus 1.0 > filter 0.55 > rest 0.15). - Сохранить существующее поведение
#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, сразу после SECTION 7: RESIZE HANDLE + LOCALSTORAGE (line 1437 в текущей версии после })(); IIFE resize-handle'а). Точка вставки — между строками 1437 (закрытие resize IIFE) и 1439 (DOMContentLoaded listener).
3.2. IIFE-обёртка
// ════════════════════════════════════════════════════
// 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
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 строятся три индекса:
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)
Алгоритм заполнения (один проход):
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
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:
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 1326–1348) |
6.1. toggleFilter(key)
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 1307–1315) расширяется, не заменяется. Текущая логика showNodeLegend(params.nodes[0]) остаётся; добавляются строки управления state.selectedNode и вызов applyHighlight(). То же для #btn-clear (lines 1378–1383) и #search input handler (lines 1326–1348).
7. UI-метки в легенде
7.1. HTML
Каждый .cat-item в #cat-legend (lines 109–120) получает data-filter-key атрибут:
<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):
.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. Делегация события
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 108–121) |
12 .cat-item без атрибутов |
Добавить data-filter-key на каждый |
network.on('click') (lines 1307–1315) |
showNodeLegend / showEdgeLegend / hide panel |
Расширить управлением state.selectedNode + вызов applyHighlight(); toggle-семантика на повторный клик по тому же узлу |
#search input (lines 1326–1348) |
свой opacity update | Прибавить в начале: сбросить state.selectedNode, state.legendFilter.clear(), updateLegendVisuals() |
#btn-clear (lines 1378–1383) |
сбросить opacity, скрыть panel | Расширить: state.selectedNode = null, legendFilter.clear(), updateLegendVisuals(), applyHighlight() |
#btn-reset (lines 1371–1376) |
вернуть 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 сценариев:
- Idle: Открыть карту. Все 73 узла и ~80 рёбер — opacity 1.0.
#cat-legend— без.activeклассов. - Single legend (group): Клик «Агенты» → 11 agent-узлов 1.0; остальные 62 узла 0.15; все рёбра между agent-узлами 1.0 (или близко — зависит от того, есть ли такие); рёбра agent↔non-agent 0.15.
.cat-itemдля «Агенты» — с.active. - Toggle off: Повторный клик «Агенты» → всё возвращается к 1.0;
.activeснят. - Multi-select group: Клик «Агенты» + «MCP» → 18 узлов 1.0, 55 — 0.15. Оба
.cat-item— с.active. - Multi-select drop one: Из состояния (4) клик «Агенты» → остаются 7 MCP 1.0, остальные 66 — 0.15.
- 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). - 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). - 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. - 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. - Search override: Состояние (9) → ввести
pestв#search→ highlight state сбрасывается, search-логика работает как раньше (matches=1, focus на узел черезnetwork.focus,showNodeLegend). - Clear all: Из любого состояния
#btn-clear→ 73 узла и ~80 рёбер 1.0;#cat-legendбез.active;#legend-panelскрыта;#search.valueпуст. - 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 1312–1314) скрывает 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 1392–1404). План 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.