diff --git a/VERSION.properties b/VERSION.properties index 59118614..69c6d861 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.11 +client.version=1.12.12 server.version=1.10.4 diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index e58e29d5..75c64e4d 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -131,6 +131,12 @@ dm_dialog_state хранит: WebSocket push ускоряет отображение, но после переподключения клиент запрашивает историю у своего единственного access-сервера. +UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к +непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое +входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в +текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм +видимого чтения/receipt отмечает его прочитанным. + ## 9. Подтверждения прочтения Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index a5842884..38978942 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -264,6 +264,12 @@ ReadReceiptBody_v1_0 - если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад. - в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview. + +UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только +для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`, +полученный при уже открытом соответствующем чате, отображается сразу без создания нового +разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола. + ## 9. Контент типов `5/6` Типы: diff --git a/shine-UI/assets/shine-official-badge.svg b/shine-UI/assets/shine-official-badge.svg new file mode 100644 index 00000000..b5768831 --- /dev/null +++ b/shine-UI/assets/shine-official-badge.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index f3d7f3e9..e76e3bfd 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1615,12 +1615,9 @@ export function render({ navigate, route, chrome }) { const handleIncomingChatRefresh = async (event) => { const updatedChatId = normalizeDmChatId(event?.detail?.chatId); if (updatedChatId !== chatId) return; - if (Number(event?.detail?.messageType || 0) === 1) { - if (!unreadSeparatorVisible) { - unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey; - unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey); - } - } + // Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт + // новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к + // непрочитанным, которые существовали ДО открытия экрана чата. preserveComposerSelection(input, () => { renderChatLog({ scrollMode: 'latest' }); }); diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index d5c04f64..25f452ca 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -201,6 +201,269 @@ function buildGraphModel(graph, centerLogin) { let persistedCenterLogin = ''; let persistedCenterHistory = []; +let persistedGraphHistory = []; + +const HISTORY_MAX_CENTERS = 4; +const HISTORY_CENTER_GAP_MIN = 280; +const HISTORY_CENTER_GAP_STEP = 72; +const HISTORY_CENTER_CLEARANCE = 110; +const HISTORY_ORBIT_FIRST_R = 104; +const HISTORY_ORBIT_GAP = 88; +const HISTORY_ORBIT_NODE_GAP = 84; +const HISTORY_NODE_CLEARANCE = 92; + +function historyHash01(value) { + let h = 2166136261; + const text = String(value || ''); + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 1000) / 1000; +} + +function historyOrbitCapacity(radius) { + const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1); + const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r))); + return Math.max(1, Math.floor((Math.PI * 2) / minAngle)); +} + +function historyOrbitPlacements(total, seed = '') { + const count = Math.max(0, Number(total) || 0); + const out = new Array(count); + let start = 0; + let ring = 0; + const seedPhase = historyHash01(seed) * Math.PI * 2; + while (start < count) { + const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP; + const capacity = historyOrbitCapacity(radius); + const ringCount = Math.min(capacity, count - start); + const phase = seedPhase + ring * 0.43; + for (let i = 0; i < ringCount; i += 1) { + out[start + i] = { + radius, + angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount), + }; + } + start += ringCount; + ring += 1; + } + return out; +} + +function historyClusterRadius(snapshot) { + const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : []; + const centerKey = normKey(snapshot?.centerLogin); + const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length; + const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || ''); + const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0); + return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE; +} + +function buildHistoryEngineModel(history) { + const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS); + const latest = snapshots[snapshots.length - 1]; + if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; + + const centerPos = new Map(); + const centerOrder = new Map(); + const clusterRadius = new Map(); + centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 }); + snapshots.forEach((snap, index) => { + const key = normKey(snap.centerLogin); + centerOrder.set(key, index); + clusterRadius.set(key, historyClusterRadius(snap)); + }); + + // Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода + // настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались. + for (let i = snapshots.length - 1; i > 0; i -= 1) { + const current = snapshots[i]; + const previous = snapshots[i - 1]; + const currentKey = normKey(current.centerLogin); + const previousKey = normKey(previous.centerLogin); + const curPos = centerPos.get(currentKey) || { x: 0, y: 0 }; + const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0; + const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE; + const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE; + let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR); + let candidate = null; + + for (let attempt = 0; attempt < 24; attempt += 1) { + candidate = { + x: curPos.x - Math.cos(angle) * gap, + y: curPos.y - Math.sin(angle) * gap, + }; + let collides = false; + for (const [placedKey, placed] of centerPos.entries()) { + const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE; + const minDist = prevR + placedR; + if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) { + collides = true; + break; + } + } + if (!collides) break; + gap += HISTORY_CENTER_GAP_STEP; + } + centerPos.set(previousKey, candidate || { + x: curPos.x - Math.cos(angle) * gap, + y: curPos.y - Math.sin(angle) * gap, + }); + } + + const nodeMap = new Map(); + const latestPlacementParent = new Map(); + const edgeMap = new Map(); + + // Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям. + snapshots.forEach((snap, snapIndex) => { + const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; + const centerKey = normKey(snap.centerLogin); + const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0]; + if (center) { + nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true }); + } + + const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); + relations.forEach((node) => { + const key = normKey(node?.id); + if (!key) return; + nodeMap.set(key, { ...nodeMap.get(key), ...node }); + + const a = centerKey; + const b = key; + const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`; + edgeMap.set(edgeKey, { + a, + b, + relationType: node.relationType || 'contact', + strength: Number(node.strength) || 0.5, + snapIndex, + }); + }); + }); + + // Все исторические центры сразу считаются занятыми местами. + const occupied = []; + for (const [centerKey, pos] of centerPos.entries()) { + occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE }); + } + + const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => { + if (row.key === key) return false; + return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); + }); + + // Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь + // встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге. + for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) { + const snap = snapshots[snapIndex]; + const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; + const centerKey = normKey(snap.centerLogin); + const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); + const placements = historyOrbitPlacements(relations.length, snap.centerLogin); + const base = centerPos.get(centerKey) || { x: 0, y: 0 }; + + relations.forEach((node, index) => { + const key = normKey(node?.id); + if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return; + const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 }; + let chosen = null; + // Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус. + for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) { + const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55; + const angularSamples = 18 + radialStep * 2; + for (let step = 0; step < angularSamples; step += 1) { + const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1); + const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples); + const x = base.x + Math.cos(angle) * radius; + const y = base.y + Math.sin(angle) * radius; + if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) { + chosen = { x, y }; + break; + } + } + } + if (!chosen) { + const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55; + chosen = { + x: base.x + Math.cos(preferred.angle) * radius, + y: base.y + Math.sin(preferred.angle) * radius, + }; + } + latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex }); + occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE }); + }); + } + + for (const [centerKey, pos] of centerPos.entries()) { + const node = nodeMap.get(centerKey); + if (!node) continue; + nodeMap.set(centerKey, { + ...node, + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + keepVisible: true, + isHistoryCenter: true, + }); + } + + for (const [key, placement] of latestPlacementParent.entries()) { + const node = nodeMap.get(key); + if (!node || centerOrder.has(key)) continue; + nodeMap.set(key, { + ...node, + layoutX: placement.x, + layoutY: placement.y, + fixedLayout: true, + parentId: placement.centerKey, + }); + } + + const edgeParentsByChild = new Map(); + const addEdgeParent = (childKey, parentKey, edge) => { + if (!childKey || !parentKey || childKey === parentKey) return; + const list = edgeParentsByChild.get(childKey) || []; + if (!list.some((row) => row.id === parentKey)) { + list.push({ + id: parentKey, + relationType: edge.relationType, + strength: edge.strength, + shining: edge.shining, + }); + } + edgeParentsByChild.set(childKey, list); + }; + + for (const edge of edgeMap.values()) { + const aCenterIndex = centerOrder.get(edge.a); + const bCenterIndex = centerOrder.get(edge.b); + if (aCenterIndex !== undefined && bCenterIndex !== undefined) { + if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge); + else addEdgeParent(edge.b, edge.a, edge); + continue; + } + if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge); + else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge); + } + + const nodes = [...nodeMap.entries()].map(([key, node]) => ({ + ...node, + id: key, + login: node.login || node.id || key, + tier: 1, + fixedLayout: true, + edgeParents: edgeParentsByChild.get(key) || [], + })); + + return { + focusId: normKey(latest.engineModel.focusId), + nodes, + preserveHistory: snapshots.length > 1, + }; +} export function render({ navigate, route, chrome } = {}) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; @@ -208,6 +471,7 @@ export function render({ navigate, route, chrome } = {}) { if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; + persistedGraphHistory = []; } const screen = document.createElement('section'); @@ -221,27 +485,36 @@ export function render({ navigate, route, chrome } = {}) { let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || ''); let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : []; + let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : []; let engine = null; let loadSeq = 0; - // Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. + // Независимые фильтры карты. Оба выключены = показываем всё. + // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. const FILTERS = { - all: { label: 'Все', pred: () => true }, - friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' }, + close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' }, shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, }; - const FILTER_ORDER = ['all', 'friends', 'shining']; - let activeFilter = 'all'; + const FILTER_ORDER = ['close', 'shining']; + const activeFilters = new Set(); const filterChips = {}; + function currentFilterPredicate(node) { + for (const key of activeFilters) { + if (!FILTERS[key].pred(node)) return false; + } + return true; + } + function applyFilter(key) { if (!FILTERS[key]) return; - activeFilter = key; + if (activeFilters.has(key)) activeFilters.delete(key); + else activeFilters.add(key); FILTER_ORDER.forEach((k) => { const el = filterChips[k]; - if (el) el.classList.toggle('is-active', k === activeFilter); + if (el) el.classList.toggle('is-active', activeFilters.has(k)); }); - if (engine) engine.setFilter(FILTERS[key].pred); + if (engine) engine.setFilter(currentFilterPredicate); } function profileInfoRoute(login) { @@ -254,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) { function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; + persistedGraphHistory = [...graphHistory]; } function syncLinksUrl(login, { push = false } = {}) { @@ -392,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) { stage: board, model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет - onNodeTap: (node) => { void load(node.login, { pushHistory: true }); }, + onNodeTap: (node) => { + const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0); + void load(node.login, { pushHistory: true, transitionAngle }); + }, // тап по центру — полноценный профиль onCenterTap: (node) => { const routeTo = profileInfoRoute(node.login); @@ -415,7 +692,7 @@ export function render({ navigate, route, chrome } = {}) { }); } - async function load(nextCenterLogin = '', { pushHistory = false } = {}) { + async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); @@ -430,10 +707,35 @@ export function render({ navigate, route, chrome } = {}) { syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); - const engineModel = engineModelFromGraphModel(graphModel); + const snapshotModel = engineModelFromGraphModel(graphModel); + const snapshot = { + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, + }; + + if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { + // Один и тот же пользователь не хранится как два исторических центра: повторный переход + // переносит его в конец истории, где он становится актуальным центром. + graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); + graphHistory.push(snapshot); + if (graphHistory.length > HISTORY_MAX_CENTERS) { + graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS); + centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1)); + } + } else { + const last = graphHistory[graphHistory.length - 1]; + if (last && normKey(last.centerLogin) === normKey(targetCenter)) { + snapshot.transitionAngle = Number(last.transitionAngle) || 0; + graphHistory[graphHistory.length - 1] = snapshot; + } else { + graphHistory = [snapshot]; + } + } + + const engineModel = buildHistoryEngineModel(graphHistory); ensureEngine(engineModel); - // сохраняем выбранный фильтр при перестроении графа (центрирование/переход) - if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred); + if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); persistHistory(); } catch (error) { @@ -475,6 +777,7 @@ export function render({ navigate, route, chrome } = {}) { if (routeLogin) { centerLogin = routeLogin; centerHistory = []; + graphHistory = []; persistHistory(); void load(centerLogin, { pushHistory: false }); } else if (keepHistory && centerLogin) { @@ -482,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) { } else { centerLogin = normalizeLogin(state.session.login || ''); centerHistory = []; + graphHistory = []; persistHistory(); if (centerLogin) { void load(centerLogin, { pushHistory: false }); @@ -499,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) { FILTER_ORDER.forEach((key) => { const chip = document.createElement('button'); chip.type = 'button'; - chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`; + chip.className = `fg-filter-chip${activeFilters.has(key) ? ' is-active' : ''}`; chip.textContent = FILTERS[key].label; chip.addEventListener('click', () => applyFilter(key)); filterChips[key] = chip; diff --git a/shine-UI/js/pages/network/adapter.js b/shine-UI/js/pages/network/adapter.js index 81f33cc0..b7143536 100644 --- a/shine-UI/js/pages/network/adapter.js +++ b/shine-UI/js/pages/network/adapter.js @@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) { relationType: 'self', strength: 1, shining: Boolean(centerMark?.shine), + official: Boolean(centerMark?.official), tier: 1, }; @@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) { relationType: relationTypeFromRelation(r), strength: deriveStrength(r || {}), shining: Boolean(r?.mark?.shine), + official: Boolean(r?.mark?.official), tier: 1, }; }) diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index e84e28e2..edd46228 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина» const K_FOCUS = 0.12; // мягкая пружина фокуса к центру +const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется) const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники) const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания @@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); - // число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу») childCountByParent = new Map(); degreeById = new Map(); + + const hasExplicitEdges = nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0); + if (hasExplicitEdges) { + for (const n of nodes) { + const refs = Array.isArray(n.edgeParents) ? n.edgeParents : []; + for (const ref of refs) { + const parentId = String(ref?.id || ''); + if (!parentId || !nodeById.has(parentId)) continue; + degreeById.set(String(n.id), (degreeById.get(String(n.id)) || 0) + 1); + degreeById.set(parentId, (degreeById.get(parentId) || 0) + 1); + } + } + return; + } + let tier1count = 0; for (const n of nodes) { if (n.tier >= 2 && n.parentId) { const i = childCountByParent.get(n.parentId) || 0; n.sibIndex = i; childCountByParent.set(n.parentId, i + 1); - degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь + degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); } else if (n.tier === 1 && String(n.id) !== focusId) { tier1count += 1; } } - degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня + degreeById.set(focusId, tier1count); }; // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы @@ -410,7 +425,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const focusSrc = list.find((n) => String(n.id) === fId) || list[0]; const tier1Orbit = buildOrbitPlacements(tier1.length); if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null }); - tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] })); + tier1.forEach((p, i) => specs.push({ + src: p, + id: String(p.id), + isFocus: false, + index: i, + total: tier1.length, + dotOnly: i >= MAX_FULL_NODES && !p.keepVisible, + orbit: p.fixedLayout ? null : tier1Orbit[i], + })); // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 })); return { focusId: fId, specs }; @@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) { const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5)); const tier = Number(src.tier) || 1; - // Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера: - // иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками. - const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R); - const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)); + // Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view + // может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему + // кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер. + const layoutX = Number(src.layoutX); + const layoutY = Number(src.layoutY); + const fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY); + const targetR = fixedLayout ? Math.hypot(layoutX, layoutY) : (isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R)); + const angle = fixedLayout ? Math.atan2(layoutY, layoutX) : (isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total))); // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда. const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); - // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — - // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. - const tx = isFocus ? 0 : Math.cos(angle) * targetR; - const ty = isFocus ? 0 : Math.sin(angle) * targetR; + const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR); + const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR); const el = buildNodeElement(src, isFocus, tier, dotOnly); world.append(el); return { ...src, isFocus, tier, - parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус) + parentId: String(src.parentId || ''), // layout/deep parent; для history-рёбер используется edgeParents + edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [], + fixedLayout, + keepVisible: Boolean(src.keepVisible), + official: Boolean(src.official), deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво» @@ -501,6 +530,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png // на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного). const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png'; + const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg'; function buildPngOrb(src, opts) { const o = opts || {}; const wrap = document.createElement('div'); @@ -528,6 +558,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return wrap; } + + function syncOfficialBadge(el, official) { + if (!(el instanceof HTMLElement)) return; + const dot = el.querySelector('.node-dot'); + if (!(dot instanceof HTMLElement)) return; + const existing = dot.querySelector('.fg-official-badge'); + if (!official) { + existing?.remove(); + return; + } + if (existing) return; + const badge = document.createElement('img'); + badge.className = 'fg-official-badge'; + badge.src = OFFICIAL_BADGE_SRC; + badge.alt = ''; + badge.setAttribute('aria-hidden', 'true'); + dot.append(badge); + } + function buildNodeElement(src, isFocus, tier, dotOnly = false) { const el = document.createElement('button'); el.type = 'button'; @@ -537,6 +586,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 'ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка) src.shining ? 'is-shine' : '', + src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); @@ -549,6 +599,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 'ui-button', 'fg-node', isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', + src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier >= 2 ? 'is-secondary' : '', @@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут. dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials })); el.append(dot); + syncOfficialBadge(el, Boolean(src.official)); - // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. - const badge = document.createElement('span'); - badge.className = 'fg-node-badge'; - badge.hidden = true; - el.append(badge); const label = document.createElement('span'); label.className = 'fg-node-label'; @@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return el; } - // Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex. - function updateBadges() { - for (const n of nodes) { - const badge = n.el.querySelector('.fg-node-badge'); - if (!badge) continue; // у точек (dotOnly) бейджа нет - const deg = degreeById.get(String(n.id)) || 0; - if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; } - else { badge.hidden = true; } - } - } + // Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой. + function updateBadges() {} // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком). function updateA11y() { @@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.strength = strength; node.relationType = src.relationType; node.shining = Boolean(src.shining); - node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R); - node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)); + node.official = Boolean(src.official); + node.keepVisible = Boolean(src.keepVisible); + node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; + const layoutX = Number(src.layoutX); + const layoutY = Number(src.layoutY); + node.fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY); + node.targetR = node.fixedLayout ? Math.hypot(layoutX, layoutY) : (spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R)); + node.angle = node.fixedLayout ? Math.atan2(layoutY, layoutX) : (spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total))); node.orbitRing = spec.orbit?.ring || 0; - node.tx = Math.cos(node.angle) * node.targetR; - node.ty = Math.sin(node.angle) * node.targetR; + node.tx = node.fixedLayout ? layoutX : Math.cos(node.angle) * node.targetR; + node.ty = node.fixedLayout ? layoutY : Math.sin(node.angle) * node.targetR; node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); node.hidden = false; @@ -628,8 +673,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26))); // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM node.el.className = spec.dotOnly - ? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') - : ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); + ? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') + : ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); + if (!spec.dotOnly) syncOfficialBadge(node.el, node.official); } // --- Рендер ---------------------------------------------------------------- @@ -637,6 +683,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`; } + + // Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает + // обе центральные оси viewport. На крайнем положении к центральной линии прижимается край + // крайнего аватара — полностью выбросить весь граф за экран больше нельзя. + function clampCameraPosition(nextX, nextY) { + const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02); + if (!visible.length) return { x: 0, y: 0 }; + let minX = Infinity; let maxX = -Infinity; + let minY = Infinity; let maxY = -Infinity; + for (const n of visible) { + const baseR = n.dotOnly ? (Number(n.dotRadius) || 5) : ORB_R; + const r = baseR * (Number(n.scale) || 1) * (Number(n.depthScale) || 1); + minX = Math.min(minX, n.x - r); + maxX = Math.max(maxX, n.x + r); + minY = Math.min(minY, n.y - r); + maxY = Math.max(maxY, n.y + r); + } + if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return { x: nextX, y: nextY }; + const minCamX = -maxX * zoom; + const maxCamX = -minX * zoom; + const minCamY = -maxY * zoom; + const maxCamY = -minY * zoom; + return { + x: Math.max(minCamX, Math.min(maxCamX, nextX)), + y: Math.max(minCamY, Math.min(maxCamY, nextY)), + }; + } + // Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся // в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он // упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет. @@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL zoom = z1; camX = sx - centerX - wx * z1; camY = sy - centerY - wy * z1; + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик applyWorldTransform(); renderEdges(); @@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.lod = full ? 'full' : 'dot'; n.dotOnly = !full; n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей - if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } } + } + + function renderHistoryEdges() { + const Z = zoom; + const parts = []; + const tx = (n) => centerX + camX + n.x * Z; + const ty = (n) => centerY + camY + n.y * Z; + + for (const n of nodes) { + const refs = Array.isArray(n.edgeParents) ? n.edgeParents : []; + if (!refs.length) continue; + const childOpacity = (typeof n.opacity === 'number' ? n.opacity : 1) * (n.spotCur ?? 1); + if (n.hidden && childOpacity <= 0.02) continue; + + for (const ref of refs) { + const parent = nodeById.get(String(ref?.id || '')); + if (!parent || parent === n) continue; + const parentOpacity = (typeof parent.opacity === 'number' ? parent.opacity : 1) * (parent.spotCur ?? 1); + if (parent.hidden && parentOpacity <= 0.02) continue; + + const fx = tx(parent); + const fy = ty(parent); + const nx = tx(n); + const ny = ty(n); + if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; + if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue; + + const dx = nx - fx; + const dy = ny - fy; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len; + const uy = dy / len; + const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z; + const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z; + const x1 = fx + ux * fr; + const y1 = fy + uy * fr; + const x2 = nx - ux * nr; + const y2 = ny - uy * nr; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const segLen = Math.hypot(x2 - x1, y2 - y1); + const bow = Math.max(7, Math.min(22, segLen * 0.10)); + const sign = hash01(`${parent.id}|${n.id}|edge`) > 0.5 ? 1 : -1; + const desX = mx + (-uy) * bow * sign; + const desY = my + ux * bow * sign; + const cpx = 2 * desX - mx; + const cpy = 2 * desY - my; + const relationType = String(ref?.relationType || n.relationType || 'contact'); + const strength = Math.max(0, Math.min(1, Number(ref?.strength) || Number(n.strength) || 0.5)); + const opacity = Math.min(childOpacity, parentOpacity); + const shine = Boolean(parent.shining && n.shining) && !n.hidden; + + if (shine) { + const pnx = -uy; + const pny = ux; + const amp = Math.min(13, 5 + segLen * 0.05); + const bowX = desX - mx; + const bowY = desY - my; + const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; + const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; + const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; + const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; + const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + parts.push(``); + parts.push(``); + parts.push(``); + } else { + const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + const col = relationColor(relationType); + const haloWidth = (2.6 + strength * 1.4).toFixed(2); + parts.push(``); + parts.push(``); + } + } + } + edgesSvg.innerHTML = parts.join(''); } function renderEdges() { + if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) { + renderHistoryEdges(); + return; + } const focus = nodes.find((n) => n.id === focusId); if (!focus) { edgesSvg.innerHTML = ''; @@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность; // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). - const shine = Boolean(n.shining) && !n.hidden; + const shine = Boolean(parent.shining && n.shining) && !n.hidden; const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; @@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let ax = 0; let ay = 0; - if (n.isFocus) { + if (n.fixedLayout) { + // Историческая карта уже получила приоритетную раскладку от network-view. Не разрушаем её + // общей радиальной физикой: только мягко возвращаем узел к назначенной точке после жестов/анимаций. + ax += K_FIXED_LAYOUT * (n.tx - n.x); + ay += K_FIXED_LAYOUT * (n.ty - n.y); + } else if (n.isFocus) { // пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается) ax += K_FOCUS * (0 - n.x); ay += K_FOCUS * (0 - n.y); @@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let dist2 = dx * dx + dy * dy; if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST; const dist = Math.sqrt(dist2); - // адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание - // пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты. const ex = Math.max(n.expandP || 0, m.expandP || 0); const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2; ax += (dx / dist) * f; @@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const visiblePeers = []; nodes.forEach((n) => { - if (n.isFocus) { n.hidden = false; return; } + if (n.isFocus || n.keepVisible) { n.hidden = false; return; } n.hidden = !pred(n); n.vx = 0; n.vy = 0; @@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL }; const focus = nodes.find((n) => n.isFocus); - if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1); + if (focus) apply(focus, focus.tx || 0, focus.ty || 0, FOCUS_SCALE, 1); - applyOrbitTargets(visiblePeers); + const fixedLayout = visiblePeers.some((n) => n.fixedLayout) || nodes.some((n) => n.fixedLayout); + if (!fixedLayout) applyOrbitTargets(visiblePeers); visiblePeers.forEach((n) => { const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; apply(n, n.tx, n.ty, sc, 1); @@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // инерция панорамирования (kinematic): камера докатывается с трением const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15); if (panActive) { - camX += panVelX; - camY += panVelY; + const proposedX = camX + panVelX; + const proposedY = camY + panVelY; + const clamped = clampCameraPosition(proposedX, proposedY); + camX = clamped.x; + camY = clamped.y; + if (camX !== proposedX) panVelX = 0; + if (camY !== proposedY) panVelY = 0; panVelX *= PAN_FRICTION; panVelY *= PAN_FRICTION; applyWorldTransform(); @@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const newCamY = camStartY + dy; panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания) panVelY = newCamY - camY; - camX = newCamX; - camY = newCamY; + const clamped = clampCameraPosition(newCamX, newCamY); + camX = clamped.x; + camY = clamped.y; + if (camX !== newCamX) panVelX = 0; + if (camY !== newCamY) panVelY = 0; applyWorldTransform(); advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа renderEdges(); // рёбра следуют за камерой синхронно (дёшево) @@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL viewH = stage.clientHeight || window.innerHeight; centerX = viewW / 2; centerY = viewH / 2; + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); + applyWorldTransform(); renderEdges(); } @@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } freezeGraph(); + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); + applyWorldTransform(); + renderEdges(); return; } boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80) @@ -1617,8 +1788,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // старый узел нового фокуса (если был) — фокус глайдит из его позиции const focusOld = oldById.get(String(newFocusId)); - // снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира - spawnGhost(); + // В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят + // в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём. + if (!nextModel?.preserveHistory) spawnGhost(); nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); }); focusId = String(newFocusId); @@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return node; }); pendingFocusOrigin = null; - diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум + // Новый центр меняет положение карты, но пользовательский масштаб сохраняем. + // Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком. + diveTargetId = null; surfacing = false; rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов updateBadges(); // бейджи-счётчики связей под новый набор updateA11y(); // текстовый список графа для скринридеров diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 675818bb..4736a2a6 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -200,6 +200,24 @@ background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px; } + +/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически + масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */ +.fg-official-badge { + position: absolute; + left: -2%; + bottom: 0; + width: 15%; + height: 15%; + min-width: 8px; + min-height: 8px; + object-fit: contain; + display: block; + z-index: 5; + pointer-events: none; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} + .fg-node.is-family .node-dot { background: linear-gradient(165deg, #785038, #5f3e2c); border-color: rgba(255, 194, 143, 0.6); @@ -241,15 +259,17 @@ .fg-node.is-pressed .node-dot { transform: none; } } -/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи. - Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль: - визуально сияющий пользователь всегда остаётся явно сияющим. */ +/* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути + от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */ .fg-node.is-shine .node-dot { border-color: rgba(150, 240, 255, 0.62); - animation: fg-shine-glow 3.6s ease-in-out infinite; + box-shadow: + 0 0 8.3px rgba(146, 243, 255, 0.57), + 0 0 18.3px rgba(121, 235, 255, 0.39), + 0 0 33px rgba(100, 220, 255, 0.24); } -/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */ +/* Статичный размытый ореол позади аватарки — та же промежуточная интенсивность ~2/3. */ .fg-node.is-shine .node-dot::before { content: ''; position: absolute; @@ -259,34 +279,8 @@ filter: url(#fg-shine-glow); z-index: -1; pointer-events: none; - animation: fg-shine-halo 3.6s ease-in-out infinite; -} - -/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */ -@keyframes fg-shine-glow { - 0%, 100% { - box-shadow: - 0 0 7px rgba(138, 239, 255, 0.48), - 0 0 15px rgba(118, 232, 255, 0.32), - 0 0 27px rgba(100, 220, 255, 0.19); - } - 50% { - box-shadow: - 0 0 9px rgba(150, 245, 255, 0.62), - 0 0 20px rgba(122, 236, 255, 0.42), - 0 0 36px rgba(100, 220, 255, 0.26); - } -} - -/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */ -@keyframes fg-shine-halo { - 0%, 100% { transform: scale(0.98); opacity: 0.72; } - 50% { transform: scale(1.12); opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - .fg-node.is-shine .node-dot { animation: none; } - .fg-node.is-shine .node-dot::before { animation: none; } + transform: scale(1.07); + opacity: 0.91; } /* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */ @@ -524,37 +518,11 @@ background: transparent; } -/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ - -/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */ -.fg-node-badge { - position: absolute; - top: -2px; - right: -2px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 999px; - background: rgba(16, 24, 40, 0.92); - border: 1px solid rgba(150, 200, 255, 0.5); - color: #d9ecff; - font-size: 9px; - font-weight: 700; - line-height: 14px; - text-align: center; - pointer-events: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); -} -.fg-node.is-focus .fg-node-badge { - background: rgba(61, 196, 223, 0.95); - border-color: rgba(220, 245, 255, 0.8); - color: #06131c; -} -.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } +/* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */ /* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */ -.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } +.fg-node.is-focus:not(.is-shine) .node-dot { box-shadow: none; } /* Строка поиска (оверлей вверху, под панелью фильтров) */ .fg-search {