47e16a8c22
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>
335 lines
25 KiB
Markdown
335 lines
25 KiB
Markdown
---
|
||
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.**
|