Связи (pixel-aquarium, 10.06): партия 3 (лаборатория) — общие связи + доступность

Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем).

- Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок
  + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана.
- Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня)
  для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label).

Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS.
Бамп client.version → 1.2.150.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-10 00:53:49 +03:00
co-authored by Claude Opus 4.8
parent 557ea96be0
commit 519bce6b78
6 changed files with 85 additions and 4 deletions
+20 -2
View File
@@ -149,7 +149,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.setAttribute('class', 'fg-edges');
const world = document.createElement('div');
world.className = 'fg-world';
stage.append(edgesSvg, world);
// Доступность: визуально скрытый текстовый список графа для скринридеров (граф сам по себе им не читается).
const a11y = document.createElement('div');
a11y.className = 'fg-a11y';
a11y.setAttribute('role', 'region');
a11y.setAttribute('aria-label', 'Карта связей — список');
stage.append(edgesSvg, world, a11y);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование + зум)
@@ -433,6 +438,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
`is-${src.relationType || 'contact'}`,
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
tier >= 2 ? 'is-secondary' : '',
src.common ? 'is-common' : '', // «общая связь» — этот человек и твой друг тоже (золотой ободок ★)
].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id);
@@ -472,6 +478,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
}
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
function updateA11y() {
const focus = nodes.find((n) => n.isFocus);
const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' };
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель.
function updateNodeRole(node, spec) {
const src = spec.src;
@@ -502,7 +518,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
node.el.className = spec.dotOnly
? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : ''].filter(Boolean).join(' ');
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
}
// --- Рендер ----------------------------------------------------------------
@@ -1540,6 +1556,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
updateBadges(); // бейджи-счётчики связей под новый набор
updateA11y(); // текстовый список графа для скринридеров
layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей
renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает)
emitDiveChange(); // сбрасываем хлебные крошки (новый граф = верхний уровень)
@@ -1622,6 +1639,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (ro) ro.disconnect();
edgesSvg.remove();
world.remove();
a11y.remove();
},
};
}