// Движок интерактивной карты связей (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-подобные точки 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 = '' + '' + '' + '' + ''; 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 } = {}) { // Слои DOM const edgesSvg = document.createElementNS(SVGNS, 'svg'); edgesSvg.setAttribute('class', 'fg-edges'); const world = document.createElement('div'); world.className = 'fg-world'; // «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется, // когда под него попадает узел (визуальная зона фокуса при свободном панорамировании). const reticle = document.createElement('div'); reticle.className = 'fg-reticle'; stage.append(edgesSvg, world, reticle); ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) // Состояние камеры (панорамирование) let camX = 0; let camY = 0; 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 = ''; // Управление циклом rAF let rafId = 0; let dragging = false; // Твин центрирования let tween = null; // { startTs, from: Map(id->{x,y,scale}), to: Map(id->{x,y,scale}), camFrom, camTo } // Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла) let pendingFocusOrigin = null; // Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра. let boost = 0; let frictionNow = FRICTION; let chargeNow = CHARGE; // Режим CSS-bloom: узлы разлетаются нативными CSS-переходами (компоновщик, без JS-физики), // цикл только перерисовывает лучи вслед за узлами. Завершается по таймеру. let cssBloom = false; let cssBloomTimer = 0; let cssBloomKind = 'bloom'; // 'bloom' (каскадный разлёт) | 'filter' (фиксация на равномерных углах) // Инерция панорамирования (kinematic panning) let panVelX = 0; let panVelY = 0; // --- Построение модели ----------------------------------------------------- // Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга: // фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки. function computeSpecs(srcModel) { const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : []; const fId = String(srcModel?.focusId || (list[0] && list[0].id) || ''); const peers = list .filter((n) => String(n.id) !== fId) .sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0)); const dotCount = Math.max(0, peers.length - MAX_FULL_NODES); if (dotCount > 0) { console.info(`[force-graph] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`); } const specs = []; const focusSrc = list.find((n) => String(n.id) === fId) || list[0]; if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false }); peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES })); return { focusId: fId, specs }; } function buildNodes(srcModel) { const { focusId: fId, specs } = computeSpecs(srcModel); focusId = fId; return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly)); } function makeNodeState(src, isFocus, index, total, dotOnly = false) { const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5)); const tier = Number(src.tier) || 1; // органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад) const jr = (hash01(src.id) - 0.5) * 18; const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4; const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr; const angle = isFocus ? 0 : spreadAngle(index, total) + ja; const scale = isFocus ? FOCUS_SCALE : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE); // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. const tx = isFocus ? 0 : Math.cos(angle) * targetR; const ty = isFocus ? 0 : Math.sin(angle) * targetR; const el = buildNodeElement(src, isFocus, tier, dotOnly); world.append(el); return { ...src, isFocus, tier, dotOnly, strength, targetR, angle, tx, ty, x: tx * INTRO_FACTOR, y: ty * INTRO_FACTOR, vx: 0, vy: 0, scale, targetScale: scale, hidden: false, opacity: 1, targetOpacity: 1, bloom: false, edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) el, dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)), }; } // Аватар из прямого URL-фото (тестовые данные лаборатории). Структура — как у renderUserAvatar // (переиспользуем CSS .avatar/.node-dot), фолбэк на инициалы при ошибке загрузки (офлайн). function buildPhotoAvatar(src) { const wrap = document.createElement('div'); wrap.className = 'avatar avatar-image node-dot'; const fb = document.createElement('span'); fb.className = 'avatar-fallback'; fb.textContent = buildAvatarInitials({ login: src.login || String(src.id), firstName: src.name || '' }); wrap.append(fb); const img = document.createElement('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.onload = () => wrap.classList.add('has-image'); img.onerror = () => { img.remove(); }; // нет сети — остаются инициалы img.src = src.photo; wrap.append(img); return wrap; } function buildNodeElement(src, isFocus, tier, dotOnly = false) { const el = document.createElement('button'); el.type = 'button'; // лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность) if (dotOnly) { el.className = [ 'fg-node', 'fg-dot', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); el.title = src.name || src.login || ''; return el; } el.className = [ 'fg-node', isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier >= 2 ? 'is-secondary' : '', ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); // тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы) const avatar = src.photo ? buildPhotoAvatar(src) : renderUserAvatar({ login: src.login || src.name || String(src.id), firstName: src.name || '', avatar: src.avatar || null, size: 'node', title: src.name || src.login || '', }); el.append(avatar); const label = document.createElement('span'); label.className = 'fg-node-label'; label.textContent = src.name || src.login || ''; el.append(label); return el; } // Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель. function updateNodeRole(node, spec) { const src = spec.src; const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5)); const tier = Number(src.tier) || 1; node.isFocus = spec.isFocus; node.tier = tier; node.dotOnly = spec.dotOnly; node.strength = strength; node.relationType = src.relationType; node.shining = Boolean(src.shining); const jr = (hash01(src.id) - 0.5) * 18; const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4; node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr; node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja; node.tx = Math.cos(node.angle) * node.targetR; node.ty = Math.sin(node.angle) * node.targetR; node.targetScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); node.targetOpacity = 1; node.hidden = false; node.bloom = false; node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26)); // обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM node.el.className = spec.dotOnly ? ['fg-node', 'fg-dot', 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-secondary' : ''].filter(Boolean).join(' '); } // --- Рендер ---------------------------------------------------------------- function applyWorldTransform() { world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`; } function renderNodes() { for (const n of nodes) { n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`; n.el.style.opacity = String(n.opacity); n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : ''; } } function renderEdges() { const focus = nodes.find((n) => n.id === focusId); if (!focus) { edgesSvg.innerHTML = ''; return; } // концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость const tx = (n) => centerX + camX + n.x; const ty = (n) => centerY + camY + n.y; const fx = tx(focus); const fy = ty(focus); const fr = focus.dotRadius * focus.scale + 4; const focusLogin = String(focus.login || '').toLowerCase(); const parts = []; const defs = []; let gi = 0; for (const n of nodes) { if (n === focus) continue; // скрытый фильтром узел: рисуем луч пока он гаснет (живая прозрачность > 0), затем пропускаем const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1; if (n.hidden && nodeOpacity <= 0.02) continue; if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue; const nx = tx(n); const ny = ty(n); if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue; // Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке // и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/ // общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash). const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1; const ex = growing ? (centerX + camX + n.bfx) : nx; const ey = growing ? (centerY + camY + n.bfy) : ny; const dx = ex - fx; const dy = ey - fy; const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; const nr = n.dotRadius * n.scale + 4; // концы линии — у краёв кружков const x1 = fx + ux * fr; const y1 = fy + uy * fr; const x2 = ex - ux * nr; const y2 = ey - uy * nr; // контрольная точка кривой Безье: постоянная изящная дуга (перпендикуляр) + // прогиб НАЗАД против вектора скорости узла (резиновый жгут); в покое — идеальная дуга const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const segLen0 = Math.hypot(x2 - x1, y2 - y1); const baseBow = Math.max(7, Math.min(20, segLen0 * 0.12)); // постоянная дуга const speed = Math.hypot(n.vx, n.vy); const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения const invY = speed > 0.01 ? -n.vy / speed : 0; // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости const desX = mx + (-uy) * baseBow + invX * lag; const desY = my + ux * baseBow + invY * lag; const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired const cpy = 2 * desY - my; // Связь рисуем по статусу узла: // • обычная — мягкое цветное свечение по типу связи; // • сияющая — плазма из трёх слоёв на одном S-пути (flare/tube/core). const shine = Boolean(n.shining) && !n.hidden; const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла // (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра. let dashAttr = ''; if (growing) { const finalD = Math.hypot(n.bfx, n.bfy) || 1; const curD = Math.hypot(n.x, n.y); const growP = Math.max(0, Math.min(1, curD / finalD)); const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2; dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; } if (shine) { const pnx = -uy; const pny = ux; const amp = Math.min(13, 5 + segLen0 * 0.05); const bowX = desX - mx; const bowY = desY - my; const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; const dS = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; parts.push(``); parts.push(``); parts.push(``); } else { const col = relationColor(n.relationType); const haloOp = (0.22 * nodeOpacity).toFixed(2); const coreOp = (0.7 * nodeOpacity).toFixed(2); const sw = (2.6 + n.strength * 1.4).toFixed(2); parts.push(``); parts.push(``); } } edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`; } function updateReticle() { // ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0) let best = Infinity; for (const n of nodes) { if (n.hidden) continue; const d = Math.hypot(camX + n.x, camY + n.y); if (d < best) best = d; } reticle.classList.toggle('is-locked', best < 46); } function renderAll() { renderNodes(); renderEdges(); updateReticle(); } // --- Физика (пружины + отталкивание) --------------------------------------- // Фокус не «пинится» жёстко, а влетает к центру пружиной (упругая стабилизация). // Периферия держится радиальной пружиной на орбите и расталкивается силой charge — // получается органичная плавающая структура, а не жёсткий круг. function stepPhysics() { let totalV = 0; for (const n of nodes) { if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает let ax = 0; let ay = 0; if (n.isFocus) { // пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается) ax += K_FOCUS * (0 - n.x); ay += K_FOCUS * (0 - n.y); } else { // радиальная пружина к целевому радиусу орбиты const d = Math.hypot(n.x, n.y) || 0.001; const ux = n.x / d; const uy = n.y / d; const fr = K_RADIAL * (n.targetR - d); ax += fr * ux; ay += fr * uy; // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре) for (const m of nodes) { if (m === n || m.hidden) continue; const dx = n.x - m.x; const dy = n.y - m.y; let dist2 = dx * dx + dy * dy; if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST; const dist = Math.sqrt(dist2); const f = chargeNow / dist2; ax += (dx / dist) * f; ay += (dy / dist) * f; } } n.vx = (n.vx + ax) * frictionNow; n.vy = (n.vy + ay) * frictionNow; n.x += n.vx; n.y += n.vy; totalV += Math.abs(n.vx) + Math.abs(n.vy); } return totalV; } // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»). function advanceVisual() { for (const n of nodes) { n.scale += (n.targetScale - n.scale) * 0.2; n.opacity += (n.targetOpacity - n.opacity) * 0.2; // линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08); } } // Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки). function visualSettling() { for (const n of nodes) { if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true; } return false; } // --- Твин центрирования ----------------------------------------------------- function startRecenterTween(newFocusId) { const target = nodes.find((n) => String(n.id) === String(newFocusId)); if (!target || target.isFocus) return; focusId = String(newFocusId); // пересчёт ролей: новый фокус — в центр, остальные — на орбиту (включая старый фокус) const peers = nodes.filter((n) => String(n.id) !== focusId); const from = new Map(); const to = new Map(); nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale })); nodes.forEach((n) => { n.isFocus = String(n.id) === focusId; n.el.classList.toggle('is-focus', n.isFocus); }); target.targetR = 0; target.tx = 0; target.ty = 0; target.vx = 0; target.vy = 0; to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE }); peers.forEach((n, i) => { n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN); n.angle = spreadAngle(i, peers.length); n.vx = 0; n.vy = 0; const tx = Math.cos(n.angle) * n.targetR; const ty = Math.sin(n.angle) * n.targetR; n.tx = tx; // обновляем целевую точку, иначе физика после твина утянет узел назад n.ty = ty; const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; to.set(n.id, { x: tx, y: ty, scale: sc }); }); tween = { startTs: 0, from, to, camFrom: { x: camX, y: camY }, camTo: { x: 0, y: 0 }, }; wake(); } // Универсальный твин (физика выключена → ноль тряски). Поддерживает длительность, кривую и // поканальную задержку (каскад) + рост линий. Используется для bloom-разлёта, фильтра, центрирования. function stepTween(ts) { if (!tween.startTs) tween.startTs = ts; const dur = tween.dur || TWEEN_MS; const ease = tween.ease || easeOutCubic; const elapsed = ts - tween.startTs; let allDone = true; for (const n of nodes) { const a = tween.from.get(n.id); const b = tween.to.get(n.id); if (!a || !b) continue; let raw = (elapsed - (a.delay || 0)) / dur; if (raw < 0) raw = 0; // узел ещё не «выпущен» — держим в стартовой точке if (raw < 1) allDone = false; raw = Math.min(1, raw); const t = ease(raw); n.x = a.x + (b.x - a.x) * t; n.y = a.y + (b.y - a.y) * t; n.scale = a.scale + (b.scale - a.scale) * t; const ao = a.opacity ?? 1; const bo = b.opacity ?? 1; n.opacity = ao + (bo - ao) * t; if (b.grow) n.edgeGrow = raw; // линия «вытекает» по прогрессу своего узла } const camT = ease(Math.min(1, elapsed / dur)); camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * camT; camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * camT; applyWorldTransform(); if (allDone) { const wasBloom = tween.idleBoost; tween = null; // твин завершён for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; n.edgeGrow = 1; } if (wasBloom) boost = 1; // в покое — лёгкое «гель»-демпфированное упругое покачивание } } // Прерывание твина жестом (требование «конфликт жестов»): фиксируем текущие позиции и отдаём пальцу. function cancelTween() { if (!tween) return; tween = null; for (const n of nodes) { n.vx = 0; n.vy = 0; } } // Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден. // Перестроение идёт нативными CSS-переходами (компоновщик): работает даже когда rAF-цикл // троттлится в простое (иначе граф «не перестраивался» бы). Скрытые узлы плавно гаснут НА МЕСТЕ // (scale 0.8 + opacity 0 за 300мс), видимые плавно переплывают на равномерные углы орбиты; // лучи скрываемых гаснут вместе с ними (renderEdges читает живую прозрачность из DOM). // По завершении — жёсткая фиксация на этих углах БЕЗ физики (ноль тряски, идеальный sleep). function setFilter(predicate) { const pred = typeof predicate === 'function' ? predicate : () => true; if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; } cancelTween(); // на случай активного JS-твина центрирования — отдаём управление CSS-переходу const visiblePeers = []; nodes.forEach((n) => { if (n.isFocus) { n.hidden = false; return; } n.hidden = !pred(n); n.vx = 0; n.vy = 0; if (!n.hidden) visiblePeers.push(n); }); const FILTER_MS = 300; const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`; // применяем целевое состояние как CSS-переход; финал кэшируем в bfx/bfy/bfs/bfo для endCssBloom const apply = (n, x, y, s, o) => { n.bfx = x; n.bfy = y; n.bfs = s; n.bfo = o; n.el.style.transition = `transform ${FILTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${FILTER_MS}ms ease`; n.el.style.transform = tf(x, y, s); n.el.style.opacity = String(o); n.el.style.pointerEvents = o <= 0.01 ? 'none' : ''; }; const focus = nodes.find((n) => n.isFocus); if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1); visiblePeers.forEach((n, i) => { n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN); n.angle = spreadAngle(i, visiblePeers.length); n.tx = Math.cos(n.angle) * n.targetR; n.ty = Math.sin(n.angle) * n.targetR; const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; apply(n, n.tx, n.ty, sc, 1); }); nodes.forEach((n) => { if (n.isFocus || !n.hidden) return; // скрытые: растворяются ПРЯМО НА МЕСТЕ (scale 0.8 + opacity 0 за 300мс) — мягкий фейд, без «вылетов» apply(n, n.x, n.y, 0.8, 0); }); // режим CSS-перехода: цикл лишь ведёт лучи за узлами; по таймеру — фиксация без физики cssBloom = true; cssBloomKind = 'filter'; renderEdges(); cssBloomTimer = window.setTimeout(endCssBloom, FILTER_MS + 60); wake(); } // Жёсткая заморозка: гасим скорости, округляем координаты до целых пикселей, // НЕ перезапускаем цикл — граф замирает намертво (без «треска»). function freezeGraph() { for (const n of nodes) { n.vx = 0; n.vy = 0; n.x = Math.round(n.x); n.y = Math.round(n.y); n.scale = n.targetScale; n.opacity = n.targetOpacity; } renderAll(); // финальный кадр на целых координатах } // --- Цикл с kill-switch + инерция + заморозка ------------------------------ function tick(ts) { rafId = 0; // режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики) if (cssBloom) { syncPositionsFromDOM(); renderEdges(); updateReticle(); schedule(); return; } // инерция панорамирования (kinematic): камера докатывается с трением const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15); if (panActive) { camX += panVelX; camY += panVelY; panVelX *= PAN_FRICTION; panVelY *= PAN_FRICTION; applyWorldTransform(); } else { panVelX = 0; panVelY = 0; } // динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80), // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле» frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION); chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost); if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES); let totalV = 0; if (tween) { stepTween(ts); } else { totalV = stepPhysics(); advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity } renderAll(); if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || visualSettling()) { schedule(); } else { freezeGraph(); // система успокоилась — замираем } } function schedule() { if (!rafId) rafId = requestAnimationFrame(tick); } function wake() { schedule(); } // --- Жесты (pan / tap / longpress) ----------------------------------------- let pointerId = null; let downX = 0; let downY = 0; let camStartX = 0; let camStartY = 0; let moved = false; let downNodeEl = null; let longTimer = 0; let longFired = false; function nodeFromEvent(ev) { const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; if (!el) return null; const id = el.dataset.nodeId; return nodes.find((n) => String(n.id) === String(id)) || null; } function onPointerDown(ev) { if (pointerId !== null) return; pointerId = ev.pointerId; panVelX = 0; // новое касание мгновенно прерывает инерцию panVelY = 0; try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ } downX = ev.clientX; downY = ev.clientY; camStartX = camX; camStartY = camY; moved = false; longFired = false; downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; const downNode = nodeFromEvent(ev); if (downNode && typeof onNodeLongPress === 'function') { longTimer = window.setTimeout(() => { if (moved) return; longFired = true; const rect = downNode.el.getBoundingClientRect(); // координаты для меню берём из экранного rect узла (меню рендерится вне масштабируемого мира) onNodeLongPress(downNode, { x: rect.left + rect.width / 2, y: rect.top, rect }); }, LONGPRESS_MS); } } function onPointerMove(ev) { if (ev.pointerId !== pointerId) return; const dx = ev.clientX - downX; const dy = ev.clientY - downY; if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { moved = true; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } cancelTween(); // жест прерывает анимацию центрирования dragging = true; } if (moved) { const newCamX = camStartX + dx; const newCamY = camStartY + dy; panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания) panVelY = newCamY - camY; camX = newCamX; camY = newCamY; applyWorldTransform(); renderEdges(); // рёбра следуют за камерой синхронно (дёшево) updateReticle(); } } function onPointerUp(ev) { if (ev.pointerId !== pointerId) return; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ } const wasMoved = moved; const wasLong = longFired; pointerId = null; dragging = false; if (wasMoved || wasLong) { // после pan даём физике чуть устаканиться и уснуть if (wasMoved) wake(); return; } // это был тап const tapNode = downNodeEl ? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId)) : null; if (!tapNode) return; if (tapNode.isFocus) { if (typeof onCenterTap === 'function') onCenterTap(tapNode); return; } if (typeof onNodeTap === 'function') { // запоминаем точку, из которой новый фокус влетит в центр; перестройку делает onNodeTap (setModel) pendingFocusOrigin = { id: String(tapNode.id), x: tapNode.x, y: tapNode.y }; onNodeTap(tapNode); } else { // нет внешнего обработчика — внутреннее перецентрирование (фолбэк) startRecenterTween(tapNode.id); } } function onResize() { viewW = stage.clientWidth || window.innerWidth; viewH = stage.clientHeight || window.innerHeight; centerX = viewW / 2; centerY = viewH / 2; renderEdges(); } // --- Жизненный цикл узлов (diffing) ---------------------------------------- // Ghost-слой = СНИМОК старых АВАТАРОК (без линий!) на полноэкранном слое. Линии в шлейф НЕ // копируем намеренно: старые связи должны исчезать мгновенно вместе с перерисовкой графа, а не // висеть «ошмётками» секунду. Клон застывает на месте и лениво тает (scale 1→0.7 + opacity→0) // за 1000мс — мягкий породистый шлейф истории, — затем удаляется из DOM (строго через 1000мс). function spawnGhost() { if (!world.childElementCount) return; const ghost = document.createElement('div'); ghost.className = 'fg-ghost-layer'; const worldClone = world.cloneNode(true); // .fg-world (центр) → только узлы на своих местах worldClone.style.transform = world.style.transform || ''; ghost.append(worldClone); stage.insertBefore(ghost, edgesSvg); // позади живых слоёв void ghost.offsetWidth; // рефлоу для запуска CSS-перехода ghost.style.transform = 'scale(0.7)'; ghost.style.opacity = '0'; window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс } // Импульс центрального кольца — подтверждение «захвата» нового фокуса. function pulseReticle() { reticle.classList.remove('is-pulse'); void reticle.offsetWidth; reticle.classList.add('is-pulse'); window.setTimeout(() => reticle.classList.remove('is-pulse'), 620); } // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM, // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками. function syncPositionsFromDOM() { const sr = stage.getBoundingClientRect(); for (const n of nodes) { const dot = n.el.querySelector('.node-dot') || n.el; const r = dot.getBoundingClientRect(); n.x = (r.left + r.width / 2) - sr.left - centerX - camX; n.y = (r.top + r.height / 2) - sr.top - centerY - camY; // живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой const o = parseFloat(getComputedStyle(n.el).opacity); if (Number.isFinite(o)) n.opacity = o; } } // Завершение CSS-bloom (по таймеру — гарантированно, даже при троттлинге rAF): // снимаем переходы, ставим узлы в финал и включаем лёгкую физику покачивания в покое. function endCssBloom() { cssBloomTimer = 0; if (!cssBloom) return; cssBloom = false; for (const n of nodes) { n.el.style.transition = ''; const fo = (typeof n.bfo === 'number') ? n.bfo : 1; // финальная прозрачность (0 — скрыт фильтром) n.x = n.bfx; n.y = n.bfy; n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo; n.vx = 0; n.vy = 0; n.edgeGrow = 1; } if (cssBloomKind === 'filter') { // ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } freezeGraph(); return; } boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80) renderNodes(); renderEdges(); wake(); } // Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний: // • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита); // • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1); // • исчезнувший — уходит призраком в глубину и удаляется. function setModel(nextModel) { const { focusId: newFocusId, specs } = computeSpecs(nextModel); const newIds = new Set(specs.map((s) => s.id)); const oldById = new Map(nodes.map((n) => [String(n.id), n])); // старый узел нового фокуса (если был) — фокус глайдит из его позиции const focusOld = oldById.get(String(newFocusId)); // снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира spawnGhost(); nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); }); focusId = String(newFocusId); if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; } const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`; let order = 0; let maxDelay = 0; const blooms = []; nodes = specs.map((spec) => { const oldNode = oldById.get(spec.id); let node; let isNew; if (oldNode && oldNode.dotOnly === spec.dotOnly) { updateNodeRole(oldNode, spec); // непрерывность: тот же DOM, новая роль/орбита node = oldNode; isNew = false; } else { if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly); isNew = true; } // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty) const finalX = node.isFocus ? 0 : node.tx; const finalY = node.isFocus ? 0 : node.ty; const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); // стартовая точка разлёта let fx; let fy; let fs; let fo; let delay = 0; if (node.isFocus) { if (focusOld) { fx = focusOld.x; fy = focusOld.y; fs = focusOld.scale; fo = 1; } // глайд из старой позиции else if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) { fx = pendingFocusOrigin.x; fy = pendingFocusOrigin.y; fs = 0.6; fo = 1; // влёт из точки клика } else { fx = 0; fy = 0; fs = 0.3; fo = 0; } // первичная инициализация } else if (isNew) { fx = Math.cos(node.angle) * 12; fy = Math.sin(node.angle) * 12; fs = 0.2; fo = 0; // из центрального круга order += 1; delay = order * BLOOM_STAGGER; // каскад (волна) } else { fx = node.x; fy = node.y; fs = node.scale; fo = node.opacity; // непрерывность: с текущего места } // финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей) node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1; node.x = fx; node.y = fy; node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1; node.hidden = false; // НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0); // переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия). node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1; maxDelay = Math.max(maxDelay, delay); blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay }); return node; }); pendingFocusOrigin = null; camX = 0; camY = 0; applyWorldTransform(); // ПАСС 1: стартовое состояние без перехода for (const b of blooms) { b.el.style.transition = 'none'; b.el.style.transform = b.start; b.el.style.opacity = String(b.fo); } void world.offsetWidth; // один форс-рефлоу, чтобы старт «зафиксировался» // ПАСС 2: включаем CSS-переход и ставим финал → компоновщик плавно «по маслу» // (работает даже когда JS-rAF троттлится; премиальная вязкая кривая Apple-уровня) for (const b of blooms) { b.el.style.transition = `transform ${BLOOM_MS}ms cubic-bezier(0.16, 1, 0.3, 1) ${b.delay}ms, opacity 700ms ease ${b.delay}ms`; b.el.style.transform = b.final; b.el.style.opacity = '1'; } cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost) renderEdges(); pulseReticle(); cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано wake(); } stage.addEventListener('pointerdown', onPointerDown); stage.addEventListener('pointermove', onPointerMove); stage.addEventListener('pointerup', onPointerUp); stage.addEventListener('pointercancel', onPointerUp); window.addEventListener('resize', onResize); let ro = null; if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(() => onResize()); ro.observe(stage); } setModel(model); return { recenter: (id) => startRecenterTween(id), setModel, setFilter, getFocusNode: () => nodes.find((n) => n.isFocus) || null, destroy() { if (rafId) cancelAnimationFrame(rafId); rafId = 0; if (longTimer) window.clearTimeout(longTimer); stage.removeEventListener('pointerdown', onPointerDown); stage.removeEventListener('pointermove', onPointerMove); stage.removeEventListener('pointerup', onPointerUp); stage.removeEventListener('pointercancel', onPointerUp); window.removeEventListener('resize', onResize); if (ro) ro.disconnect(); edgesSvg.remove(); world.remove(); reticle.remove(); }, }; } /** * Конвертирует данные формы ТЗ (focusUser + connections[]) в нейтральную модель движка. * Используется на этапе мок-прототипа (Фаза 1). */ export function buildModelFromTz(tz) { const focus = tz?.focusUser || {}; const focusNode = { id: String(focus.id || 'focus'), login: focus.login || focus.id || '', name: focus.name || '', avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null, photo: focus.photo || null, relationType: 'self', strength: 1, shining: String(focus.status || '').toLowerCase() === 'shining', tier: 1, }; const connections = Array.isArray(tz?.connections) ? tz.connections : []; const peerNodes = connections.map((c) => ({ id: String(c.id), login: c.login || c.id || '', name: c.name || '', avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null, photo: c.photo || null, relationType: c.relationType || 'contact', strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5, shining: String(c.status || '').toLowerCase() === 'shining', tier: c.hasOwnConnections === false ? 1 : (c.tier || 1), })); return { focusId: focusNode.id, nodes: [focusNode, ...peerNodes] }; }