Связи (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
parent 9a49cc67f0
commit 557ea96be0
6 changed files with 254 additions and 7 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.148
server.version=1.2.132
client.version=1.2.149
server.version=1.2.133

View File

@ -137,10 +137,18 @@
мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход;
**префетч аватарок** детей при наведении/нырке.
**Фишки (партия 2, лаборатория):**
- **Поиск + телепорт** — строка `.fg-search`; Enter → `graph.findNode(имя)` → камера летит к узлу (dive в
«Вселенной», иначе перецентр).
- **Хлебные крошки**`.fg-breadcrumb` «Иван Нина Ада» (движок шлёт `onDiveChange(path)`,
API `getDivePath()`); клик по корню — полный сброс, по предку — навигация на тот уровень.
- **Бейдж числа связей**`.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`).
- **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`).
**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`,
прогоняет 14 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/выход) через детерминированные
dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры до покоя — не
зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны.
прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через
детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры
до покоя — не зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны.
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.
> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥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/тест-хелперы (для автопроверок; не вызываются в обычной работе) -------------------
// Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера.

View File

@ -189,6 +189,9 @@ export function renderNetworkLab({ navigate }) {
const model = buildLabModel(centerLogin, deepMode);
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
@ -212,6 +215,8 @@ export function renderNetworkLab({ navigate }) {
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
// Изменение пути погружения → перерисовываем хлебные крошки (Иван Нина Ада).
onDiveChange: (path) => renderBreadcrumb(path),
onCenterTap: (node) => {
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
if (deepMode) { graph.collapseAll(); return; }
@ -256,6 +261,59 @@ export function renderNetworkLab({ navigate }) {
filterBar.append(deepChip);
stage.append(filterBar);
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
const searchWrap = document.createElement('div');
searchWrap.className = 'fg-search';
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
const searchIco = document.createElement('span');
searchIco.className = 'fg-search-ico';
searchIco.textContent = '🔍';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Найти человека…';
searchInput.setAttribute('aria-label', 'Поиск по имени');
function doSearch() {
const hit = graph.findNode(searchInput.value);
if (!hit) return;
if (deepMode) {
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
} else {
const from = centerLogin;
centerLogin = hit.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
}
searchInput.blur();
}
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
searchWrap.append(searchIco, searchInput);
stage.append(searchWrap);
// --- Хлебные крошки: стек погружений (Иван Нина Ада); клик по крошке — навигация назад ---
breadcrumbEl = document.createElement('div');
breadcrumbEl.className = 'fg-breadcrumb';
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
stage.append(breadcrumbEl);
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
function renderBreadcrumb(path) {
if (!breadcrumbEl) return;
breadcrumbEl.innerHTML = '';
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
breadcrumbEl.classList.toggle('is-open', open);
if (!open) return;
path.forEach((p, i) => {
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = ''; breadcrumbEl.append(sep); }
const c = document.createElement('button');
c.type = 'button';
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
c.textContent = p.name;
if (i < path.length - 1) {
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
}
breadcrumbEl.append(c);
});
}
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
window.__fg = graph;

View File

@ -81,6 +81,27 @@ export async function runNetworkSelfTest(graph, deepChipEl) {
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
}
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
if (named && typeof graph.findNode === 'function') {
const byId = graph.findNode(named.id);
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
}
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
if (typeof graph.getDivePath === 'function' && t2withKids) {
const path = graph.getDivePath();
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' ')}`);
}
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
if (typeof document !== 'undefined') {
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
}
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
graph.exitDive();
graph.pumpForTest();

View File

@ -533,3 +533,109 @@
.fg-sheet-actions > button {
flex: 1;
}
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
.fg-node-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: rgba(16, 24, 40, 0.92);
border: 1px solid rgba(150, 200, 255, 0.5);
color: #d9ecff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.fg-node.is-focus .fg-node-badge {
background: rgba(61, 196, 223, 0.95);
border-color: rgba(220, 245, 255, 0.8);
color: #06131c;
}
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */
.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); }
.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); }
.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); }
.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); }
/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
/* Строка поиска (оверлей вверху, под панелью фильтров) */
.fg-search {
position: absolute;
top: max(92px, calc(env(safe-area-inset-top) + 88px));
left: 50%;
transform: translateX(-50%);
z-index: 12;
width: min(280px, 70vw);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 0.5px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08);
}
.fg-search input {
flex: 1;
border: 0;
background: transparent;
color: #eaf2ff;
font-size: 13px;
outline: none;
}
.fg-search input::placeholder { color: #7d8aa6; }
.fg-search .fg-search-ico { color: #9fc0ff; font-size: 13px; }
/* Хлебные крошки навигации (стек погружений: Иван Нина Ада) */
.fg-breadcrumb {
position: absolute;
top: max(132px, calc(env(safe-area-inset-top) + 128px));
left: 0;
right: 0;
z-index: 12;
display: none;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px;
pointer-events: none;
}
.fg-breadcrumb.is-open { display: flex; }
.fg-crumb {
pointer-events: auto;
border: 0;
background: rgba(16, 24, 40, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #cfe0ff;
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 5px 10px;
border-radius: 999px;
cursor: pointer;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fg-crumb.is-last {
background: rgba(125, 215, 255, 0.18);
color: #eaf7ff;
cursor: default;
}
.fg-crumb-sep { color: #5f7196; font-size: 11px; align-self: center; pointer-events: none; }