Исправлены критические баги «аквариума» по разбору (видео): 1. Слипание узлов → адаптивный радиус орбиты (фикс collision). Дети раскладываются на кольце ringR = max(baseR + радиус_родителя, число_детей×13): не лезут на (увеличенный зумом) родитель и друг на друга. Проверено: мин. дистанция 125px, 0 наложений (было — все в одной точке). 2. Умный наезд камеры на КЛИК по любому узлу (раньше 1-й уровень раскрывался на месте). diveTo центрирует узел (offset ~0), zoom 1.7; узел и дети растут до единого видимого размера (HERO_VISUAL/baseScaleOf, DIVE_CHILD_VISUAL) — крупно и читаемо. Наведение остаётся лёгким превью. 3. Железный Spotlight (единый активный путь): diveTo гасит ВСЕ прежние pin/hover, затем раскрывает только путь к цели. Открыто → путь=1.0, остальное=0.25; переключение веток сбрасывает прежнюю; exitDive/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает. (Проверено программно.) Реальный путь /network-view не затронут (вся глубина под tier≥2/hasDeep). Бамп client.version → 1.2.147. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1546 lines
93 KiB
JavaScript
1546 lines
93 KiB
JavaScript
// Движок интерактивной карты связей (force-directed graph).
|
||
//
|
||
// Что делает:
|
||
// - держит «мир» (бесконечный холст), по которому можно панорамировать свайпом;
|
||
// - раскладывает фокусный узел в центре, остальные — по орбите (радиус зависит от силы связи);
|
||
// - по тапу на периферийный узел плавно стягивает его в центр (новый фокус), старый уходит на орбиту;
|
||
// - рисует рёбра аналитически (без чтения DOM в цикле) — дёшево для 60 FPS.
|
||
//
|
||
// Критичные требования (см. план):
|
||
// 1) Kill-switch физики: цикл rAF останавливается, когда кинетическая энергия падает ниже порога;
|
||
// просыпается только при взаимодействии (pan / tap / recenter). Батарея не «выжирается».
|
||
// 2) Конфликт жестов: любой pan мгновенно прерывает твин центрирования и отдаёт управление пальцу.
|
||
//
|
||
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё):
|
||
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
||
|
||
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
|
||
|
||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||
|
||
// --- Параметры физики и анимации ---------------------------------------------
|
||
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
||
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
|
||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||
const FRICTION = 0.80; // базовое затухание (после транзита — лёгкое упругое покачивание)
|
||
const FRICTION_BOOST = 0.94; // «гелевая» вязкость в первые ~700мс после перестроения (гасит «взрыв»)
|
||
const BOOST_FRAMES = 42; // длительность затухающего boost'а вязкости (~700мс @60fps)
|
||
const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| для жёсткой заморозки графа
|
||
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
||
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
||
const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк)
|
||
const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
|
||
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
|
||
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
|
||
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
||
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
||
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
|
||
const LONGPRESS_MS = 480; // порог долгого нажатия
|
||
const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки
|
||
|
||
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
|
||
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
|
||
const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем
|
||
const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка»
|
||
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
|
||
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
|
||
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
|
||
|
||
// Зум камеры (свободное масштабирование колесом мыши / щипком двумя пальцами): мир целиком
|
||
// масштабируется CSS-scale (GPU), линии (отдельный SVG-слой) пересчитываются в экранных координатах.
|
||
const ZOOM_MIN = 0.55; // максимальное отдаление
|
||
const ZOOM_MAX = 2.6; // максимальное приближение
|
||
const ZOOM_WHEEL = 0.0016; // чувствительность колеса мыши
|
||
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
|
||
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
|
||
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
|
||
const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом
|
||
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
|
||
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
|
||
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
|
||
|
||
// Умный фокус (Smart Zoom / «аквариум»): клик по узлу 2-го уровня — камера летит и зумит к нему,
|
||
// он вырастает в центр; Иван и боковые ветки уменьшаются + расфокус (blur) на задний план;
|
||
// нить-крошка обратно к Ивану остаётся яркой. При сильном зуме точки 3-го уровня → аватарки (LOD).
|
||
const DIVE_ZOOM = 1.7; // зум камеры при погружении (наезд ~600мс)
|
||
const DIVE_FLY_K = 0.13; // скорость «полёта» камеры/зума к узлу (lerp за кадр) ≈ 600мс до цели
|
||
const HERO_VISUAL = 1.4; // желаемый ВИДИМЫЙ масштаб нырнутого узла — одинаков для tier-1/2/3 (читаемо)
|
||
const DIVE_CHILD_VISUAL = 0.95; // желаемый видимый масштаб его прямых детей (крупно/читаемо)
|
||
const DIVE_PATH_MUL = 0.72; // предки на пути назад — чуть мельче (видимая «цепочка крошек»)
|
||
const DIVE_ROOT_MUL = 0.55; // корень (Иван) уходит вглубь сильнее всех
|
||
const DIVE_OFFPATH_MUL = 0.55; // боковые ветки (вне пути) — уменьшаются на задний план
|
||
const DIVE_BLUR = 3; // размытие фоновых (вне пути) узлов — эффект расфокуса/глубины, px
|
||
const LOD_ZOOM = 1.55; // порог зума, на котором точки 3-го уровня превращаются в аватарки
|
||
|
||
const RELATION_COLORS = {
|
||
family: 'rgba(255, 159, 94, 0.92)',
|
||
friend: 'rgba(120, 179, 255, 0.9)',
|
||
business: 'rgba(190, 150, 255, 0.9)',
|
||
contact: 'rgba(170, 190, 220, 0.7)',
|
||
};
|
||
|
||
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
|
||
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
|
||
|
||
|
||
function easeOutCubic(t) {
|
||
const x = 1 - t;
|
||
return 1 - x * x * x;
|
||
}
|
||
|
||
function relationColor(relationType) {
|
||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||
}
|
||
|
||
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
|
||
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
|
||
function ensureShineFilter() {
|
||
if (typeof document === 'undefined' || document.getElementById('fg-shine-glow')) return;
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('width', '0');
|
||
svg.setAttribute('height', '0');
|
||
svg.style.position = 'absolute';
|
||
svg.innerHTML = '<defs><filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" '
|
||
+ 'color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></filter></defs>';
|
||
document.body.appendChild(svg);
|
||
}
|
||
|
||
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
||
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
||
function spreadAngle(index, total) {
|
||
if (total <= 0) return 0;
|
||
return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
|
||
}
|
||
|
||
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
|
||
// а не идеальным кругом. Без Math.random: одинаковый узел всегда смещён одинаково.
|
||
function hash01(str) {
|
||
let h = 2166136261;
|
||
const s = String(str || '');
|
||
for (let i = 0; i < s.length; i += 1) {
|
||
h ^= s.charCodeAt(i);
|
||
h = Math.imul(h, 16777619);
|
||
}
|
||
return ((h >>> 0) % 1000) / 1000;
|
||
}
|
||
|
||
/**
|
||
* Создаёт движок графа внутри переданного контейнера-сцены.
|
||
* @param {Object} opts
|
||
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
|
||
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
|
||
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
|
||
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
|
||
* @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 } = {}) {
|
||
// Слои DOM
|
||
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||
edgesSvg.setAttribute('class', 'fg-edges');
|
||
const world = document.createElement('div');
|
||
world.className = 'fg-world';
|
||
stage.append(edgesSvg, world);
|
||
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
|
||
|
||
// Состояние камеры (панорамирование + зум)
|
||
let camX = 0;
|
||
let camY = 0;
|
||
let zoom = 1; // масштаб камеры (1 = базовый); меняется колесом мыши / щипком
|
||
let camTargetX = null; // цель дотяжки камеры-доводчика (null = доводчик выключен)
|
||
let camTargetY = null;
|
||
let viewW = stage.clientWidth || window.innerWidth;
|
||
let viewH = stage.clientHeight || window.innerHeight;
|
||
let centerX = viewW / 2;
|
||
let centerY = viewH / 2;
|
||
|
||
// Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius }
|
||
let nodes = [];
|
||
let focusId = '';
|
||
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
|
||
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
|
||
let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют)
|
||
let diveTargetId = null; // id «нырнутого» узла (Smart Zoom); null — мы на верхнем уровне (Иван)
|
||
let diveZoom = 1; // целевой зум активного погружения
|
||
let surfacing = false; // идёт «всплытие» назад (камера/зум возвращаются к корню)
|
||
let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания)
|
||
const rebuildIndex = () => {
|
||
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
||
hasDeep = nodes.some((n) => n.tier >= 2);
|
||
childCountByParent = new Map();
|
||
for (const n of nodes) { if (n.tier >= 2 && n.parentId) childCountByParent.set(n.parentId, (childCountByParent.get(n.parentId) || 0) + 1); }
|
||
};
|
||
|
||
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
|
||
// взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка
|
||
// 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности.
|
||
function rootTier1(n) {
|
||
let r = n; let guard = 0;
|
||
while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null;
|
||
return r;
|
||
}
|
||
function spotTargetOf(n) {
|
||
if (!spotActive || n.isFocus) return 1;
|
||
const r = rootTier1(n);
|
||
if (!r) return SPOTLIGHT_DIM;
|
||
return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM;
|
||
}
|
||
|
||
// Путь-«крошки» от корня (Иван) до нырнутого узла включительно — для подсветки нити назад и глубины.
|
||
function divePathSet() {
|
||
const set = new Set();
|
||
if (!diveTargetId) return set;
|
||
let cur = nodeById.get(diveTargetId); let guard = 0;
|
||
while (cur && guard++ < 16) { set.add(String(cur.id)); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
|
||
set.add(String(focusId));
|
||
return set;
|
||
}
|
||
let _pathSet = new Set();
|
||
let _pathSetKey = ' |