Files
portal/docs/superpowers/specs/2026-05-15-graph-interactive-highlighting-design.md
T
Дмитрий 8a22cc45c5 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>
2026-05-15 07:04:57 +03:00

25 KiB
Raw Blame History

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
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, сразу после 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 13261348)

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

<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 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: Клик pravila4 узла (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_pestag_pest + соседи (claude_md / mcp_redis / mem_env) — 4 узла на 1.0. Остальные 10 agents + 6 MCP = 16 узлов на 0.55. Прочие 53 узла — 0.15. BLACK-ребро ag_pestmcp_redis (конфликт) — 1.0 (focus + оба endpoint'а в фокусе, baseline=1.0). Обычное ребро ag_pestmcp_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.