Связи (pixel-aquarium, 10.06): партия 2 (UI-фишки) — поиск, хлебные крошки, бейджи, цветовые кластеры

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

- Поиск + телепорт: строка .fg-search; Enter → graph.findNode(имя) → камера летит к узлу
  (dive в «Вселенной», иначе перецентр).
- Хлебные крошки: .fg-breadcrumb «Иван › Нина › Ада» (движок шлёт onDiveChange(path), API getDivePath);
  клик по корню — полный сброс, по предку — навигация на его уровень.
- Бейдж числа связей: .fg-node-badge (degreeById → updateBadges; у центра — число связей 1-го уровня).
- Цветовые кластеры: мягкая аура узла по типу связи (CSS is-family/friend/business/contact).

Автопроверки расширены до 17 ассертов (добавлены поиск/крошки/бейдж) — прогон 17/17 PASS.
Фикс: TDZ breadcrumbEl (объявлен до createForceGraph, т.к. onDiveChange вызывается при монтировании).
Бамп client.version → 1.2.149.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-10 00:43:02 +03:00
co-authored by Claude Opus 4.8
parent 9a49cc67f0
commit 557ea96be0
6 changed files with 254 additions and 7 deletions
+56 -2
View File
@@ -143,7 +143,7 @@ function hash01(str) {
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover } = {}) {
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
@@ -173,18 +173,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let diveZoom = 1; // целевой зум активного погружения
let surfacing = false; // идёт «всплытие» назад (камера/зум возвращаются к корню)
let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания)
let degreeById = new Map(); // id → число связей узла (для бейджа-счётчика на аватарке)
const rebuildIndex = () => {
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
hasDeep = nodes.some((n) => n.tier >= 2);
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
childCountByParent = new Map();
degreeById = new Map();
let tier1count = 0;
for (const n of nodes) {
if (n.tier >= 2 && n.parentId) {
const i = childCountByParent.get(n.parentId) || 0;
n.sibIndex = i;
childCountByParent.set(n.parentId, i + 1);
degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь
} else if (n.tier === 1 && String(n.id) !== focusId) {
tier1count += 1;
}
}
degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня
};
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
@@ -212,12 +219,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return set;
}
let _pathSet = new Set();
let _pathSetKey = '';
let _pathSetKey = '';
function ensurePathSet() {
const k = diveTargetId || '';
if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); }
return _pathSet;
}
// Хлебные крошки: упорядоченный путь focus → … → нырнутый узел (для UI-навигации). Пусто = верхний уровень.
function divePathNodes() {
const out = [];
if (!diveTargetId) return out;
let cur = nodeById.get(diveTargetId); let guard = 0;
while (cur && guard++ < 16) { out.push(cur); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
const f = nodeById.get(focusId);
if (f && out[out.length - 1] !== f) out.push(f);
return out.reverse(); // от корня (Иван) к цели
}
function emitDiveChange() {
if (typeof onDiveChange !== 'function') return;
onDiveChange(divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })));
}
// Поиск узла по имени/логину (строка поиска): точное совпадение приоритетнее подстроки.
function findNode(query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return null;
let hit = nodes.find((n) => String(n.name || '').toLowerCase() === q || String(n.login || '').toLowerCase() === q);
if (!hit) hit = nodes.find((n) => String(n.name || '').toLowerCase().includes(q) || String(n.login || '').toLowerCase().includes(q));
return hit ? { id: String(hit.id), name: hit.name || hit.login || String(hit.id), tier: hit.tier } : null;
}
// Базовый масштаб узла по его роли/уровню (как в makeNodeState) — чтобы привести героя и его детей
// к ОДИНАКОВОМУ видимому размеру независимо от tier (depthScale = желаемый_видимый / базовый).
function baseScaleOf(n) {
@@ -419,6 +448,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
});
el.append(avatar);
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
const badge = document.createElement('span');
badge.className = 'fg-node-badge';
badge.hidden = true;
el.append(badge);
const label = document.createElement('span');
label.className = 'fg-node-label';
label.textContent = src.name || src.login || '';
@@ -426,6 +461,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return el;
}
// Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex.
function updateBadges() {
for (const n of nodes) {
const badge = n.el.querySelector('.fg-node-badge');
if (!badge) continue; // у точек (dotOnly) бейджа нет
const deg = degreeById.get(String(n.id)) || 0;
if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; }
else { badge.hidden = true; }
}
}
// Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель.
function updateNodeRole(node, spec) {
const src = spec.src;
@@ -611,6 +657,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
n.lod = full ? 'full' : 'dot';
n.dotOnly = !full;
n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей
if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } }
}
function renderEdges() {
@@ -1158,6 +1205,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } }
if (diveTargetId) { diveTargetId = null; surfacing = true; any = true; } // всплыть наверх
if (any) haptic(14);
emitDiveChange(); // крошки → верхний уровень
wake();
}
@@ -1178,6 +1226,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
camTargetX = null; camTargetY = null; // dive-камера центрирует сама
prefetchChildren(n); // подгружаем лица детей заранее
haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс
emitDiveChange(); // обновляем хлебные крошки (Иван › … › цель)
wake();
}
// Всплытие/закрытие ветки: ПОЛНЫЙ сброс — снимаем все фиксации/ховеры (дети втягиваются),
@@ -1187,6 +1236,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
diveTargetId = null;
surfacing = true;
haptic(10);
emitDiveChange(); // крошки → верхний уровень
wake();
}
@@ -1489,8 +1539,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
pendingFocusOrigin = null;
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
updateBadges(); // бейджи-счётчики связей под новый набор
layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей
renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает)
emitDiveChange(); // сбрасываем хлебные крошки (новый граф = верхний уровень)
camX = 0; camY = 0; applyWorldTransform();
@@ -1538,6 +1590,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
diveTo, // Smart Zoom: погрузиться в узел 2-го+ уровня (наезд камеры, «аквариум»)
exitDive, // Smart Zoom: всплыть из погружения на уровень назад
collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню)
findNode, // поиск узла по имени/логину → { id, name, tier } | null (для строки поиска)
getDivePath: () => divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })), // хлебные крошки
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
// --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) -------------------
// Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера.