diff --git a/.gitignore b/.gitignore index cbf0c09..6c2b95d 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,9 @@ ESP32/**/*.a # Полные серверные бэкапы (тяжёлые архивы, не коммитим) server-backup/archive/** !server-backup/archive/.gitkeep + +# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим +.claude/ +# Рабочие бэкапы/превью-ассеты UI — не для репозитория +*.bak.png +shine-UI/assets/navbar_preview.png diff --git a/VERSION.properties b/VERSION.properties index d01b380..985e3b0 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.219 -server.version=1.2.207 +client.version=1.2.220 +server.version=1.2.208 diff --git a/shine-UI/assets/glass_overlay_faithful.png b/shine-UI/assets/glass_overlay_faithful.png new file mode 100644 index 0000000..3111655 Binary files /dev/null and b/shine-UI/assets/glass_overlay_faithful.png differ diff --git a/shine-UI/assets/icon_kanaly.png b/shine-UI/assets/icon_kanaly.png new file mode 100644 index 0000000..8a139aa Binary files /dev/null and b/shine-UI/assets/icon_kanaly.png differ diff --git a/shine-UI/assets/icon_lichnye.png b/shine-UI/assets/icon_lichnye.png new file mode 100644 index 0000000..6e645c7 Binary files /dev/null and b/shine-UI/assets/icon_lichnye.png differ diff --git a/shine-UI/assets/icon_profil.png b/shine-UI/assets/icon_profil.png new file mode 100644 index 0000000..1ab37d1 Binary files /dev/null and b/shine-UI/assets/icon_profil.png differ diff --git a/shine-UI/assets/icon_svyazi.png b/shine-UI/assets/icon_svyazi.png new file mode 100644 index 0000000..e0b4b2e Binary files /dev/null and b/shine-UI/assets/icon_svyazi.png differ diff --git a/shine-UI/assets/icon_uvedomleniya.png b/shine-UI/assets/icon_uvedomleniya.png new file mode 100644 index 0000000..bec52ea Binary files /dev/null and b/shine-UI/assets/icon_uvedomleniya.png differ diff --git a/shine-UI/docs/design/messages-list-v2.md b/shine-UI/docs/design/messages-list-v2.md new file mode 100644 index 0000000..827da4f --- /dev/null +++ b/shine-UI/docs/design/messages-list-v2.md @@ -0,0 +1,74 @@ +# Личные сообщения (messages-list) — дизайн v2 + +Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет +обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header. + +> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository. + +## Источник данных +- **Demo/lab:** мок `js/mock-data.js` → `directMessages` (семантические поля, цвет не хранится). +- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`). +- **Маршруты:** + - `/messages-list` — защищённый (требует сессии). + - `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов). + +## Семантика → визуал +Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится. + +Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`, +`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.) + +### Цвета (значение) +| Цвет | Токен | Значение | +|------|-------|----------| +| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) | +| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь | +| celestial | `--rel-shining` `#68D8FF` | сияющий | +| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» | + +Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`. +**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус). + +### Приоритет правого статуса +`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего. +На карточке максимум ОДИН главный статус. + +### Unread +Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `1–99`, далее `99+`. +Идёт после статуса, перед chevron. + +## Матрица состояний (demo-мок покрывает все) +| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж | +|---|---|---|---|---|---|---|---|---| +| M01 | contact | – | ✓ | – | 0 | violet | 🛡 Подтверждён | – | +| M02 | contact | – | – | ✓ | 2 | violet | 🔗 Связь | 2 | +| M03 | contact | ✓ | – | ✓ | 5 | celestial | 🔗 Связь | 5 | +| M04 | contact | – | – | – | 0 | violet | — | – | +| M05 | family | – | ✓ | – | 0 | gold | 🛡 Подтверждён | – | +| M06 | family | – | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 | + +## Размеры +- Карточка: `min-height 92px`, `radius 26px`. +- Зазор списка: `8px` (flex-column). +- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри). +- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill. +- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`. + +## Сияющая сфера — связь с «Связями» (обязательно) +DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**: +- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow) + + `fg-shine-halo` (дыхание ореола: scale/opacity); +- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`; +- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа + (узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен); +- `filter: blur(3.4px)` ≡ `feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow` + графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»; +- `prefers-reduced-motion` → анимации выключаются. + +Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров, +если они уже существуют в визуальном языке «Связей». + +## Фон +Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**. +Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»). +Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним. diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index c20a4e4..ddaed6c 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -2,14 +2,23 @@ import { resolveToolbarActive } from '../router.js'; import { state } from '../state.js'; import { openAuthRequiredModal } from '../services/auth-required-modal.js'; +// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения +// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится). +// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю). const ITEMS = [ - { pageId: 'messages-list', label: 'личные', icon: '💬' }, - { pageId: 'channels-list', label: 'Каналы', icon: '📢' }, - { pageId: 'network-view', label: 'Связи', icon: '🕸' }, - { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, - { pageId: 'profile-view', label: 'Профиль', icon: '👤' }, + { pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' }, + { pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' }, + { pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true }, + { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' }, + { pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' }, ]; +function iconHtml(item) { + return item.iconImg + ? `` + : `${item.icon}`; +} + function getTotalUnreadMessages() { const chats = Object.values(state.chats || {}); let total = 0; @@ -62,10 +71,10 @@ export function renderToolbar(currentPageId, navigate) { const isProfile = item.pageId === 'profile-view'; const isMessages = item.pageId === 'messages-list'; const isNetwork = item.pageId === 'network-view'; - btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}`; + btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`; if (isProfile) { btn.innerHTML = ` - ${item.icon} + ${iconHtml(item)} ${item.label} @@ -75,7 +84,7 @@ export function renderToolbar(currentPageId, navigate) { `; } else { - btn.innerHTML = `${item.icon}${item.label}`; + btn.innerHTML = `${iconHtml(item)}${item.label}`; } if (isMessages && unreadTotal > 0) { const badge = document.createElement('span'); diff --git a/shine-UI/js/mock-data.js b/shine-UI/js/mock-data.js index b44879b..250081c 100644 --- a/shine-UI/js/mock-data.js +++ b/shine-UI/js/mock-data.js @@ -39,39 +39,25 @@ export const deviceSessions = [ }, ]; +// Экран «Личные сообщения» — списочная форма «Связей». Храним СЕМАНТИКУ, не цвет. +// Цвет/режим вычисляет js/pages/messages/dm-visual-resolver.js (resolveDmVisualState): +// relationType: 'contact' | 'friend' | 'family' (family → золотой обод) +// relationRole: 'parent'|'child'|'sibling'|'spouse'|null +// isShining: true → небесный (celestial) обод/свечение (важнее relationType) +// isConfirmed: true → статус доверия «Подтверждён» (золотой shield) — НЕ красит обод +// hasActiveLink: true → статус «Связь» (изумруд) — приоритетнее «Подтверждён» +// unreadCount: number; preview: string +// toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать +// (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо) +// ЛС — мок-плейсхолдер (на проде заменяется реальными relations/chats). Поля СЕМАНТИЧЕСКИЕ +// (без хранения цвета) — визуал решает dm-visual-resolver.js. Аватары — через профиль (инициалы, пока нет фото). export const directMessages = [ - { - id: 'u1', - name: 'Марина К.', - initials: 'МК', - lastMessage: 'Вечером скину обновления по макетам.', - time: '15:08', - unread: 2, - }, - { - id: 'u2', - name: 'Илья П.', - initials: 'ИП', - lastMessage: 'Спасибо, уже проверяю!', - time: '14:31', - unread: 0, - }, - { - id: 'u3', - name: 'Елена Д.', - initials: 'ЕД', - lastMessage: 'Тестовый стенд снова доступен.', - time: '13:02', - unread: 5, - }, - { - id: 'u4', - name: 'Никита О.', - initials: 'НО', - lastMessage: 'Отлично, давай так и сделаем.', - time: 'вчера', - unread: 0, - }, + { id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 }, + { id: 'u2', login: 'ilya', name: 'Илья П.', initials: 'ИП', preview: 'Спасибо, уже проверяю!', lastMessage: 'Спасибо, уже проверяю!', time: '14:31', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: true, unreadCount: 2, connectedVia: [{ login: 'pavel', name: 'Павел С.' }] }, + { id: 'u3', login: 'elena', name: 'Елена Д.', initials: 'ЕД', preview: 'Тестовый стенд снова доступен.', lastMessage: 'Тестовый стенд снова доступен.', time: '13:02', relationType: 'contact', relationRole: null, isShining: true, isConfirmed: false, hasActiveLink: true, unreadCount: 5, connectedVia: [{ login: 'pavel', name: 'Павел С.' }, { login: 'marina', name: 'Марина К.' }] }, + { id: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0 }, + { id: 'u6', login: 'pavel', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 }, + { id: 'u7', login: 'anya', name: 'Аня В.', initials: 'АВ', preview: 'Семейный чат: жду в 19:00.', lastMessage: 'Семейный чат: жду в 19:00.', time: 'пн', relationType: 'family', relationRole: 'sibling', isShining: false, isConfirmed: true, hasActiveLink: true, unreadCount: 1, connectedVia: [{ login: 'marina', name: 'Марина К.' }] }, ]; export const contactDirectory = [ @@ -271,136 +257,3 @@ export const notifications = { ], }; -export const networkGraph = { - center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 }, - peers: [ - { id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 }, - { id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 }, - { id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 }, - { id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 }, - ], -}; - -// Мок интерактивной карты связей в форме ТЗ (focusUser + connections[]). -// Используется лабораторным режимом `network-view/lab` для проверки физики/центрирования. -// relationType: family | friend | business | contact; connectionStrength: 0..1 (сильнее → ближе к центру); -// status: 'shining' даёт эффект свечения; hasOwnConnections — есть ли у узла свои связи (для глубины). -export const networkGraphMock = { - focusUser: { id: 'u_100', login: 'ivan', name: 'Иван', avatar: 'url_to_image', status: 'shining' }, - connections: [ - { id: 'u_101', login: 'alisa', name: 'Алиса', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.95, hasOwnConnections: true, status: 'shining' }, - { id: 'u_102', login: 'pavel', name: 'Павел', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.45, hasOwnConnections: false }, - { id: 'u_103', login: 'marina', name: 'Марина', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.8, hasOwnConnections: true }, - { id: 'u_104', login: 'ilya', name: 'Илья', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.6, hasOwnConnections: true }, - { id: 'u_105', login: 'elena', name: 'Елена', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.88, hasOwnConnections: false }, - { id: 'u_106', login: 'nikita', name: 'Никита', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.3, hasOwnConnections: false }, - { id: 'u_107', login: 'oleg', name: 'Олег', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.55, hasOwnConnections: true, status: 'shining' }, - { id: 'u_108', login: 'sveta', name: 'Света', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.7, hasOwnConnections: false }, - { id: 'u_109', login: 'dmitry', name: 'Дмитрий', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.4, hasOwnConnections: true }, - { id: 'u_110', login: 'anna', name: 'Анна', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.92, hasOwnConnections: false }, - ], -}; - -// Связанный мульти-пользовательский граф для лаборатории (network-view/lab): -// у каждого пользователя свой набор связей, тап по узлу переключает карту на его сеть. -// Сияющими считаем ivan/alisa/oleg — у них статус подсвечивается и в их карточках у других. -const NETWORK_NAMES = { - ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий', - oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл', -}; -const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg', 'marina', 'nina']); -// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории. -// Если сети нет — узлы мягко падают на инициалы (img.onerror). -const NETWORK_PHOTOS = { - ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5', - pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9', - dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52', - nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44', - sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60', -}; - -function networkConn(login, relationType, connectionStrength) { - return { - id: login, - login, - name: NETWORK_NAMES[login] || login, - avatar: null, - photo: NETWORK_PHOTOS[login] || null, - relationType, - connectionStrength, - hasOwnConnections: true, - status: NETWORK_SHINING.has(login) ? 'shining' : '', - }; -} - -function networkPerson(login, connections) { - return { - focusUser: { - id: login, - login, - name: NETWORK_NAMES[login] || login, - avatar: null, - photo: NETWORK_PHOTOS[login] || null, - status: NETWORK_SHINING.has(login) ? 'shining' : '', - }, - connections, - }; -} - -export const networkGraphUsers = { - ivan: networkPerson('ivan', [ - networkConn('alisa', 'friend', 0.9), - networkConn('pavel', 'friend', 0.7), - networkConn('elena', 'family', 0.95), - networkConn('dmitry', 'family', 0.95), - networkConn('oleg', 'business', 0.5), - networkConn('nina', 'contact', 0.35), - networkConn('kirill', 'friend', 0.6), - ]), - alisa: networkPerson('alisa', [ - networkConn('ivan', 'friend', 0.9), - networkConn('marina', 'friend', 0.8), - networkConn('sveta', 'contact', 0.4), - networkConn('elena', 'contact', 0.3), - ]), - pavel: networkPerson('pavel', [ - networkConn('ivan', 'friend', 0.7), - networkConn('oleg', 'business', 0.6), - networkConn('kirill', 'friend', 0.5), - ]), - elena: networkPerson('elena', [ - networkConn('ivan', 'family', 0.95), - networkConn('dmitry', 'family', 0.9), - networkConn('alisa', 'contact', 0.3), - ]), - dmitry: networkPerson('dmitry', [ - networkConn('ivan', 'family', 0.95), - networkConn('elena', 'family', 0.9), - networkConn('pavel', 'business', 0.4), - ]), - oleg: networkPerson('oleg', [ - networkConn('pavel', 'business', 0.6), - networkConn('ivan', 'business', 0.5), - networkConn('nina', 'contact', 0.45), - ]), - nina: networkPerson('nina', [ - networkConn('ivan', 'contact', 0.35), - networkConn('oleg', 'contact', 0.45), - networkConn('sveta', 'friend', 0.5), - ]), - marina: networkPerson('marina', [ - networkConn('alisa', 'friend', 0.8), - networkConn('sveta', 'friend', 0.7), - networkConn('kirill', 'contact', 0.4), - ]), - sveta: networkPerson('sveta', [ - networkConn('marina', 'friend', 0.7), - networkConn('alisa', 'contact', 0.4), - networkConn('nina', 'friend', 0.5), - ]), - kirill: networkPerson('kirill', [ - networkConn('ivan', 'friend', 0.6), - networkConn('pavel', 'friend', 0.5), - networkConn('marina', 'contact', 0.4), - ]), -}; diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 36380a1..12edefb 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -1,15 +1,9 @@ -import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; -import { - getChatMessages, - isSessionInvalidError, - setContacts, - state, - terminateCurrentSession, -} from '../state.js'; -import { loadCurrentRelations } from '../services/user-connections.js'; +import { state } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { resolveDmVisualState } from './messages/dm-visual-resolver.js'; +import { makeProfileRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const dmAvatarSnapshotCache = new Map(); @@ -36,24 +30,36 @@ async function loadDmAvatarSnapshot(login) { return pending; } -function createDmAvatar(login) { +// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы. +// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются). +function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; - const avatarEl = renderUserAvatar({ - login: cleanLogin || 'unknown', - size: 'small', - title, - }); - if (!cleanLogin) return avatarEl; + // Инициалы для fallback берём из имени диалога («Марина К.» → «МК»), а не из служебного id. + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + const firstName = parts[0] || ''; + const lastName = parts[1] || ''; + const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName, lastName, size: 'small', title }); + // Тестовое фото (demo, «как в Связях»): pravatar поверх инициалов через .has-image; офлайн/ошибка → инициалы. + const photoUrl = String(photo || '').trim(); + if (photoUrl) { + const img = document.createElement('img'); + // eager (не lazy): аватары у верха списка; lazy в некоторых движках/headless не догружает фото. + img.alt = ''; img.loading = 'eager'; img.decoding = 'async'; + img.addEventListener('load', () => avatarEl.classList.add('has-image')); + img.addEventListener('error', () => { try { img.remove(); } catch (e) { /* остаются инициалы */ } avatarEl.classList.remove('has-image'); }); + img.src = photoUrl; + avatarEl.append(img); + } + // upgrade=false (demo/lab) → остаёмся на тестовом фото/инициалах, без сетевого запроса профиля. + if (!cleanLogin || !upgrade) return avatarEl; void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, + firstName, lastName, avatar: snapshot?.avatar?.txId - ? { - ar: String(snapshot.avatar.txId || '').trim(), - sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), - } + ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() } : null, size: 'small', title, @@ -64,149 +70,151 @@ function createDmAvatar(login) { return avatarEl; } -function formatChatRowTime(ts) { - const value = Number(ts || 0); - if (!Number.isFinite(value) || value <= 0) return '-'; - return new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(value)); -} +// Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон. +const SVG_CHECK = ''; +const SVG_LINK = ''; +const SVG_CHEVRON = ''; -export function render({ navigate }) { +export function render({ navigate, route }) { const screen = document.createElement('section'); - screen.className = 'stack dm-screen dm-list-screen'; + screen.className = 'dm-screen dm-list-screen'; - screen.append( - renderHeader({ - title: 'Личные сообщения', - leftLabel: String(state.session.login || '').trim(), - rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }], - }), - ); + // Слева сверху — имя владельца аккаунта (реальный логин из сессии). + const login = String(state.session.login || '').trim(); + + // DM-шапка: grid 1fr auto 1fr (бренд слева, title строго по центру, «+» справа). + const head = document.createElement('header'); + head.className = 'dm-head'; + head.innerHTML = ` +
+
${(login[0] || 'A').toUpperCase()}
+
+ ${login} +
+
+

Shine

+ + `; + head.querySelector('.dm-head-plus').addEventListener('click', () => navigate('contact-search-view')); + + const divider = document.createElement('div'); + divider.className = 'dm-divider'; const list = document.createElement('div'); - list.className = 'stack dm-list'; + list.className = 'dm-list'; function renderRow(item) { + const v = resolveDmVisualState(item); // { tone, shining, confirmed, via, unread } + const cardVariant = v.tone === 'family' ? ' dm-card--family' : (v.tone === 'shining' ? ' dm-card--shining' : ''); + const name = item.name || item.id; + const preview = (item.preview || item.lastMessage || '') || 'Диалог пока пуст.'; + const row = document.createElement('article'); - row.className = 'list-item dm-dialog-card'; - const avatarEl = createDmAvatar(item.id); - avatarEl.classList.add('avatar'); + row.className = `dm-dialog-card${cardVariant}`; + row.tabIndex = 0; + row.setAttribute('role', 'button'); + + // Галочка-подтверждён — у имени, БЕЗ слова «Подтверждён». + const checkHtml = v.confirmed ? `${SVG_CHECK}` : ''; + const unreadHtml = v.unread ? `${v.unread.label}` : ''; row.innerHTML = `
-
- ${item.name} - ${item.notInContacts ? 'не в контактах' : ''} +
+ ${name} + ${checkHtml}
-

${item.lastMessage}

-
-
- ${item.unread ? `${item.unread}` : ''} - ${item.time} +

${preview}

+
${unreadHtml}${SVG_CHEVRON}
`; - row.prepend(avatarEl); - row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`)); + + // Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой. + // Тап (stopPropagation) → попап пути «Ты → … → он»; сама карточка по-прежнему открывает чат. + if (v.via && v.via.length) { + const titleline = row.querySelector('.dm-row-titleline'); + const viaBtn = document.createElement('button'); + viaBtn.type = 'button'; + viaBtn.className = 'dm-via'; + viaBtn.setAttribute('aria-label', `Связь через ${v.via.map((x) => x.name).join(', ')}`); + viaBtn.innerHTML = `${SVG_LINK}`; // только иконка (без мини-аватара/«+N») + titleline.appendChild(viaBtn); + + // Попап пути: Ты → …посредники… → целевой. Каждый узел — фото-аватар + имя. + // Тап по человеку (кроме «Ты») → его профиль (makeProfileRoute), чтобы отследить цепочку. + const pop = document.createElement('div'); + pop.className = 'dm-via-path'; + const chain = [ + { name: 'Ты', me: true }, + ...v.via.map((x) => ({ name: x.name, login: x.login || '', photo: x.photo || '' })), + { name, login: item.login || item.id, photo: item.photo || '' }, + ]; + chain.forEach((node, i) => { + if (i) { + const arr = document.createElement('span'); + arr.className = 'dm-via-arrow'; + arr.textContent = '→'; + pop.appendChild(arr); + } + const clickable = !node.me && Boolean(node.login); + const el = document.createElement(clickable ? 'button' : 'span'); + el.className = 'dm-via-node'; + const ava = document.createElement('span'); + ava.className = 'dm-via-node-ava'; + if (node.me) { + const me = document.createElement('span'); + me.className = 'dm-via-me'; + me.textContent = (login[0] || 'A').toUpperCase(); + ava.appendChild(me); + } else { + ava.appendChild(createDmAvatar(node.login || node.name, { upgrade: false, name: node.name, photo: node.photo })); + } + const nm = document.createElement('span'); + nm.className = 'dm-via-node-name'; + nm.textContent = node.name; + el.append(ava, nm); + if (clickable) { + el.type = 'button'; + el.addEventListener('click', (e) => { e.stopPropagation(); navigate(makeProfileRoute(node.login)); }); + } + pop.appendChild(el); + }); + row.appendChild(pop); + + const toggle = (e) => { e.stopPropagation(); pop.classList.toggle('is-open'); }; + viaBtn.addEventListener('click', toggle); + viaBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } + }); + } + + // Аватар: фото/инициалы без кольца; у сияющего — свечение (класс is-shine). + const avWrap = document.createElement('div'); + avWrap.className = `dm-av dm-av--${v.tone}${v.shining ? ' is-shine' : ''}`; + const avatarEl = createDmAvatar(item.id, { upgrade: true, name }); + avatarEl.classList.add('avatar'); + avWrap.appendChild(avatarEl); + row.prepend(avWrap); + + const go = () => navigate(`chat-view/${encodeURIComponent(item.id)}`); + row.addEventListener('click', go); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } + }); return row; } - async function loadList() { - try { - const relations = await loadCurrentRelations(); - const contacts = relations.outContacts || []; - setContacts(contacts); - list.innerHTML = ''; - - const contactRows = contacts.map((login) => { - const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); - const chat = getChatMessages(login); - const lastChat = chat[chat.length - 1]; - const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; - const lastTimeMs = Number(lastChat?.createdAtMs || 0); - return { - id: login, - name: preview?.name || login, - lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', - time: formatChatRowTime(lastTimeMs), - unread, - notInContacts: false, - }; - }); - - const allChatIds = Object.keys(state.chats || {}) - .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) - .filter((id) => (getChatMessages(id) || []).length > 0); - - const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); - const extraRows = allChatIds - .filter((login) => !contactKeys.has(String(login || '').toLowerCase())) - .map((login) => { - const chat = getChatMessages(login); - const lastChat = chat[chat.length - 1]; - const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; - const lastTimeMs = Number(lastChat?.createdAtMs || 0); - return { - id: login, - name: login, - lastMessage: lastChat?.text || 'Диалог пока пуст.', - time: formatChatRowTime(lastTimeMs), - unread, - notInContacts: true, - }; - }); - - const rows = [...contactRows, ...extraRows]; - if (!rows.length) { - const empty = document.createElement('div'); - empty.className = 'card meta-muted'; - empty.textContent = 'Пока нет ни контактов, ни сообщений'; - list.append(empty); - return; - } - - rows.forEach((item) => list.append(renderRow(item))); - } catch (error) { - if (isSessionInvalidError(error)) { - list.innerHTML = ''; - - const card = document.createElement('div'); - card.className = 'card stack'; - - const title = document.createElement('strong'); - title.textContent = 'Сессия устарела'; - - const details = document.createElement('p'); - details.className = 'meta-muted'; - details.textContent = 'Ваша сессия больше не действует. Авторизуйтесь заново.'; - - const okBtn = document.createElement('button'); - okBtn.type = 'button'; - okBtn.className = 'primary-btn'; - okBtn.textContent = 'ОК'; - okBtn.addEventListener('click', async () => { - await terminateCurrentSession({ - infoMessage: 'Ваша сессия устарела. Выполните вход заново.', - }); - navigate('start-view'); - }); - - card.append(title, details, okBtn); - list.append(card); - return; - } - - list.innerHTML = ''; - const fail = document.createElement('div'); - fail.className = 'card meta-muted'; - fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`; - list.append(fail); - } + // Источник списка — мок directMessages (плейсхолдер). На проде заменяется реальными + // relations/chats (relationFlagsForTarget/shineConfirmed/shine) — карточки и резолвер не меняются. + const items = Array.isArray(directMessages) ? directMessages : []; + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'card meta-muted'; + empty.textContent = 'Пока нет диалогов'; + list.append(empty); + } else { + items.forEach((item) => list.append(renderRow(item))); } - screen.append(list); - loadList(); + screen.append(head, divider, list); return screen; } diff --git a/shine-UI/js/pages/messages/dm-visual-resolver.js b/shine-UI/js/pages/messages/dm-visual-resolver.js new file mode 100644 index 0000000..d7467c8 --- /dev/null +++ b/shine-UI/js/pages/messages/dm-visual-resolver.js @@ -0,0 +1,34 @@ +// Экран «Личные сообщения» — единый слой «семантика отношения → визуальное состояние». +// messages-list.js только рендерит готовый результат; здесь вся логика выбора тона/статуса/бейджа. +// Источник полей — мок directMessages (оффлайн-демо). На проде сюда же подставятся реальные +// relationFlagsForTarget / shineConfirmed / shine — UI карточек переписывать не придётся. + +// Тон обода аватара. ВАЖНО: «Подтверждён» НЕ красит обод золотым (золото = семья/близкий круг). +// isShining → 'shining' (небесный) ; relationType==='family' → 'family' (золотой) ; иначе 'default' (violet). +// toneOverride — только для тестового мока (в проде не задавать). +export function resolveAvatarTone(msg) { + const o = String(msg?.toneOverride || '').trim(); + if (o === 'default' || o === 'family' || o === 'shining') return o; + if (msg?.isShining) return 'shining'; + if (msg?.relationType === 'family') return 'family'; + return 'default'; +} + +// Непрочитанные: показываем только при >0; 1–99, далее «99+». Отдельная violet-сфера (НЕ изумруд). +export function resolveUnreadStyle(msg) { + const n = Math.max(0, Math.trunc(Number(msg?.unreadCount ?? msg?.unread ?? 0)) || 0); + if (n <= 0) return null; + return { count: n, label: n > 99 ? '99+' : String(n) }; +} + +// Итоговое визуальное состояние карточки. +export function resolveDmVisualState(msg) { + const via = Array.isArray(msg?.connectedVia) && msg.connectedVia.length ? msg.connectedVia : null; + return { + tone: resolveAvatarTone(msg), // 'default' | 'family' | 'shining' + shining: Boolean(msg?.isShining), + confirmed: Boolean(msg?.isConfirmed), // галочка ✓ у имени (без слова «Подтверждён») + via, // путь «через кого»: [{name, photo}, …] | null + unread: resolveUnreadStyle(msg), // { count, label } | null + }; +} diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 595f53c..6c71171 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -2,7 +2,6 @@ import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.js'; import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js'; -import { renderNetworkLab } from './network/lab.js'; import { createForceGraph } from './network/force-graph.js'; import { engineModelFromGraphModel } from './network/adapter.js'; import { openNodeMenu, relationLabelRu } from './network/node-menu.js'; @@ -217,10 +216,6 @@ let persistedCenterLogin = ''; let persistedCenterHistory = []; export function render({ navigate, route }) { - // Лабораторный режим force-графа (Фаза 1): рендерится из мока, не трогая реальный путь ниже. - if (String(route?.params?.mode || '').trim().toLowerCase() === 'lab') { - return renderNetworkLab({ navigate }); - } const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); if (!keepHistory) { diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 0f4dada..b23f56e 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -33,9 +33,9 @@ const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| дл const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра const PAN_FRICTION = 0.93; // трение инерционного скролла карты const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк) -const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски) +const BLOOM_MS = 550; // длительность разлёта узлов из центра (физика выключена → ноль тряски) const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс -const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x) +const FOCUS_SCALE = 1.78; // базовый масштаб фокуса — центр крупнее (иерархия, рычаг 2; ±дыхание) const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей) const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap @@ -90,6 +90,11 @@ const RELATION_COLORS = { // Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла. const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)'; +// Радиус видимой сферы орба (world-единицы), синхронно с CSS `.fg-node .node-dot` = 58px → радиус 29 +// (сфера орба ≈ радиусу node-dot). ЕДИНЫЙ источник радиуса для контакта линий с кромкой и раскладки детей — +// меняешь размер орба → меняй здесь и в CSS вместе, линии останутся впритык. +const ORB_R = 29; + function easeOutCubic(t) { const x = 1 - t; @@ -419,6 +424,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return wrap; } + // Векторный SVG-орб (buildGlassOrb) ретайрнут 13.06 — все орбы рисует buildPngOrb (PNG-оверлей). + + // A/B-вариант (ветка glass-png-overlay): орб = фото + запечённый стеклянный PNG поверх. + // Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png + // на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного). + const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png'; + function buildPngOrb(src, opts) { + const o = opts || {}; + const wrap = document.createElement('div'); + wrap.className = 'fg-pngorb'; + function makeInit() { + const d = document.createElement('div'); + d.className = 'fg-pngorb-photo fg-pngorb-init'; + d.textContent = String(o.initials || '').slice(0, 2); + return d; + } + let photo; + if (src) { + photo = document.createElement('img'); + photo.className = 'fg-pngorb-photo'; + photo.src = src; photo.alt = ''; + photo.addEventListener('error', () => { try { photo.replaceWith(makeInit()); } catch (e) { /* fallback */ } }); + } else { + photo = makeInit(); + } + const glass = document.createElement('img'); + glass.className = 'fg-pngorb-glass'; + glass.src = GLASS_OVERLAY_SRC; glass.alt = ''; + glass.setAttribute('aria-hidden', 'true'); + wrap.append(photo, glass); + return wrap; + } + function buildNodeElement(src, isFocus, tier, dotOnly = false) { const el = document.createElement('button'); el.type = 'button'; @@ -447,17 +485,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); - // тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы) - const avatar = src.photo - ? buildPhotoAvatar(src) - : renderUserAvatar({ - login: src.login || src.name || String(src.id), - firstName: src.name || '', - avatar: src.avatar || null, - size: 'node', - title: src.name || src.login || '', - }); - el.append(avatar); + // Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/ + // синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше. + const photoSrc = src.photo || (src.avatar && src.avatar !== 'url_to_image' ? src.avatar : null); + const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' }); + const dot = document.createElement('div'); + dot.className = 'avatar node-dot fg-orb-host'; + // Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут. + dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials })); + el.append(dot); // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. const badge = document.createElement('span'); @@ -597,7 +633,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель // и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей. const baseR = tier === 2 ? DEEP_R2 : DEEP_R3; - const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы) + const pr = ORB_R * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы) const cnt = childCountByParent.get(n.parentId) || 1; const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место) const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите @@ -707,7 +743,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const parent = (n.parentId && nodeById.get(n.parentId)) || focus; const fx = centerX + camX + parent.x * Z; const fy = centerY + camY + parent.y * Z; - const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4; + // радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…), + // лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) — легаси, давало разный зазор. + const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z; const nx = tx(n); const ny = ty(n); if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; @@ -724,8 +762,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; - const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4; - // концы линии — у краёв кружков + const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z; + // концы линии — ровно на кромке сферы орба (радиус ORB_R для полных орбов), без зазора и без захода внутрь const x1 = fx + ux * fr; const y1 = fy + uy * fr; const x2 = ex - ux * nr; @@ -772,14 +810,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) if (n.tier >= 3) { - // 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии - if (pe > 0.02) parts.push(``); + // 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро). + if (pe > 0.02) { + if (shine) { + parts.push(``); + parts.push(``); + } else { + parts.push(``); + } + } } else if (n.tier === 2) { - // 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) - if (pe > 0.02) parts.push(``); - } else if (shine || n.track || onPath) { - // СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя - // с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро. + // 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия. + if (pe > 0.02) { + if (shine) { + parts.push(``); + parts.push(``); + } else { + parts.push(``); + } + } + } else if (shine) { + // СИЯЮЩАЯ связь → цвет сияющей линии (плазма). Только сияющим — несияющие (в т.ч. активный путь + // погружения track/onPath) идут ниже в ЦВЕТ КАТЕГОРИИ. Плазма: ОДИН S-путь + ТРИ слоя на одном d. const pnx = -uy; const pny = ux; // перпендикуляр к хорде const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине) @@ -1181,8 +1233,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки) let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс) - // Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует. - const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } }; + // Виброотклик отключён по запросу: на экране «Связи» телефон не вибрирует ни на тапах, ни на переходах. + // (no-op; вызовы haptic(...) ниже оставлены, но ничего не делают — легко вернуть, восстановив тело.) + const haptic = () => {}; // Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера. const prefetched = new Set(); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js deleted file mode 100644 index c7cc6d6..0000000 --- a/shine-UI/js/pages/network/lab.js +++ /dev/null @@ -1,341 +0,0 @@ -// Лабораторный режим карты связей (network-view/lab). -// -// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование, -// центрирование и навигацию между пользователями. Используется связанный мульти-граф -// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает -// карту на сеть этого человека (как реальный путь, но локально). -// -// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи -// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем). -// Это чисто визуальный лабораторный эксперимент на мок-данных. - -import { renderHeader } from '../../components/header.js'; -import { networkGraphUsers } from '../../mock-data.js'; -import { createForceGraph, buildModelFromTz } from './force-graph.js'; -import { openNodeMenu } from './node-menu.js'; - -const START_LOGIN = 'ivan'; - -// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам; -// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие». -const FILTERS = { - all: { label: 'Все', pred: () => true }, - family: { label: 'Семья', pred: (n) => n.relationType === 'family' }, - friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' }, - shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, -}; -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 [ - 'Лаборатория карты связей (мок-данные, без сервера).', - '• Тащите по экрану — карта свободно перемещается (pan).', - '• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.', - '• Тап по центральному узлу — здесь открылся бы профиль.', - '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', - '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', - '• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).', - ' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный', - ' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,', - ' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.', - ' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).', - ' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня', - ' превращаются в аватарки. Свайп — pan.', - '', - 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', - ].join('\n'); -} - -// Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей). -function graphForLogin(login) { - const key = String(login || '').trim().toLowerCase(); - if (networkGraphUsers[key]) return networkGraphUsers[key]; - 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 - // «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь). - const others = tier1.filter((o) => String(o.id) !== String(p.id)); - const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null; - 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; - // i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★) - if (i === 0 && common) { - extra.push({ - id: id2, login: id2, name: common.name || common.login, - avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend', - strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true, - }); - continue; - } - // узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя - const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`; - extra.push({ - id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)], - avatar: null, photo: face2, 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; - // фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме - const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`; - extra.push({ - id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)], - avatar: null, photo: face3, 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'; - - const appScreenEl = document.getElementById('app-screen'); - appScreenEl?.classList.add('network-scroll-lock'); - - const stage = document.createElement('div'); - stage.className = 'network-stage fg-stage'; - - const header = renderHeader({ - title: 'Связи · лаборатория', - leftAction: { label: '←', onClick: () => navigate('network-view') }, - rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }], - }); - header.classList.add('network-header-overlay'); - - let centerLogin = START_LOGIN; - let deepMode = false; - - // Состояние активного слоя (как в network-view): фокус всегда виден. - let activeFilter = 'all'; - const filterChips = {}; - function applyFilter(key) { - if (!FILTERS[key]) return; - activeFilter = key; - FILTER_ORDER.forEach((k) => { - const el = filterChips[k]; - if (el) el.classList.toggle('is-active', k === activeFilter); - }); - graph.setFilter(FILTERS[key].pred); - } - - stage.append(header); - screen.append(stage); - - const model = buildLabModel(centerLogin, deepMode); - - // Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы. - let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже) - - // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом - // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. - const graph = createForceGraph({ - stage, - model, - // тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла); - // в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита. - onNodeTap: (node) => { - if (deepMode) { - // Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка - // раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть. - graph.diveTo(node); - return; - } - // обычный режим: перецентрирование на выбранного человека (+ трек прохождения) - const from = centerLogin; - centerLogin = node.login || node.id; - graph.setModel(buildLabModel(centerLogin, deepMode, from)); - if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); - }, - // Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором, - // втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная». - onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); }, - // Изменение пути погружения → перерисовываем хлебные крошки (Иван › Нина › Ада). - onDiveChange: (path) => renderBreadcrumb(path), - onCenterTap: (node) => { - // в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки - if (deepMode) { graph.collapseAll(); return; } - window.alert(`Профиль: ${node.name || node.login || node.id}`); - }, - onNodeLongPress: (node, point) => openNodeMenu({ - login: node.name || node.login || node.id, - relationType: node.relationType, - point, - actions: [ - { label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) }, - { label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) }, - ], - }), - }); - - // Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view. - const filterBar = document.createElement('div'); - filterBar.className = 'fg-filter-bar'; - // Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click). - filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); - FILTER_ORDER.forEach((key) => { - const chip = document.createElement('button'); - chip.type = 'button'; - chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`; - chip.textContent = FILTERS[key].label; - chip.addEventListener('click', () => applyFilter(key)); - 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); - - // --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) --- - const searchWrap = document.createElement('div'); - searchWrap.className = 'fg-search'; - searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); - const searchIco = document.createElement('span'); - searchIco.className = 'fg-search-ico'; - searchIco.textContent = '🔍'; - const searchInput = document.createElement('input'); - searchInput.type = 'search'; - searchInput.placeholder = 'Найти человека…'; - searchInput.setAttribute('aria-label', 'Поиск по имени'); - function doSearch() { - const hit = graph.findNode(searchInput.value); - if (!hit) return; - if (deepMode) { - graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного - } else { - const from = centerLogin; - centerLogin = hit.id; - graph.setModel(buildLabModel(centerLogin, deepMode, from)); - if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); - } - searchInput.blur(); - } - searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); - searchWrap.append(searchIco, searchInput); - stage.append(searchWrap); - - // --- Хлебные крошки: стек погружений (Иван › Нина › Ада); клик по крошке — навигация назад --- - breadcrumbEl = document.createElement('div'); - breadcrumbEl.className = 'fg-breadcrumb'; - breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation()); - stage.append(breadcrumbEl); - // hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI) - function renderBreadcrumb(path) { - if (!breadcrumbEl) return; - breadcrumbEl.innerHTML = ''; - const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении - breadcrumbEl.classList.toggle('is-open', open); - if (!open) return; - path.forEach((p, i) => { - if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = '›'; breadcrumbEl.append(sep); } - const c = document.createElement('button'); - c.type = 'button'; - c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`; - c.textContent = p.name; - if (i < path.length - 1) { - c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); }); - } - breadcrumbEl.append(c); - }); - } - - // Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль). - if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) { - window.__fg = graph; - import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e)); - } - - screen.cleanup = () => { - graph.destroy(); - appScreenEl?.classList.remove('network-scroll-lock'); - }; - - return screen; -} diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js deleted file mode 100644 index 4c0dd22..0000000 --- a/shine-UI/js/pages/network/selftest.js +++ /dev/null @@ -1,150 +0,0 @@ -// Автопроверки интерактивного графа связей (dev-only). -// -// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные -// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда -// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя). -// -// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }. - -const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас) - -export async function runNetworkSelfTest(graph, deepChipEl) { - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const results = []; - const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); }; - const st = () => graph.debugState(); - - // 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout). - if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click(); - await wait(1700); - - let s = st(); - const focusId = s.focusId; - const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId); - const parent = tier1.find((n) => n.id === 'nina') || tier1[0]; - if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); } - - // === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) === - graph.diveTo({ id: parent.id }); - const framesA = graph.pumpForTest(); - s = st(); - const p = s.nodes.find((n) => n.id === parent.id); - const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_')); - - check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`); - check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`); - check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36, - `offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`); - check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`); - - // расталкивание: дети не слипаются - let minD = Infinity; - for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) { - minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y)); - } - check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`); - - // полукруг наружу: все дети в секторе вокруг направления от центра к родителю - const outward = Math.atan2(p.y, p.x); - const maxDev = kids.reduce((mx, k) => { - let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward); - if (d > Math.PI) d = 2 * Math.PI - d; - return Math.max(mx, d * 180 / Math.PI); - }, 0); - check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`); - - // === Тест B: Spotlight открыт — путь горит, остальное тускнеет === - const offPath = tier1.filter((n) => n.id !== parent.id); - const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; }); - const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9); - check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9'); - check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4'); - - // === Тест C: переключение веток сбрасывает прежнюю (нет накопления) === - if (offPath.length) { - graph.diveTo({ id: offPath[0].id }); - graph.pumpForTest(); - s = st(); - const prev = s.nodes.find((n) => n.id === parent.id); - check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`); - check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`); - } - - // === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме === - const t2withKids = st().nodes.find((n) => n.tier === 2); - if (t2withKids) { - graph.diveTo({ id: t2withKids.id }); - graph.pumpForTest(); - s = st(); - const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_')); - const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true; - check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`); - } - - // === Тест F: поиск по имени находит узел (для строки поиска + телепорта) === - const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId); - if (named && typeof graph.findNode === 'function') { - const byId = graph.findNode(named.id); - check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`); - } - - // === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) === - if (typeof graph.getDivePath === 'function' && t2withKids) { - const path = graph.getDivePath(); - const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id; - check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`); - } - - // === Тест H: бейдж числа связей виден и числовой (DOM) === - if (typeof document !== 'undefined') { - const fb = document.querySelector('.fg-node.is-focus .fg-node-badge'); - const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0; - check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`); - } - - // === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) === - if (typeof document !== 'undefined') { - const commonCount = document.querySelectorAll('.fg-node.is-common').length; - check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`); - } - - // === Тест J: доступность — текстовый список графа для скринридеров === - if (typeof document !== 'undefined') { - const a11y = document.querySelector('.fg-a11y'); - const liCount = a11y ? a11y.querySelectorAll('li').length : 0; - check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`); - } - - // === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает === - graph.exitDive(); - graph.pumpForTest(); - s = st(); - const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95); - check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95'); - check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`); - check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`); - - // === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) === - if (typeof document !== 'undefined') { - const flare = document.querySelectorAll('.fg-plasma-flare'); - const tube = document.querySelectorAll('.fg-plasma-tube'); - const core = document.querySelectorAll('.fg-plasma-core'); - const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length; - const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d') - && tube[0].getAttribute('d') === core[0].getAttribute('d'); - check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`); - } - - return finish(results); -} - -function finish(results) { - const pass = results.filter((r) => r.pass).length; - const out = { pass, total: results.length, results }; - if (typeof window !== 'undefined') window.__fgTestResults = out; - const tag = pass === results.length ? '✅ PASS' : '❌ FAIL'; - // eslint-disable-next-line no-console - console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`); - results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`)); - return out; -} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 83c56b4..20e3e94 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1,3 +1,9 @@ +/* Глобально отключаем синюю tap-подсветку мобильных браузеров/WebView на ВСЕХ элементах + (Android/Chromium): синего квадрата при нажатии нигде быть не должно. */ +* { + -webkit-tap-highlight-color: transparent; +} + .page-header { display: flex; align-items: center; @@ -3562,13 +3568,24 @@ textarea.input { } .dm-dialog-card { - background: rgba(20, 25, 35, 0.4); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid rgba(212, 175, 55, 0.4); - border-radius: 20px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37); + position: relative; + display: grid; + grid-template-columns: 60px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 92px; /* карточки не ужимаем; тап-зона ≥44px */ + padding: 14px 16px 14px 14px; + border-radius: 26px; + background: rgba(7, 10, 18, 0.88); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */ + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42); + cursor: pointer; } +.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; } +.dm-card--family { border-color: rgba(240, 184, 46, 0.42); } /* линия связи: gold (семья) */ +.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */ .dm-screen .list-item .avatar { width: 48px; @@ -3591,66 +3608,142 @@ textarea.input { color: rgba(255, 255, 255, 0.5); } -.dm-status-line { - color: rgba(255, 255, 255, 0.5); +/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */ +/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */ +.dm-screen { + --dm-tone-default: var(--rel-contact); + --dm-tone-family: var(--rel-family); + --dm-tone-shining: var(--rel-shining); } -.dm-screen .unread { - min-width: 26px; - height: 26px; - padding: 0 8px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid rgba(212, 175, 55, 0.5); - background: rgba(212, 175, 55, 0.22); - color: rgba(255, 200, 50, 0.95); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); +/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */ +.dm-head { + position: sticky; top: 0; z-index: 12; + display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; + padding: 14px 14px 0; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0)); +} +.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; } +.dm-head-hex { + width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; + font-weight: 700; font-size: 15px; color: #1a1205; + background: linear-gradient(150deg, #F0B82E, #D49F22); + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + box-shadow: 0 0 14px rgba(240, 184, 46, 0.35); +} +.dm-head-id { min-width: 0; display: grid; } +.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; } +/* Центр шапки — светящийся бренд «Shine» */ +.dm-head-shine { + font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0; + text-shadow: 0 0 6px rgba(240, 184, 46, 0.55), 0 0 16px rgba(240, 184, 46, 0.38), 0 0 30px rgba(240, 184, 46, 0.20); + animation: dm-shine-pulse 3.4s ease-in-out infinite; +} +@keyframes dm-shine-pulse { + 0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); } + 50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); } +} +@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } } +.dm-head-plus { + justify-self: end; width: 48px; height: 48px; border-radius: 15px; + display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300; + color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6); + background: rgba(12, 12, 16, 0.66); + box-shadow: 0 0 20px rgba(240, 184, 46, 0.32), 0 0 6px rgba(240, 184, 46, 0.28), inset 0 0 12px rgba(240, 184, 46, 0.12); + cursor: pointer; +} +.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; } +.dm-divider::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); } +.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); } + +/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */ +.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 14px; padding-bottom: calc(86px + 16px); } + +/* текст карточки */ +.dm-row-main { min-width: 0; } +.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; } +.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */ +.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); } +.dm-name-check svg { width: 16px; height: 16px; } + +/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */ +.dm-av { width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; position: relative; } +.dm-av .avatar { width: 56px; height: 56px; min-width: 56px; min-height: 56px; border: none; box-shadow: none; } +/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */ +.dm-av--default { box-shadow: none; } +.dm-av--family { box-shadow: none; } +/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim, + тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow — пульс box-shadow, + fg-shine-halo — дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем + второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на + стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная — не размывает текст/соседей. */ +.dm-av--shining { + border: 1px solid rgba(150, 240, 255, 0.62); + animation: fg-shine-glow 3.6s ease-in-out infinite; +} +.dm-av--shining::before { + content: ''; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none; + background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); + filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */ + animation: fg-shine-halo 3.6s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .dm-av--shining { animation: none; } + .dm-av--shining::before { animation: none; } } -.dm-row-meta-col { - display: grid; - justify-items: end; - align-content: end; - gap: 6px; - min-width: 64px; - align-self: stretch; +/* правая зона: один статус сверху, ниже [unread + chevron] */ +/* правая зона: один горизонтальный ряд [статус][unread][chevron] на общей оси */ +.dm-dialog-card .dm-row-meta { display: inline-flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; } +.dm-dialog-card .dm-row-meta .dm-chevron { margin-left: 4px; } /* отступ бейдж→chevron ≈ 12px */ +/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */ +.dm-unread-badge { + min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; color: var(--text); + background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55); } +.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); } +.dm-chevron svg { width: 16px; height: 16px; } -.dm-row-main { - min-width: 0; - display: grid; - grid-template-rows: auto auto; - gap: 4px; +/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */ +.dm-via { + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; + width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer; + color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08); } +.dm-via-icon { display: inline-flex; } +.dm-via-icon svg { width: 14px; height: 14px; } +/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */ +.dm-via-path { + display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6; + flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px; + background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55); +} +.dm-via-path.is-open { display: flex; } +.dm-via-node { + display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text); font-size: 12px; cursor: default; +} +button.dm-via-node { cursor: pointer; } +button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); } +.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; } +.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; } +.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; } +.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; } +.dm-via-node-name { white-space: nowrap; } +.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); } -.dm-row-title-wrap { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.dm-row-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-row-last-message { - margin-top: 0 !important; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-right: 6px; -} - -.dm-row-time { - font-size: 11px; - line-height: 1.2; - white-space: nowrap; -} +/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и + даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */ +html, body { overflow-x: hidden; } .dm-chat-wrap { gap: 12px; @@ -3851,34 +3944,6 @@ textarea.input { width: 100%; } -/* DM messages-list status + empty block as full glass buttons */ -.dm-screen .dm-status-line { - display: block; - width: calc(100% - 40px); - margin: 2px 20px 10px; - padding: 12px 16px; - border-radius: 14px; - background: rgba(18, 24, 38, 0.42); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid rgba(212, 175, 55, 0.32); - color: rgba(255, 227, 154, 0.92); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); -} - -/* Hide "Нет диалогов." line on DM list per UI request */ -.dm-screen .dm-status-line { - display: none !important; -} - -.dm-screen .dm-status-line.is-available { - color: rgba(255, 227, 154, 0.92); -} - -.dm-screen .dm-status-line.is-unavailable { - color: rgba(255, 161, 176, 0.95); -} - .dm-screen .dm-list > .card.meta-muted { width: calc(100% - 40px); margin: 0 20px; @@ -4008,6 +4073,39 @@ textarea.input { text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); } +/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */ +.toolbar-icon-img { + --tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */ + width: var(--tab-icon-size); + height: var(--tab-icon-size); + object-fit: contain; + display: block; + transition: transform .12s ease, filter .15s ease; +} +/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */ +.toolbar-btn.active .toolbar-icon-img { + filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08); +} +/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */ +.toolbar-btn:active .toolbar-icon-img { + transform: scale(0.9); + filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2); +} +/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */ +.toolbar-btn-hero .toolbar-icon-img { + /* Крупнее ВИЗУАЛЬНО через transform (origin center) — раскладочный размер как у остальных (27px), + поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */ + transform: scale(1.63); /* ≈44px при базовых 27px */ + filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */ +} +.toolbar-btn-hero.active .toolbar-icon-img { + filter: brightness(1.12); +} +.toolbar-btn-hero:active .toolbar-icon-img { + transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */ + filter: brightness(1.25); /* нажатие — только подсветление, без ореола */ +} + .toolbar-channels-hold-overlay { position: fixed; z-index: 1200; @@ -4424,10 +4522,17 @@ textarea.input { .toolbar-btn-network { position: relative; overflow: hidden; + -webkit-tap-highlight-color: transparent; /* нет синей вспышки-квадрата при тапе (Android WebView/Chromium) */ +} +/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */ +.toolbar-btn-network:focus, +.toolbar-btn-network:focus-visible { + outline: none; } .toolbar-btn-network::before { content: ""; + display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */ position: absolute; inset: 6px; border-radius: 10px; diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 5350608..22adccc 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -4,6 +4,20 @@ Отдельный модуль, чтобы не раздувать components.css. ========================================================================== */ +/* Канонические токены ЯЗЫКА СВЯЗЕЙ (единый источник цвета отношений для всего продукта). + Экран «Личные сообщения» наследует их через --dm-* (не дублирует hex). + (force-graph пока использует свои JS-цвета RELATION_COLORS — миграция на токены = будущая задача.) */ +:root { + --rel-contact: #8C63FF; /* violet — обычная связь / контакт */ + --rel-family: #F0B82E; /* gold — семья / близкий круг / важность / подтверждение */ + --rel-shining: #68D8FF; /* celestial — сияющий / сильная активная связь */ + --rel-link: #19E58A; /* emerald — статус «Связь» (активный канал) */ + --rel-contact-glow: rgba(140, 99, 255, 0.24); + --rel-family-glow: rgba(240, 184, 46, 0.30); + --rel-shining-glow: rgba(104, 216, 255, 0.35); + --rel-link-glow: rgba(25, 229, 138, 0.24); +} + .fg-stage { touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */ user-select: none; @@ -120,14 +134,73 @@ } /* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */ +/* 58px → радиус 29 = ORB_R в force-graph.js (контакт линий берётся от этого радиуса). */ .fg-node .node-dot { - width: 52px; - height: 52px; + width: 58px; + height: 58px; margin: 0; font-size: 16px; transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; } +/* SVG-«стеклянный орб» — масштабируем так, чтобы сфера (r42 = 84% SVG) ≈ диаметр узла → линии-связи + прилипают к краю орба, как раньше. Хост .node-dot держит размер/состояния/синхронизацию позиций. */ +.fg-node .node-dot.fg-orb-host { + position: relative; + background: none; + overflow: visible; /* не срезать внешнее свечение орба */ + box-shadow: none; +} +/* A/B PNG-оверлей орба (ветка glass-png-overlay): фото снизу + запечённый стеклянный PNG сверху. + Бокс = 119% от .node-dot (как .fg-orb-svg → сфера ≈ кромке node-dot, контакт линий от ORB_R сохраняется). */ +/* Специфичность `.fg-orb-host …` бьёт глобальное `.node-dot img` (иначе оно гасит opacity→0 + и форсит размер 100%). Поэтому opacity/размеры/радиус задаём здесь явно. */ +/* Кромку даёт сам glass_overlay.png → убираем остаточный border .node-dot (синее кольцо + старого векторного орба). Только у PNG-хоста; вектор и свечение/box-shadow не трогаем. */ +/* (0,4,0) — выше специфичности правил категории `.fg-node.is-family .node-dot` (0,3,0), + иначе их background/border перебивают. */ +.fg-node .node-dot.fg-orb-host:has(.fg-pngorb) { + border: none; + background: none; /* фон-градиент категории не торчит из-под прозрачного стекла (та самая «обводка») */ +} +.fg-orb-host .fg-pngorb { + position: absolute; + left: 50%; top: 50%; + width: 119%; height: 119%; + transform: translate(-50%, -50%); +} +.fg-orb-host .fg-pngorb-glass { + position: absolute; + left: 50%; top: 50%; + width: 100%; height: 100%; + transform: translate(-50%, -50%); + opacity: 1; + border-radius: 0; + object-fit: contain; + display: block; + pointer-events: none; +} +.fg-orb-host .fg-pngorb-photo { + position: absolute; + left: 50%; top: 50%; + width: 78%; height: 78%; /* ~78% от бокса оверлея — сидит внутри стеклянной кромки */ + transform: translate(-50%, -50%); + opacity: 1; + border-radius: 50%; /* фолбэк, если mask не поддержан */ + object-fit: cover; + display: block; + /* Мягкий край: фото непрозрачно до --feather-full, плавно гаснет к 0 у --feather-edge — + сливается со стеклом без жёсткого ободка. Силу растушёвки крутим этими двумя параметрами. */ + --feather-full: 62%; + --feather-edge: 78%; + -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge)); + mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge)); +} +.fg-orb-host .fg-pngorb-init { + display: flex; align-items: center; justify-content: center; + background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px; +} + .fg-node.is-family .node-dot { background: linear-gradient(165deg, #785038, #5f3e2c); border-color: rgba(255, 194, 143, 0.6); @@ -334,14 +407,6 @@ .fg-dot.is-tier3 { animation: none; } } -/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .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; -} - /* «Призрак» старой карты при Z-переходе (эффект погружения) */ .fg-ghost-layer { position: absolute; @@ -567,12 +632,8 @@ } .fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } -/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */ -.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); } -.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); } -.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); } -.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); } -/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */ +/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу + box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */ .fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } /* Строка поиска (оверлей вверху, под панелью фильтров) */ @@ -659,20 +720,8 @@ border: 0; } -/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */ +/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */ .fg-node.is-common .node-dot { border-color: rgba(255, 214, 120, 0.95); box-shadow: 0 0 14px rgba(255, 200, 90, 0.4); } -.fg-node.is-common .node-dot::after { - content: '★'; - position: absolute; - bottom: -5px; - left: 50%; - transform: translateX(-50%); - font-size: 10px; - line-height: 1; - color: #ffd678; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); - pointer-events: none; -}