@ -14,7 +14,9 @@
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму Т З в неё):
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
import { renderUserAvatar } from '../../components/avatar-image.js' ;
import { renderUserAvatar , buildAvatarInitials } from '../../components/avatar-image.js' ;
const SVGNS = 'http://www.w3.org/2000/svg' ;
// --- Параметры физики и анимации ---------------------------------------------
const ORBIT _MIN = 150 ; // минимальный радиус орбиты (защитный отступ от центра), px
@ -24,14 +26,16 @@ const K_FOCUS = 0.12; // мягкая пружина фокуса к
const CHARGE = 1400 ; // базовое отталкивание (на старте перестроения временно ослабляется)
const CHARGE _START _FACTOR = 0.45 ; // доля отталкивания в момент «рождения» из центра (без паники)
const MIN _DIST = 40 ; // минимальная дистанция для расчёта отталкивания
const FRICTION = 0.8 2; // базовое затухание скорости (свободн о е покачивание)
const FRICTION _BOOST = 0.9 2; // максимальная вязкость в первые ~6 00мс после перестроения (гасит «взрыв»)
const BOOST _FRAMES = 36 ; // длительность затухающего boost'а вязкости (~ 6 00мс @60fps)
const FRICTION = 0.8 0; // базовое затухание (после транзита — лёгкое упруг о е покачивание)
const FRICTION _BOOST = 0.9 4; // «гелевая» вязкость в первые ~7 00мс после перестроения (гасит «взрыв»)
const BOOST _FRAMES = 42 ; // длительность затухающего boost'а вязкости (~ 7 00мс @60fps)
const SLEEP _V = 0.03 ; // порог суммарной |vx|+|vy| для жёсткой заморозки графа
const INTRO _FACTOR = 0.22 ; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
const EDGE _LERP = 0.25 ; // догон концов линии за узлом за кадр (эффект натянутой резинки)
const PAN _FRICTION = 0.93 ; // трение инерционного скролла карты
const TWEEN _MS = 560 ; // длительность анимации центрирования
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-г о уровня (друзья друзей)
@ -46,15 +50,59 @@ const RELATION_COLORS = {
contact : 'rgba(170, 190, 220, 0.7)' ,
} ;
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
const FOCUS _NEON = 'rgba(140, 240, 255, 0.95)' ;
// Яркий неон сияния — в него уходит градиент связи к «сияющему» узлу (совпадает с о свечением аватарки).
const SHINE _EDGE _NEON = 'rgba(150, 245, 255, 0.95)' ;
function easeOutCubic ( t ) {
const x = 1 - t ;
return 1 - x * x * x ;
}
// Решатель кубической кривой Безье (CSS cubic-bezier): прогресс x → значение y.
function cubicBezier ( x1 , y1 , x2 , y2 ) {
const cx = 3 * x1 ;
const bx = 3 * ( x2 - x1 ) - cx ;
const ax = 1 - cx - bx ;
const cy = 3 * y1 ;
const by = 3 * ( y2 - y1 ) - cy ;
const ay = 1 - cy - by ;
const sampleX = ( t ) => ( ( ax * t + bx ) * t + cx ) * t ;
const sampleY = ( t ) => ( ( ay * t + by ) * t + cy ) * t ;
const dX = ( t ) => ( 3 * ax * t + 2 * bx ) * t + cx ;
return ( x ) => {
let t = x ;
for ( let i = 0 ; i < 6 ; i += 1 ) {
const d = dX ( t ) ;
if ( Math . abs ( d ) < 1e-6 ) break ;
t -= ( sampleX ( t ) - x ) / d ;
}
return sampleY ( Math . max ( 0 , Math . min ( 1 , t ) ) ) ;
} ;
}
// Премиальная «вязкая» кривая для разлёта узлов (быстрый старт → очень мягкая посадка).
const EASE _BLOOM = cubicBezier ( 0.16 , 1 , 0.3 , 1 ) ;
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 ) {
@ -86,7 +134,7 @@ function hash01(str) {
* /
export function createForceGraph ( { stage , model , onCenterTap , onNodeTap , onNodeLongPress } = { } ) {
// Слои DOM
const edgesSvg = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' ) ;
const edgesSvg = document . createElementNS ( SVGNS , 'svg' ) ;
edgesSvg . setAttribute ( 'class' , 'fg-edges' ) ;
const world = document . createElement ( 'div' ) ;
world . className = 'fg-world' ;
@ -95,6 +143,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const reticle = document . createElement ( 'div' ) ;
reticle . className = 'fg-reticle' ;
stage . append ( edgesSvg , world , reticle ) ;
ensureShineFilter ( ) ; // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование)
let camX = 0 ;
@ -118,14 +167,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла)
let pendingFocusOrigin = null ;
// Прогресс «прорастания» линий 0→1 (1 = полностью вычерчены)
let edgeGrowth = 1 ;
// Затухающий «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 ;
@ -193,11 +245,32 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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' ;
@ -221,13 +294,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
] . filter ( Boolean ) . join ( ' ' ) ;
el . dataset . nodeId = String ( src . id ) ;
const avatar = renderUserAvatar ( {
login : src . login || src . name || String ( src . id ) ,
firstName : src . name || '' ,
avatar : src . avatar || null ,
size : 'node' ,
title : src . name || src . login || '' ,
} ) ;
// тестовое фото (лаборатория) — прямой 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' ) ;
@ -295,51 +371,83 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const focusLogin = String ( focus . login || '' ) . toLowerCase ( ) ;
const parts = [ ] ;
const defs = [ ] ;
let gi = 0 ;
for ( const n of nodes ) {
if ( n === focus ) continue ;
if ( n . hidden ) 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 ;
const dx = nx - fx ;
const dy = ny - fy ;
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (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 = n x - ux * nr ;
const y2 = n y - uy * nr ;
// контрольная точка кривой Безье: постоянн ый лёгкий изгиб (провисание) перпендикулярно
// линии + динамика от запаздывания (при движении узла нить выгибается сильнее)
const x2 = e x - ux * nr ;
const y2 = e y - uy * nr ;
// контрольная точка кривой Безье: постоянн ая изящная дуга (перпендикуляр) +
// прогиб НАЗАД против вектора скорости узла (резиновый жгут); в покое — идеальная дуга
const mx = ( x1 + x2 ) / 2 ;
const my = ( y1 + y2 ) / 2 ;
const segLen0 = Math . hypot ( x2 - x1 , y2 - y1 ) ;
// изгиб строго перпендикулярный: заметная постоянная дуга (≈7– 22px) +
// динамика от скорости узла → при движении нить выгибается, как натянутая резина
const baseBow = Math . max ( 7 , Math . min ( 20 , segLen0 * 0.12 ) ) ; // постоянная дуга
const speed = Math . hypot ( n . vx , n . vy ) ;
const bow = Math . max ( 7 , Math . min ( 22 , segLen0 * 0.13 ) ) + Math . min ( 16 , speed * 1.2 ) ;
const cpx = mx + ( - uy ) * bow * 2 ; // CP даёт середину Q-кривой = M + perp*bow
const cpy = my + ux * bow * 2 ;
// минимализм: тонкие (1.3– 1.8px), полупрозрачные линии — без «энергетических лучей»
const w = 1.3 + n . strength * 0.5 ;
// прорастание: длину пути приближаем хордой, dash-offset → 0
let dash = '' ;
if ( edgeGrowth < 1 ) {
const segLen = ( Math . hypot ( cpx - x1 , cpy - y1 ) + Math . hypot ( x2 - cpx , y2 - cpy ) ) || 1 ;
dash = ` stroke-dasharray=" ${ segLen . toFixed ( 1 ) } " stroke-dashoffset=" ${ ( segLen * ( 1 - edgeGrowth ) ) . toFixed ( 1 ) } " ` ;
}
parts . push (
` <path d="M ${ x1 . toFixed ( 1 ) } ${ y1 . toFixed ( 1 ) } Q ${ cpx . toFixed ( 1 ) } ${ cpy . toFixed ( 1 ) } ${ x2 . toFixed ( 1 ) } ${ y2 . toFixed ( 1 ) } " `
+ ` fill="none" stroke=" ${ relationColor ( n . relationType ) } " stroke-opacity="0.42" stroke-width=" ${ w . toFixed ( 2 ) } " stroke-linecap="round" ${ dash } /> `
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 ;
// ТОНКАЯ изящная дуга: одинарная квадратичная кривая Безье, лёгкий градиентный штрих.
// Обычная связь — неон у центра → цвет роли у узла. Связь к «СИЯЮЩЕМУ» — ярче, уходит в
// неон сияния и МОНОЛИТНО светится (статичный drop-shadow через класс .fg-edge-shine).
const shine = Boolean ( n . shining ) && ! n . hidden ;
const gid = ` fg-grad- ${ gi } ` ;
gi += 1 ;
const tipColor = shine ? SHINE _EDGE _NEON : relationColor ( n . relationType ) ;
const baseStop = shine ? 0.85 : 0.5 ;
const tipStop = shine ? 0.7 : 0.14 ;
defs . push (
` <linearGradient id=" ${ gid } " gradientUnits="userSpaceOnUse" x1=" ${ x1 . toFixed ( 1 ) } " y1=" ${ y1 . toFixed ( 1 ) } " x2=" ${ x2 . toFixed ( 1 ) } " y2=" ${ y2 . toFixed ( 1 ) } "> `
+ ` <stop offset="0" stop-color=" ${ FOCUS _NEON } " stop-opacity=" ${ baseStop } "/> `
+ ` <stop offset="1" stop-color=" ${ tipColor } " stop-opacity=" ${ tipStop } "/></linearGradient> `
) ;
const sw = ( shine ? 1.7 + n . strength * 0.8 : 1.3 + n . strength * 0.9 ) . toFixed ( 2 ) ; // тонко
const d = ` M ${ x1 . toFixed ( 1 ) } ${ y1 . toFixed ( 1 ) } Q ${ cpx . toFixed ( 1 ) } ${ cpy . toFixed ( 1 ) } ${ x2 . toFixed ( 1 ) } ${ y2 . toFixed ( 1 ) } ` ;
// прозрачность луча = живая прозрачность узла (гаснет вместе с узлом при фильтре/уходе)
const op = nodeOpacity < 0.995 ? ` opacity=" ${ nodeOpacity . toFixed ( 2 ) } " ` : '' ;
// ПРОРАСТАНИЕ из центра: 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 ) } " ` ;
}
const cls = shine ? ' class="fg-edge-shine"' : '' ; // монолитное неоновое свечение (drop-shadow)
parts . push ( ` <path ${ cls } d=" ${ d } " fill="none" stroke="url(# ${ gid } )" stroke-width=" ${ sw } " stroke-linecap="round" ${ op } ${ dashAttr } /> ` ) ;
}
edgesSvg . innerHTML = parts . join ( '' ) ;
edgesSvg . innerHTML = ` <defs> ${ defs . join ( '' ) } </defs> ${ parts . join ( '' ) } ` ;
}
function updateReticle ( ) {
@ -413,18 +521,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
}
// Плавное приближение масштаба/прозрачности к целям (bloom новых, рост/уменьшение при смене роли ).
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание» ).
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 ) return true ;
if ( Math . abs ( n . scale - n . targetScale ) > 0.01 || Math . abs ( n . opacity - n . targetOpacity ) > 0.01 || n . edgeGrow < 1 ) return true ;
}
return false ;
}
@ -476,28 +586,41 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
wake ( ) ;
}
// Универсальный твин (физика выключена → ноль тряски). Поддерживает длительность, кривую и
// поканальную задержку (каскад) + рост линий. Используется для bloom-разлёта, фильтра, центрирования.
function stepTween ( ts ) {
if ( ! tween . startTs ) tween . startTs = ts ;
const raw = Math . min ( 1 , ( ts - tween . startTs ) / TWEEN _MS ) ;
const t = easeOutCubic ( raw ) ;
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 ;
n . lerpX = n . x ; n . lerpY = n . y ;
if ( b . grow ) n . edgeGrow = raw ; // линия «вытекает» по прогрессу своего узла
}
camX = tween . camFrom . x + ( tween . camTo . x - tween . camFrom . x ) * t ;
camY = tween . camFrom . y + ( tween . camTo . y - tween . camFrom . y ) * t ;
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 ( raw >= 1 ) {
if ( allDone ) {
const wasBloom = tween . idleBoost ;
tween = null ; // твин завершён
// синхронизируем цели визуала с текущими, чтобы advanceVisual не «откатил» (важно для фильтра)
for ( const n of nodes ) { n . targetScale = n . scale ; n . targetOpacity = n . opacity ; }
for ( const n of nodes ) { n . targetScale = n . scale ; n . targetOpacity = n . opacity ; n . edgeGrow = 1 ; }
if ( wasBloom ) boost = 1 ; // в покое — лёгкое «гель»-демпфированное упругое покачивание
}
}
@ -509,12 +632,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
// Видимые перераспределяются по орбите, скрытые плавно гаснут (scale↓ + opacity→0).
// Перестроение идёт нативными CSS-переходами (компоновщик): работает даже когда rAF-цикл
// троттлится в простое (иначе граф «не перестраивался» бы). Скрытые узлы плавно гаснут Н А М Е С Т Е
// (scale 0.8 + opacity 0 за 300мс ), видимые плавно переплывают на равномерные углы орбиты;
// лучи скрываемых гаснут вместе с ними (renderEdges читает живую прозрачность из DOM).
// По завершении — жёсткая фиксация на этих углах БЕЗ физики (ноль тряски, идеальный sleep).
function setFilter ( predicate ) {
const pred = typeof predicate === 'function' ? predicate : ( ) => true ;
const from = new Map ( ) ;
const to = new Map ( ) ;
nodes . forEach ( ( n ) => from . set ( n . id , { x : n . x , y : n . y , scale : n . scale , opacity : n . opacity } ) ) ;
if ( cssBloomTimer ) { window . clearTimeout ( cssBloomTimer ) ; cssBloomTimer = 0 ; }
cancelTween ( ) ; // на случай активного JS-твина центрирования — отдаём управление CSS-переходу
const visiblePeers = [ ] ;
nodes . forEach ( ( n ) => {
@ -525,8 +651,19 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 ) to . set ( focus . id , { x : 0 , y : 0 , scale : FOCUS _SCALE , opacity : 1 } ) ;
if ( focus ) apply( focus , 0 , 0 , FOCUS _SCALE , 1 ) ;
visiblePeers . forEach ( ( n , i ) => {
n . targetR = ORBIT _MIN + ( 1 - n . strength ) * ( ORBIT _MAX - ORBIT _MIN ) ;
@ -534,17 +671,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 ;
to. set ( n . id , { x : n . tx , y : n . ty , scale : sc , opacity : 1 } ) ;
apply( n , n . tx , n . ty , sc , 1 ) ;
} ) ;
nodes . forEach ( ( n ) => {
if ( n . isFocus || ! n . hidden ) return ;
// скрытые: подтягиваем к центру и гасим
to. set ( n . id , { x : n . x * 0.35 , y : n . y * 0.35 , scale : 0.2 , opacity : 0 } ) ;
// скрытые: растворяются ПРЯМО Н А М Е С Т Е (scale 0.8 + opacity 0 за 300мс ) — мягкий фейд, без «вылетов»
apply( n , n . x , n . y , 0.8 , 0 ) ;
} ) ;
// фильтр не двигает камеру (в отличие от центрирования)
tween = { startTs : 0 , from , to , camFrom : { x : camX , y : camY } , camTo : { x : camX , y : camY } } ;
// режим CSS-перехода: цикл лишь ведёт лучи за узлами; по таймеру — фиксация без физики
cssBloom = true ;
cssBloomKind = 'filter' ;
renderEdges ( ) ;
cssBloomTimer = window . setTimeout ( endCssBloom , FILTER _MS + 60 ) ;
wake ( ) ;
}
@ -568,6 +708,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 ) {
@ -581,10 +730,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
panVelY = 0 ;
}
if ( edgeGrowth < 1 ) edgeGrowth = Math . min ( 1 , edgeGrowth + 0.07 ) ; // прорастание линий ~15 кадров
// динамическая вязкость: первые ~400мс после перестроения трение выше (0.90→0.82),
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту мягко
// динамическая вязкость: первые ~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 ) ;
@ -601,7 +748,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
renderAll ( ) ;
const lerpSettling = nodes . some ( ( n ) => Math . abs ( n . x - n . lerpX ) + Math . abs ( n . y - n . lerpY ) > 0.5 ) ;
if ( tween || dragging || panActive || edgeGrowth < 1 || boost > 0 || totalV > SLEEP _V || lerpSettling || visualSettling ( ) ) {
if ( tween || dragging || panActive || boost > 0 || totalV > SLEEP _V || lerpSettling || visualSettling ( ) ) {
schedule ( ) ;
} else {
freezeGraph ( ) ; // система успокоилась — замираем
@ -724,24 +871,22 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// --- Жизненный цикл узлов (diffing) ----------------------------------------
// Ghost-слой = СНИМОК всего старого графа (узлы + линии) на полноэкранном слое.
// Клон застывает СТРОГО Н А М Е С Т Е (полноэкранный overlay → координаты не сбрасываются),
// плавно уменьшается (scale 1→0.7) и растворяется (opacity 0.4→0) за 500мс — красивый
// шлейф истории перехода, — после чего полностью удаляется из DOM .
// 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 edgesClone = edgesSvg . cloneNode ( true ) ; // .fg-edges (inset:0) → линии совпадают по координатам
edgesClone . style . opacity = '' ; // снимаем возможный inline-fade, слой задаёт прозрачность сам
const worldClone = world . cloneNode ( true ) ; // .fg-world (центр) → узлы на своих местах
const worldClone = world . cloneNode ( true ) ; // .fg-world (центр) → только узлы на своих местах
worldClone . style . transform = world . style . transform || '' ;
ghost . append ( edgesClone, worldClone) ;
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 ( ) , 800) ;
window . setTimeout ( ( ) => ghost . remove ( ) , 1000) ; // удаление строго через 1000мс
}
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
@ -752,6 +897,48 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 ;
n . lerpX = n . x ; n . lerpY = n . y ;
// живая прозрачность из 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 . lerpX = n . x ; n . lerpY = n . y ;
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);
@ -761,75 +948,86 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 ) ) ;
const originX = focusOld ? focusOld . x : ( pendingFocusOrigin ? pendingFocusOrigin . x : 0 ) ;
const originY = focusOld ? focusOld . y : ( pendingFocusOrigin ? pendingFocusOrigin . y : 0 ) ;
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
spawnGhost ( ) ;
nodes . forEach ( ( n ) => { if ( ! newIds . has ( String ( n . id ) ) ) n . el . remove ( ) ; } ) ;
focusId = String ( newFocusId ) ;
edgeGrowth = 0 ; // линии к новым узлам прорастают из центра
boost = 1 ; // включаем повышенную вязкость на ~400мс (гасим энергию разлёта)
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 = [ ] ;
const fresh = [ ] ;
let bloomOrder = 0 ;
nodes = specs . map ( ( spec ) => {
const old = oldById . get ( spec . id ) ;
if ( old && old . dotOnly === spec . dotOnly ) {
updateNodeRole ( old , spec ) ; // непрерывность: тот же DOM, новая цель → перелёт пружиной
return old ;
}
if ( old ) old . el . remove ( ) ; // сменился тип (точка↔аватар) — заменяем элемент
const node = makeNodeState ( spec . src , spec . isFocus , spec . index , spec . total , spec . dotOnly ) ;
// периферия «выстреливает» из центрального круга (0,0); смещение вдоль угла даёт направление силам
const bx = Math . cos ( node . angle ) * 14 ;
const by = Math . sin ( node . angle ) * 14 ;
node . x = node . isFocus ? originX : bx ;
node . y = node . isFocus ? originY : by ;
node . lerpX = node . x ; node . lerpY = node . y ;
node . scale = 0.01 ; node . opacity = 0 ; node . bloom = true ;
node . bloomScale = spec . isFocus ? FOCUS _SCALE : ( spec . dotOnly ? 1 : ( Number ( spec . src . tier ) >= 2 ? SECONDARY _SCALE : PRIMARY _SCALE ) ) ;
if ( node . isFocus ) {
node . targetScale = node . bloomScale ; node . targetOpacity = 1 ; // фокус виден сразу (влетает)
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 {
// периферия: держим скрытой в центре и «выстреливаем» по очереди (каскад 40мс )
node . hidden = true ;
node . targetScale = 0 ; node . targetOpacity = 0 ;
node . bloomOrder = bloomOrder ++ ;
fresh . push ( node ) ;
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 . lerpX = fx ; node . lerpY = 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 ;
} ) ;
// новый фокус «влетает» из точки клика (если кликнули по периферийному узлу)
if ( pendingFocusOrigin && String ( pendingFocusOrigin . id ) === focusId ) {
const f = nodes . find ( ( n ) => n . isFocus ) ;
if ( f && ! focusOld ) { f . x = pendingFocusOrigin . x ; f . y = pendingFocusOrigin . y ; f . lerpX = f . x ; f . lerpY = f . y ; }
}
pendingFocusOrigin = null ;
// каскад: каждый новый узел освобождается из центра через order*40мс → волна
fresh . forEach ( ( node ) => {
window . setTimeout ( ( ) => {
node . hidden = false ;
node . targetScale = node . bloomScale ;
node . targetOpacity = 1 ;
wake ( ) ;
} , node . bloomOrder * 40 ) ;
} ) ;
camX = 0 ; camY = 0 ; applyWorldTransform ( ) ;
camX = 0 ;
camY = 0 ;
applyWorldTransform ( ) ;
renderAll ( ) ;
// линии: плавно проявляем (старые ушли с призраком)
edgesSvg . style . opacity = '0' ;
void edgesSvg . offsetWidth ;
edgesSvg . style . opacity = '1' ;
// ПАСС 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 ( ) ;
}
@ -880,6 +1078,7 @@ export function buildModelFromTz(tz) {
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' ,
@ -891,6 +1090,7 @@ export function buildModelFromTz(tz) {
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' ,