-
-
${item.name}
- ${item.notInContacts ? '
не в контактах' : ''}
+
+ ${name}
+ ${checkHtml}
-
${item.lastMessage}
-
-
+
${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;
-}