diff --git a/VERSION.properties b/VERSION.properties index f5d162c..405cd97 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.141 +client.version=1.2.142 server.version=1.2.127 diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 7d3dc05..ea0de4b 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -42,6 +42,14 @@ const PAN_THRESHOLD = 8; // порог смещения (px), после const LONGPRESS_MS = 480; // порог долгого нажатия const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки +// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не +// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep). +const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше +const DEEP2_OPACITY = 0.4; // и полупрозрачный +const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка) +const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px +const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px + const RELATION_COLORS = { family: 'rgba(255, 159, 94, 0.92)', friend: 'rgba(120, 179, 255, 0.9)', @@ -129,6 +137,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Узлы движка: { 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-ветки рендера/раскладки) + const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); }; // Управление циклом rAF let rafId = 0; @@ -155,23 +166,37 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let panVelX = 0; let panVelY = 0; + // Упругий «изгиб от свайпа»: сглаженный вектор, который догоняет скорость пальца и плавно + // возвращается к нулю при отпускании (lerp). Им смещаем контрольные точки Безье — нити тянутся. + let panBendX = 0; + let panBendY = 0; + function advancePanBend() { + panBendX += (panVelX - panBendX) * 0.3; + panBendY += (panVelY - panBendY) * 0.3; + } + // --- Построение модели ----------------------------------------------------- // Вычисляем «спецификации» узлов нового графа (без создания 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) + // 1-й уровень держит орбиту вокруг центра (сортируем по силе, как раньше); узлы 2/3 уровня + // («друзья друзей» и микрозвёзды) орбиту не используют — их раскладывает layoutDeep вокруг родителя. + const tier1 = list + .filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) === 1) .sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0)); - const dotCount = Math.max(0, peers.length - MAX_FULL_NODES); + const deep = list.filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) >= 2); + const dotCount = Math.max(0, tier1.length - MAX_FULL_NODES); if (dotCount > 0) { - console.info(`[force-graph] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`); + console.info(`[force-graph] связей ${tier1.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 })); + tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES })); + // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками + deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 })); return { focusId: fId, specs }; } @@ -189,7 +214,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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); + // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда. + const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); + const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. const tx = isFocus ? 0 : Math.cos(angle) * targetR; @@ -200,6 +227,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL ...src, isFocus, tier, + parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус) + deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, + track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко + expandTarget: 0, // 0/1 — раскрыты ли дочерние глубокие узлы (по клику/ховеру) + expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс dotOnly, strength, targetR, @@ -213,12 +245,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL scale, targetScale: scale, hidden: false, - opacity: 1, - targetOpacity: 1, + opacity: tier >= 2 ? op : 1, + targetOpacity: op, bloom: false, edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) el, - dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)), + dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))), }; } @@ -249,6 +281,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (dotOnly) { el.className = [ 'fg-node', 'fg-dot', + tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся точка) src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, ].filter(Boolean).join(' '); @@ -261,6 +294,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, + tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier >= 2 ? 'is-secondary' : '', ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); @@ -291,6 +325,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const tier = Number(src.tier) || 1; node.isFocus = spec.isFocus; node.tier = tier; + node.parentId = String(src.parentId || ''); + node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2; + node.track = Boolean(src.track); + node.expandTarget = 0; node.expandP = 0; // при перестроении глубина схлопывается (раскрытие — по клику) node.dotOnly = spec.dotOnly; node.strength = strength; node.relationType = src.relationType; @@ -301,15 +339,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); + node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); node.hidden = false; node.bloom = false; - node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26)); - // обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM + node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 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(' '); + ? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', 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-tier2' : '', tier >= 2 ? 'is-secondary' : ''].filter(Boolean).join(' '); } // --- Рендер ---------------------------------------------------------------- @@ -326,6 +364,57 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } } + // Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию + // СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия + // (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь. + function layoutDeep() { + if (!hasDeep) return; + for (const tier of [2, 3]) { + for (const n of nodes) { + if (n.tier !== tier) continue; + const p = nodeById.get(n.parentId); + if (!p) { n.opacity = 0; continue; } + const e = p.expandP || 0; // насколько раскрыт родитель + const r = (tier === 2 ? DEEP_R2 : DEEP_R3) * e; // при e=0 — в центре родителя, при 1 — на орбите + n.x = p.x + Math.cos(n.deepAngle) * r; + n.y = p.y + Math.sin(n.deepAngle) * r; + const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY; + const baseSc = tier === 2 ? DEEP2_SCALE : 1; + // вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий) + const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1; + n.opacity = baseOp * e * parentVis; + n.scale = baseSc * (0.3 + 0.7 * e); // лёгкий «поп» при выплывании + n.targetOpacity = n.opacity; n.targetScale = n.scale; + } + } + } + + // Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет. + function advanceExpand() { + let moving = false; + for (const n of nodes) { + const t = n.expandTarget || 0; + const cur = n.expandP || 0; + if (Math.abs(cur - t) > 0.001) { + let next = cur + (t - cur) * 0.18; + if (Math.abs(next - t) < 0.012) next = t; else moving = true; + n.expandP = next; + } + } + return moving; + } + + // Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик) + // отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями. + function renderDeepNodes() { + if (!hasDeep) return; + for (const n of nodes) { + if (n.tier < 2) continue; + 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); + } + } + function renderEdges() { const focus = nodes.find((n) => n.id === focusId); if (!focus) { @@ -336,10 +425,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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 = []; @@ -350,6 +435,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1; if (n.hidden && nodeOpacity <= 0.02) continue; if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue; + // начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель + const parent = (n.parentId && nodeById.get(n.parentId)) || focus; + const fx = centerX + camX + parent.x; + const fy = centerY + camY + parent.y; + const fr = parent.dotRadius * parent.scale + 4; const nx = tx(n); const ny = ty(n); if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; @@ -382,9 +472,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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; + // ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца, + // сильнее у дальних узлов (инерция периферии). panBend сглажен и сам затухает → нить пружинит назад. + const farK = Math.min(1.3, Math.max(0.35, segLen0 / 200)); + let panBx = -panBendX * 0.55 * farK; + let panBy = -panBendY * 0.55 * farK; + const panBmag = Math.hypot(panBx, panBy); + if (panBmag > 40) { panBx = (panBx / panBmag) * 40; panBy = (panBy / panBmag) * 40; } + // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости узла + пан-стретч + const desX = mx + (-uy) * baseBow + invX * lag + panBx; + const desY = my + ux * baseBow + invY * lag + panBy; const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired const cpy = 2 * desY - my; // Связь рисуем по статусу узла: @@ -403,8 +500,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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) { - // glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr) + const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) + if (n.tier >= 3) { + // 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии + if (pe > 0.02) parts.push(``); + } else if (n.tier === 2) { + // 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) + if (pe > 0.02) parts.push(``); + } else if (shine || n.track) { + // glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения» + // (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации. const gOp = (0.4 * nodeOpacity).toFixed(2); const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; parts.push(``); @@ -452,6 +557,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let totalV = 0; for (const n of nodes) { if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает + if (n.tier >= 2) { n.vx = 0; n.vy = 0; continue; } // глубокие уровни — раскладывает layoutDeep let ax = 0; let ay = 0; @@ -467,9 +573,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const fr = K_RADIAL * (n.targetR - d); ax += fr * ux; ay += fr * uy; - // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре) + // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре); + // глубокие уровни (tier≥2) в отталкивании не участвуют (их много — берегём перф) for (const m of nodes) { - if (m === n || m.hidden) continue; + if (m === n || m.hidden || m.tier >= 2) continue; const dx = n.x - m.x; const dy = n.y - m.y; let dist2 = dx * dx + dy * dy; @@ -667,6 +774,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.scale = n.targetScale; n.opacity = n.targetOpacity; } + panBendX = 0; panBendY = 0; // нити в покое — ровные базовые дуги + layoutDeep(); // глубокие уровни — на орбитах родителей перед финальным кадром renderAll(); // финальный кадр на целых координатах } @@ -674,9 +783,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function tick(ts) { rafId = 0; - // режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики) + // режим CSS-bloom: узлы 1-го уровня анимирует компоновщик — мы лишь ведём лучи за ними (без физики); + // глубокие уровни (tier≥2) не в CSS-bloom — позиционируем их из JS, чтобы следовали за родителями if (cssBloom) { syncPositionsFromDOM(); + advanceExpand(); + layoutDeep(); + renderDeepNodes(); renderEdges(); updateReticle(); schedule(); @@ -691,10 +804,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL panVelX *= PAN_FRICTION; panVelY *= PAN_FRICTION; applyWorldTransform(); + } else if (dragging) { + // палец удерживается без движения → скорость (и изгиб нитей) мягко расслабляется, + // но onPointerMove перезапишет её свежим дельтой при следующем движении + panVelX *= 0.7; + panVelY *= 0.7; } else { panVelX = 0; panVelY = 0; } + advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю // динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80), // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле» @@ -710,9 +829,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity } + const expanding = advanceExpand(); // раскрытие/схлопывание глубоких уровней (по клику/ховеру) + layoutDeep(); // глубокие уровни следуют за родителями (после шага физики 1-го уровня) renderAll(); - if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || visualSettling()) { + const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад + if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) { schedule(); } else { freezeGraph(); // система успокоилась — замираем @@ -737,6 +859,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let downNodeEl = null; let longTimer = 0; let longFired = false; + let pressedNode = null; // узел, чьи глубокие дети сейчас «выплыли» (по нажатию) + let hoverNode = null; // узел под курсором (десктоп) — тоже раскрывает свои глубокие связи + + // раскрыть/схлопнуть глубокие уровни вокруг узла (локальный bloom по взаимодействию) + function expandNode(n) { if (n && !n.expandTarget) { n.expandTarget = 1; wake(); } } + function collapseNode(n) { if (n && n.expandTarget) { n.expandTarget = 0; wake(); } } + + // Hover (десктоп): наведение раскрывает глубокие связи узла, увод/смена — схлопывает. + function onHoverMove(ev) { + if (pointerId !== null || dragging) return; // только когда не нажато и не тащим + const n = nodeFromEvent(ev); + if (n === hoverNode) return; + if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode); + hoverNode = n; + if (n) expandNode(n); + } + function onHoverLeave() { + if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode); + hoverNode = null; + } function nodeFromEvent(ev) { const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; @@ -758,7 +900,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL moved = false; longFired = false; downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; + if (downNodeEl) downNodeEl.classList.add('is-pressed'); // тактильный отклик «нажатия вглубь» const downNode = nodeFromEvent(ev); + if (downNode) { pressedNode = downNode; expandNode(downNode); } // локальный bloom его глубоких связей if (downNode && typeof onNodeLongPress === 'function') { longTimer = window.setTimeout(() => { if (moved) return; @@ -777,6 +921,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { moved = true; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } + if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие + if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // свайп — схлопываем глубину cancelTween(); // жест прерывает анимацию центрирования dragging = true; } @@ -788,6 +934,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL camX = newCamX; camY = newCamY; applyWorldTransform(); + advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа renderEdges(); // рёбра следуют за камерой синхронно (дёшево) updateReticle(); } @@ -796,6 +943,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function onPointerUp(ev) { if (ev.pointerId !== pointerId) return; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } + if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается + if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // отпустили — глубина уходит обратно try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ } const wasMoved = moved; const wasLong = longFired; @@ -889,6 +1038,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo; n.vx = 0; n.vy = 0; n.edgeGrow = 1; } + layoutDeep(); // глубокие уровни ставим на орбиты родителей (их bfx — орбита центра, не годится) if (cssBloomKind === 'filter') { // ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. @@ -942,7 +1092,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // финальные координаты разлёта (детерминированная орбита с джиттером — в 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)); + const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка) + const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1) // стартовая точка разлёта let fx; let fy; let fs; let fo; let delay = 0; @@ -959,19 +1110,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } // финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей) - node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1; + node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = finalOp; node.x = fx; node.y = fy; - node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1; + node.scale = finalScale; node.opacity = finalOp; node.targetScale = finalScale; node.targetOpacity = finalOp; 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 }); + // глубокие уровни (tier≥2) НЕ участвуют в CSS-bloom — их позиционирует layoutDeep/renderDeepNodes + if (node.tier < 2) { + 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; + rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов + layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей + renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает) camX = 0; camY = 0; applyWorldTransform(); @@ -996,6 +1153,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL stage.addEventListener('pointerdown', onPointerDown); stage.addEventListener('pointermove', onPointerMove); + stage.addEventListener('pointermove', onHoverMove); // hover-раскрытие глубины (десктоп) + stage.addEventListener('pointerleave', onHoverLeave); stage.addEventListener('pointerup', onPointerUp); stage.addEventListener('pointercancel', onPointerUp); window.addEventListener('resize', onResize); @@ -1019,6 +1178,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (longTimer) window.clearTimeout(longTimer); stage.removeEventListener('pointerdown', onPointerDown); stage.removeEventListener('pointermove', onPointerMove); + stage.removeEventListener('pointermove', onHoverMove); + stage.removeEventListener('pointerleave', onHoverLeave); stage.removeEventListener('pointerup', onPointerUp); stage.removeEventListener('pointercancel', onPointerUp); window.removeEventListener('resize', onResize); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index dc2a655..8f2e64d 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -4,6 +4,10 @@ // центрирование и навигацию между пользователями. Используется связанный мульти-граф // networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает // карту на сеть этого человека (как реальный путь, но локально). +// +// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи +// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем). +// Это чисто визуальный лабораторный эксперимент на мок-данных. import { renderHeader } from '../../components/header.js'; import { networkGraphUsers } from '../../mock-data.js'; @@ -22,6 +26,18 @@ const FILTERS = { }; const FILTER_ORDER = ['all', 'family', 'friends', 'shining']; +// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки). +const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна', + 'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт']; + +// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков). +function seed01(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) % 100000) / 100000; +} + function helpText() { return [ 'Лаборатория карты связей (мок-данные, без сервера).', @@ -30,6 +46,9 @@ function helpText() { '• Тап по центральному узлу — здесь открылся бы профиль.', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', + '• Чип «Вселенная» — прототип глубины 2-3 уровней (фейковые связи, для наглядности).', + ' По умолчанию дальние связи скрыты. Зажми/наведи узел — его микро-связи выплывают', + ' вокруг, отпусти — втягиваются обратно. Перейдёшь на узел — путь до него горит треком.', '', 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', ].join('\n'); @@ -42,6 +61,87 @@ function graphForLogin(login) { return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] }; } +// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) — +// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой. +function synthTier1(focusId) { + const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6 + const out = []; + for (let i = 0; i < k; i += 1) { + const id = `${focusId}__t1_${i}`; + const s = seed01(id); + out.push({ + id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)], + avatar: null, photo: null, + relationType: ['family', 'friend', 'business', 'contact'][i % 4], + connectionStrength: 0.5 + s * 0.4, + status: s > 0.78 ? 'shining' : '', + hasOwnConnections: true, tier: 1, + }); + } + return out; +} + +// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня, +// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id. +function addDeepLevels(model) { + const focusId = model.focusId; + const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId)); + const extra = []; + tier1.forEach((p) => { + const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5 + for (let i = 0; i < k2; i += 1) { + const id2 = `${p.id}__d2_${i}`; + const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6; + extra.push({ + id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)], + avatar: null, photo: null, relationType: p.relationType || 'contact', + strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, + }); + const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5 + for (let j = 0; j < k3; j += 1) { + const id3 = `${id2}_d3_${j}`; + const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9; + extra.push({ + id: id3, login: id3, name: '', avatar: null, photo: null, relationType: 'contact', + strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3, + }); + } + } + }); + return { focusId, nodes: [...model.nodes, ...extra] }; +} + +// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых +// логинов) + опционально дальние уровни, когда включён режим «Вселенная». +// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …). +function buildLabModel(login, deep, fromLogin) { + const tz = graphForLogin(login); + if (!Array.isArray(tz.connections) || tz.connections.length === 0) { + if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login)); + else tz.connections = []; + } + const base = buildModelFromTz(tz); + + // «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost). + if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) { + const fid = String(fromLogin); + const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase() + || String(n.login || '').toLowerCase() === fid.toLowerCase()); + if (found) { + found.track = true; // уже среди связей — просто подсветим трек + } else { + const f = graphForLogin(fromLogin).focusUser || {}; + base.nodes.push({ + id: fid, login: f.login || fromLogin, name: f.name || fromLogin, + avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null, + photo: f.photo || null, relationType: 'friend', strength: 0.97, + shining: false, tier: 1, track: true, + }); + } + } + return deep ? addDeepLevels(base) : base; +} + export function renderNetworkLab({ navigate }) { const screen = document.createElement('section'); screen.className = 'network-screen'; @@ -54,17 +154,13 @@ export function renderNetworkLab({ navigate }) { const header = renderHeader({ title: 'Связи · лаборатория', - leftAction: { - label: '←', - onClick: () => navigate('network-view'), - }, - rightActions: [ - { label: '?', onClick: () => window.alert(helpText()) }, - ], + leftAction: { label: '←', onClick: () => navigate('network-view') }, + rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }], }); header.classList.add('network-header-overlay'); - const model = buildModelFromTz(graphForLogin(START_LOGIN)); + let centerLogin = START_LOGIN; + let deepMode = false; // Состояние активного слоя (как в network-view): фокус всегда виден. let activeFilter = 'all'; @@ -82,15 +178,19 @@ export function renderNetworkLab({ navigate }) { stage.append(header); screen.append(stage); + const model = buildLabModel(centerLogin, deepMode); + // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. const graph = createForceGraph({ stage, model, - // тап по узлу — переключаем карту на сеть выбранного человека + // тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла); + // в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита. onNodeTap: (node) => { - graph.setModel(buildModelFromTz(graphForLogin(node.login))); - // сохраняем выбранный слой при переходе на сеть другого человека (как в network-view) + const from = centerLogin; // предыдущий фокус → трек прохождения + centerLogin = node.login || node.id; + graph.setModel(buildLabModel(centerLogin, deepMode, from)); if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); }, onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`), @@ -108,8 +208,7 @@ export function renderNetworkLab({ navigate }) { // Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view. const filterBar = document.createElement('div'); filterBar.className = 'fg-filter-bar'; - // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, - // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. + // Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click). filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); FILTER_ORDER.forEach((key) => { const chip = document.createElement('button'); @@ -120,6 +219,18 @@ export function renderNetworkLab({ navigate }) { filterChips[key] = chip; filterBar.append(chip); }); + // Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип. + const deepChip = document.createElement('button'); + deepChip.type = 'button'; + deepChip.className = 'fg-filter-chip fg-deep-chip'; + deepChip.textContent = '🌌 Вселенная'; + deepChip.addEventListener('click', () => { + deepMode = !deepMode; + deepChip.classList.toggle('is-active', deepMode); + graph.setModel(buildLabModel(centerLogin, deepMode)); + if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); + }); + filterBar.append(deepChip); stage.append(filterBar); screen.cleanup = () => { diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index eaa552f..5dab4ac 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -102,7 +102,7 @@ height: 52px; margin: 0; font-size: 16px; - transition: box-shadow 160ms ease, border-color 160ms ease; + transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; } .fg-node.is-family .node-dot { @@ -132,10 +132,18 @@ box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45); } +/* Тактильный отклик «нажатия вглубь»: аватарка слегка вдавливается (scale 0.92), а неоновое кольцо + вспыхивает заметно ярче (~1.5×). Срабатывает при наведении, фокусе и зажатии (.is-pressed). */ .fg-node:focus-visible .node-dot, -.fg-node:hover .node-dot { - border-color: rgba(166, 218, 255, 0.95); - box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35); +.fg-node:hover .node-dot, +.fg-node.is-pressed .node-dot { + transform: scale(0.92); + border-color: rgba(160, 240, 255, 0.95); + box-shadow: 0 0 0 2px rgba(150, 238, 255, 0.6), 0 0 22px rgba(120, 230, 255, 0.85); +} + +@media (prefers-reduced-motion: reduce) { + .fg-node.is-pressed .node-dot { transform: none; } } /* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи. @@ -265,6 +273,40 @@ .fg-dot.is-contact { background: #36435c; } .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } +/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */ +/* 2-й уровень — «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени + и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */ +.fg-node.is-tier2 .node-dot { + border-color: rgba(150, 180, 220, 0.4); + box-shadow: none; +} +.fg-node.is-tier2 .fg-node-label { + font-size: 9px; + opacity: 0.55; + top: calc(100% + 1px); +} + +/* 3-й уровень — микрозвезда: светящаяся точка без картинки (эффект далёкого созвездия). */ +.fg-dot.is-tier3 { + width: 9px; + height: 9px; + border: 0; + background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%); + box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5); +} +.fg-dot.is-tier3.is-shine { + background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%); + box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7); +} + +/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */ +.fg-deep-chip.is-active { + background: rgba(150, 130, 255, 0.18); + border-color: rgba(190, 170, 255, 0.6); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3); + color: #efeaff; +} + /* «Прицел» в центре экрана (зона фокуса) — позади узлов */ .fg-reticle { position: absolute;