---
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 1326–1348) |

### 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 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` атрибут:

```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 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 сценариев:

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 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.**
