diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index bc90f4db..91906643 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -4,7 +4,7 @@ import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { createForceGraph } from './network/force-graph.js'; import { engineModelFromGraphModel } from './network/adapter.js'; -import { openNodeMenu, relationLabelRu } from './network/node-menu.js'; +import { openNodeMenu } from './network/node-menu.js'; export const pageMeta = { id: 'network-view', title: 'Связи' }; @@ -237,7 +237,6 @@ export function render({ navigate, route, chrome } = {}) { let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || ''); let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : []; let engine = null; - let sheetEl = null; let loadSeq = 0; // Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. @@ -400,41 +399,6 @@ export function render({ navigate, route, chrome } = {}) { window.setTimeout(() => inputEl.focus(), 0); } - // Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту. - function showNodeSheet(node) { - if (!sheetEl) { - sheetEl = document.createElement('div'); - sheetEl.className = 'fg-sheet'; - stage.append(sheetEl); - } - const login = normalizeLogin(node.login); - const shineBadge = node.shining ? 'сияющий' : ''; - sheetEl.innerHTML = ` - -
-
${escapeHtml(login)} ${shineBadge}
-
${escapeHtml(relationLabelRu(node.relationType))}
-
-
- - -
- `; - sheetEl.classList.add('is-open'); - sheetEl.onclick = (e) => { - const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null; - if (!(btn instanceof HTMLElement)) return; - const act = btn.dataset.act; - if (act === 'close') hideNodeSheet(); - else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); } - else if (act === 'write') navigate(`chat/${encodeURIComponent(login)}`); - }; - } - - function hideNodeSheet() { - if (sheetEl) sheetEl.classList.remove('is-open'); - } - function ensureEngine(model) { if (engine) { engine.setModel(model); @@ -443,8 +407,8 @@ export function render({ navigate, route, chrome } = {}) { engine = createForceGraph({ stage: board, model, - // тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет - onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); }, + // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет + onNodeTap: (node) => { void load(node.login, { pushHistory: true }); }, // тап по центру — полноценный профиль onCenterTap: (node) => { const routeTo = profileInfoRoute(node.login); diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index ffa297f0..66728f09 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -21,8 +21,9 @@ import { buildArweaveDataUrl } from '../../services/arweave-file-service.js'; const SVGNS = 'http://www.w3.org/2000/svg'; // --- Параметры физики и анимации --------------------------------------------- -const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px -const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px +const ORBIT_FIRST_R = 100; // первый круг связей: был ~150px, теперь на треть ближе к центру +const ORBIT_GAP = 87; // между соседними кругами >= 1.5 диаметра аватарки (58px * 1.5) +const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина» const K_FOCUS = 0.12; // мягкая пружина фокуса к центру const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется) @@ -146,11 +147,57 @@ function ensureShineFilter() { document.body.appendChild(svg); } -// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N. -// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали. -function spreadAngle(index, total) { +// Равномерный угол по кругу. Небольшой постоянный сдвиг, чтобы первый узел +// не «прилипал» к горизонтали. +function spreadAngle(index, total, phase = 0.52) { if (total <= 0) return 0; - return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2); + return ((index / total) * Math.PI * 2 + phase) % (Math.PI * 2); +} + +// Сколько аватарок гарантированно помещается на круге радиуса r, если расстояние +// между ЦЕНТРАМИ соседних аватарок должно быть не меньше ORBIT_NODE_GAP. +// Используем хорду, а не длину дуги — поэтому условие выполняется геометрически точно. +function orbitCapacity(radius) { + const r = Math.max(radius, ORBIT_NODE_GAP / 2 + 1); + const minAngle = 2 * Math.asin(Math.min(1, ORBIT_NODE_GAP / (2 * r))); + return Math.max(1, Math.floor((Math.PI * 2) / minAngle)); +} + +// Детерминированная раскладка первого уровня по концентрическим кругам. +// Сначала максимально заполняем внутренний круг, затем второй, третий и т.д. +// Соседние кольца разнесены минимум на 87px, а каждое следующее чуть повёрнуто, +// чтобы линии второго/третьего круга естественно проходили под аватарками внутренних кругов. +function buildOrbitPlacements(total) { + const count = Math.max(0, Number(total) || 0); + const out = new Array(count); + let start = 0; + let ring = 0; + while (start < count) { + const radius = ORBIT_FIRST_R + ring * ORBIT_GAP; + const capacity = orbitCapacity(radius); + const ringCount = Math.min(capacity, count - start); + const phase = 0.52 + (ring % 2 ? Math.PI / Math.max(6, ringCount) : 0); + for (let i = 0; i < ringCount; i += 1) { + const angle = spreadAngle(i, ringCount, phase); + out[start + i] = { radius, angle, ring }; + } + start += ringCount; + ring += 1; + } + return out; +} + +function applyOrbitTargets(peers) { + const list = Array.isArray(peers) ? peers : []; + const placements = buildOrbitPlacements(list.length); + list.forEach((n, i) => { + const p = placements[i] || { radius: ORBIT_FIRST_R, angle: spreadAngle(i, list.length), ring: 0 }; + n.targetR = p.radius; + n.angle = p.angle; + n.orbitRing = p.ring; + n.tx = Math.cos(n.angle) * n.targetR; + n.ty = Math.sin(n.angle) * n.targetR; + }); } // Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной, @@ -360,8 +407,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } 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 }); - tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES })); + const tier1Orbit = buildOrbitPlacements(tier1.length); + if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null }); + tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] })); // 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 }; @@ -370,17 +418,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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)); + return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly, s.orbit)); } - function makeNodeState(src, isFocus, index, total, dotOnly = false) { + function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) { 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 targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R); + const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)); // масштаб/прозрачность по уровню глубины: 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); @@ -404,6 +451,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL strength, targetR, angle, + orbitRing: orbit?.ring || 0, tx, ty, x: tx * INTRO_FACTOR, @@ -567,10 +615,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R); + node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)); + node.orbitRing = spec.orbit?.ring || 0; node.tx = Math.cos(node.angle) * node.targetR; node.ty = Math.sin(node.angle) * node.targetR; node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); @@ -990,17 +1037,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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); + applyOrbitTargets(peers); + peers.forEach((n) => { 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 }); + to.set(n.id, { x: n.tx, y: n.ty, scale: sc }); }); tween = { @@ -1091,11 +1133,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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; + applyOrbitTargets(visiblePeers); + visiblePeers.forEach((n) => { const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; apply(n, n.tx, n.ty, sc, 1); }); @@ -1598,7 +1637,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node = oldNode; isNew = false; } else { if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент - node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly); + node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly, spec.orbit); isNew = true; } diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 3a7fda8c..57c41492 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -531,79 +531,6 @@ background: transparent; } -/* Нижний сниппет (bottom sheet) */ -.fg-sheet { - position: absolute; - left: 12px; - right: 12px; - bottom: calc(12px + env(safe-area-inset-bottom)); - z-index: 13; - display: none; - padding: 12px 14px 14px; - background: rgba(16, 24, 40, 0.95); - border: 1px solid rgba(166, 196, 245, 0.26); - border-radius: 16px; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); -} - -.fg-sheet.is-open { - display: block; - animation: fg-sheet-in 200ms ease; -} - -@keyframes fg-sheet-in { - from { transform: translateY(16px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -.fg-sheet-close { - position: absolute; - top: 8px; - right: 10px; - background: transparent; - border: 0; - color: #9fb6e0; - font-size: 16px; - cursor: pointer; - line-height: 1; -} - -.fg-sheet-title { - font-weight: 700; - font-size: 15px; - color: #eaf1ff; - display: flex; - gap: 8px; - align-items: center; -} - -.fg-sheet-badge { - font-size: 10px; - background: rgba(130, 235, 255, 0.2); - color: #bff0ff; - padding: 2px 8px; - border-radius: 999px; - font-weight: 600; -} - -.fg-sheet-rel { - font-size: 12px; - color: #9fb6e0; - margin-top: 3px; -} - -.fg-sheet-actions { - display: flex; - gap: 8px; - margin-top: 12px; -} - -.fg-sheet-actions > button { - flex: 1; -} - /* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ /* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */