/* ============================================================================ Force-directed карта связей (.fg-*) — интерактивный граф на странице «Связи». Узлы позиционируются трансформами (GPU), рёбра — отдельный SVG-слой. Отдельный модуль, чтобы не раздувать components.css. ========================================================================== */ .fg-stage { touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */ user-select: none; -webkit-user-select: none; cursor: grab; background: radial-gradient(circle at 50% 42%, rgba(83, 216, 251, 0.07), rgba(255, 255, 255, 0.01) 60%); } .fg-stage:active { cursor: grabbing; } .fg-world { position: absolute; left: 50%; top: 50%; width: 0; height: 0; will-change: transform; z-index: 1; /* узлы и подписи строго над линиями связей */ } .fg-edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; z-index: 0; /* линии связей — под узлами */ transition: opacity 420ms ease; /* плавное появление линий при перестройке */ } .fg-node { position: absolute; left: 0; top: 0; width: 52px; height: 52px; border: 0; padding: 0; background: transparent; color: inherit; cursor: pointer; transform-origin: center center; will-change: transform; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; /* iOS: не показывать системное меню по долгому тапу */ z-index: 1; } /* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */ .fg-node .node-dot { width: 52px; height: 52px; margin: 0; font-size: 16px; transition: box-shadow 160ms ease, border-color 160ms ease; } .fg-node.is-family .node-dot { background: linear-gradient(165deg, #785038, #5f3e2c); border-color: rgba(255, 194, 143, 0.6); } .fg-node.is-friend .node-dot { background: linear-gradient(165deg, #2f4f80, #2a3f62); border-color: rgba(150, 190, 255, 0.5); } .fg-node.is-business .node-dot { background: linear-gradient(165deg, #4a3b7a, #2f2750); border-color: rgba(196, 165, 255, 0.55); } .fg-node.is-contact .node-dot { background: linear-gradient(165deg, #36435c, #283142); border-color: rgba(180, 200, 226, 0.4); } .fg-node.is-focus .node-dot { background: linear-gradient(130deg, #3a5f8e, #3dc4df); color: #061119; border-color: rgba(180, 230, 255, 0.85); box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45); } .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.is-shine .node-dot::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: radial-gradient(circle, rgba(130, 235, 255, 0.6) 0%, rgba(130, 235, 255, 0.26) 44%, rgba(130, 235, 255, 0) 76%); filter: blur(2px); z-index: -1; animation: fg-shine-pulse 2.4s ease-in-out infinite; } @keyframes fg-shine-pulse { 0%, 100% { transform: scale(0.92); opacity: 0.5; } 50% { transform: scale(1.16); opacity: 0.95; } } @media (prefers-reduced-motion: reduce) { .fg-node.is-shine .node-dot::before { animation: none; } } /* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */ .fg-node.is-focus .node-dot::after { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: radial-gradient(circle, rgba(130, 235, 255, 0.32) 0%, rgba(130, 235, 255, 0) 70%); z-index: -1; pointer-events: none; } /* «Дыхание» фокуса — бесконечная очень мягкая пульсация РАЗМЕРА (база 1.5x → 1.48–1.52x), период 4с. CSS-анимация на transform (GPU) — НЕ будит rAF-цикл физики; интерфейс «живой». */ .fg-node.is-focus .node-dot { animation: fg-focus-breath 4s ease-in-out infinite; } @keyframes fg-focus-breath { 0%, 100% { transform: scale(0.987); } 50% { transform: scale(1.013); } } @media (prefers-reduced-motion: reduce) { .fg-node.is-focus .node-dot { animation: none; } } /* подпись под узлом — абсолютная, чтобы не влиять на размер бокса (центрирование по трансформу) */ .fg-node-label { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: 5px; max-width: 110px; font-size: 10px; line-height: 1.1; color: #d6e2ff; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); pointer-events: none; } .fg-node.is-secondary .fg-node-label { opacity: 0.75; } /* Имя центрального узла — на подложке, чтобы линии связей не просвечивали сквозь текст */ .fg-node.is-focus .fg-node-label { margin-top: 7px; padding: 3px 10px; border-radius: 9px; background: rgba(8, 14, 24, 0.74); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); color: #f4f8ff; font-weight: 600; text-shadow: none; } /* Лёгкая точка (узлы сверх хард-лимита DOM) — без аватара/подписи */ .fg-dot { width: 15px; height: 15px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); background: #36435c; box-shadow: 0 2px 6px rgba(4, 8, 15, 0.4); } .fg-dot.is-family { background: #6f4a34; border-color: rgba(255, 194, 143, 0.5); } .fg-dot.is-friend { background: #2f4f80; border-color: rgba(150, 190, 255, 0.45); } .fg-dot.is-business { background: #4a3b7a; border-color: rgba(196, 165, 255, 0.5); } .fg-dot.is-contact { background: #36435c; } .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } /* «Прицел» в центре экрана (зона фокуса) — позади узлов */ .fg-reticle { position: absolute; left: 50%; top: 50%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%; border: 2px dashed rgba(150, 190, 255, 0.3); pointer-events: none; z-index: 0; opacity: 0.45; transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease; } .fg-reticle.is-locked { width: 94px; height: 94px; margin: -47px 0 0 -47px; border-color: rgba(130, 235, 255, 0.65); opacity: 0.85; } /* «Призрак» старой карты при Z-переходе (эффект погружения) */ .fg-ghost-layer { position: absolute; inset: 0; /* полноэкранный overlay → клон линий/узлов совпадает по координатам */ pointer-events: none; z-index: 0; opacity: 0.5; /* стартовая прозрачность шлейфа выше → дольше читается (JS уводит в 0) */ transform-origin: 50% 50%; transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 800ms cubic-bezier(0.16, 1, 0.3, 1); } /* Импульс центрального кольца при захвате нового фокуса */ .fg-reticle.is-pulse { animation: fg-reticle-pulse 0.6s ease; } @keyframes fg-reticle-pulse { 0% { transform: scale(1); } 40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); } 100% { transform: scale(1); } } /* Панель фильтров слоёв (оверлей под шапкой) */ .fg-filter-bar { position: absolute; top: max(54px, calc(env(safe-area-inset-top) + 50px)); left: 0; right: 0; z-index: 11; display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; padding: 0 12px; pointer-events: none; } .fg-filter-chip { pointer-events: auto; border: 1px solid rgba(166, 196, 245, 0.28); background: rgba(10, 20, 37, 0.6); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: #cfe0ff; font-size: 12px; font-weight: 600; line-height: 1; padding: 7px 13px; border-radius: 999px; cursor: pointer; transition: background 140ms ease, border-color 140ms ease, color 140ms ease; } .fg-filter-chip.is-active { background: linear-gradient(130deg, rgba(61, 196, 223, 0.92), rgba(58, 95, 142, 0.92)); border-color: rgba(180, 230, 255, 0.85); color: #061119; } /* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */ .fg-menu-overlay { position: fixed; inset: 0; z-index: 50; } .fg-menu { position: fixed; min-width: 210px; padding: 8px; display: grid; gap: 3px; background: rgba(16, 24, 40, 0.97); border: 1px solid rgba(166, 196, 245, 0.28); border-radius: 14px; box-shadow: 0 16px 44px rgba(0, 0, 0, 0.55); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); z-index: 51; } .fg-menu-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; padding: 4px 8px 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); margin-bottom: 3px; } .fg-menu-login { font-weight: 700; color: #eaf1ff; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fg-menu-rel { font-size: 11px; color: #9fb6e0; flex: 0 0 auto; } .fg-menu-item { text-align: left; background: transparent; border: 0; color: #dfe9ff; font-size: 14px; padding: 9px 10px; border-radius: 9px; cursor: pointer; } .fg-menu-item:hover { background: rgba(77, 160, 255, 0.16); } .fg-menu-item.is-stub { color: #7f8aa3; cursor: default; } .fg-menu-item.is-stub:hover { 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; }