From ea0098d7053237fce9d2254f8774b73f0978430d623600e6e73671ee71ca1ca3 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 11 Sep 2026 00:43:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84=20=D1=81=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/network-view.js | 885 +++++++++++++++-------- shine-UI/js/pages/network/force-graph.js | 45 +- 3 files changed, 616 insertions(+), 316 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index a41defa7..39756fc9 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.13 +client.version=1.12.14 server.version=1.10.4 diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 1cfd0983..44018ee2 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -212,7 +212,6 @@ let persistedHistoryDepth = 4; let persistedX2Enabled = false; const HISTORY_MAX_PREVIOUS = 4; -const HISTORY_CENTER_GAP_START = 108; const HISTORY_CENTER_GAP_STEP = 28; const HISTORY_CENTER_CLEARANCE = 88; const HISTORY_NODE_CLEARANCE = 82; @@ -294,14 +293,6 @@ function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, return out; } -function historyIdealShell(count) { - const n = Math.max(0, Math.trunc(Number(count) || 0)); - if (!n) return 0; - let shell = 1; - while (4 * shell * (shell + 1) < n) shell += 1; - return shell; -} - function relationEdge(parentId, node) { return { id: normKey(parentId), @@ -310,21 +301,6 @@ function relationEdge(parentId, node) { }; } -function stripSecondLevel(model) { - const nodes = Array.isArray(model?.nodes) ? model.nodes : []; - const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id))); - return { - ...model, - nodes: nodes - .filter((node) => keep.has(normKey(node?.id))) - .map((node) => ({ - ...node, - edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []) - .filter((edge) => keep.has(normKey(edge?.id))), - })), - }; -} - async function mapWithConcurrency(items, concurrency, worker) { const list = Array.isArray(items) ? items : []; const out = new Array(list.length); @@ -343,12 +319,16 @@ async function mapWithConcurrency(items, concurrency, worker) { async function buildSecondLevelEngineModel(baseModel, getGraph) { const focusKey = normKey(baseModel?.focusId); const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : []; - const byKey = new Map(); + // ФАЗА 1. Сначала фиксируем ПОЛНЫЙ первый уровень центрального пользователя. + // Его уровень больше никогда не зависит от того, в каком из ответов друзей он встретится позднее. + const byKey = new Map(); + const firstLevelKeys = new Set(); baseNodes.forEach((src) => { const key = normKey(src?.id); if (!key) return; const isFocus = key === focusKey; + if (!isFocus && src?.relationType !== 'friend' && src?.relationType !== 'close_friend') return; const normalized = { ...src, id: key, @@ -358,83 +338,385 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) { edgeParents: isFocus ? [] : [relationEdge(focusKey, src)], }; byKey.set(key, normalized); + if (!isFocus) firstLevelKeys.add(key); }); - const directFriends = [...byKey.values()].filter((node) => ( - normKey(node.id) !== focusKey - && (node.relationType === 'friend' || node.relationType === 'close_friend') - )); + const directFriends = [...firstLevelKeys] + .map((key) => byKey.get(key)) + .filter(Boolean); + // ФАЗА 2. Запрашиваем КАЖДОГО друга первого уровня. Пока все ответы не получены, + // структуру X2 не достраиваем и в движок ничего не отдаём. const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => { const graph = await getGraph(parent.login || parent.id); const graphModel = buildGraphModel(graph, parent.login || parent.id); return { parent, model: engineModelFromGraphModel(graphModel) }; }); + const failedParents = fetched + .map((row, index) => row?.error ? directFriends[index] : null) + .filter(Boolean); + if (failedParents.length) { + const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', '); + throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`); + } + + // ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра. + // Сначала накапливаем, потом одним проходом присваиваем глубину. depth=1 всегда приоритетнее depth=2. + const secondCandidates = new Map(); + const edgesByChild = new Map(); fetched.forEach((row) => { - if (!row || row.error || !row.model) return; + if (!row || !row.model) return; const parentKey = normKey(row.parent?.id); const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : []; childNodes.forEach((child) => { const childKey = normKey(child?.id); if (!childKey || childKey === parentKey || childKey === focusKey) return; if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return; - const edge = relationEdge(parentKey, child); - const existing = byKey.get(childKey); - if (existing) { - const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : []; - if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge); - byKey.set(childKey, { ...existing, edgeParents: refs }); - return; - } - byKey.set(childKey, { - ...child, - id: childKey, - login: child?.login || child?.id || childKey, - tier: 2, - parentId: parentKey, - alwaysVisible: true, - edgeParents: [edge], - }); + + const refs = edgesByChild.get(childKey) || []; + if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(relationEdge(parentKey, child)); + edgesByChild.set(childKey, refs); + + // Если это прямой друг центра, его не переносим на второй уровень — только добавляем новое ребро. + if (firstLevelKeys.has(childKey)) return; + if (!secondCandidates.has(childKey)) secondCandidates.set(childKey, child); + }); + }); + + // ФАЗА 4. Только теперь собираем окончательные узлы. + firstLevelKeys.forEach((key) => { + const existing = byKey.get(key); + if (!existing) return; + const extra = edgesByChild.get(key) || []; + const refs = [...(Array.isArray(existing.edgeParents) ? existing.edgeParents : [])]; + extra.forEach((edge) => { + if (!refs.some((ref) => normKey(ref?.id) === normKey(edge?.id))) refs.push(edge); + }); + byKey.set(key, { ...existing, tier: 1, edgeParents: refs }); + }); + + secondCandidates.forEach((child, childKey) => { + const refs = edgesByChild.get(childKey) || []; + const parentKey = normKey(refs[0]?.id); + byKey.set(childKey, { + ...child, + id: childKey, + login: child?.login || child?.id || childKey, + tier: 2, + parentId: parentKey, + alwaysVisible: true, + edgeParents: refs, }); }); return { ...baseModel, nodes: [...byKey.values()] }; } -function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { - const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); - const maxSnapshots = keepPrevious + 1; - const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots); - const latest = snapshots[snapshots.length - 1]; - if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; +const NETWORK_GRAPH_CACHE_TTL_MS = 2 * 60 * 1000; +const NETWORK_GRAPH_CACHE_MAX = 240; +const networkGraphCache = new Map(); +const networkGraphInflight = new Map(); - const centerOrder = new Map(); +async function getConnectionsGraphCached(login, { force = false, retries = 0 } = {}) { + const clean = normalizeLogin(login); + const key = normKey(clean); + if (!key) throw new Error('LOGIN_REQUIRED'); + const now = Date.now(); + const cached = networkGraphCache.get(key); + if (!force && cached && now - cached.savedAt < NETWORK_GRAPH_CACHE_TTL_MS) return cached.graph; + if (!force && networkGraphInflight.has(key)) return networkGraphInflight.get(key); + + const request = (async () => { + let lastError = null; + for (let attempt = 0; attempt <= Math.max(0, retries); attempt += 1) { + try { + const graph = await authService.getUserConnectionsGraph(clean); + networkGraphCache.delete(key); + networkGraphCache.set(key, { graph, savedAt: Date.now() }); + while (networkGraphCache.size > NETWORK_GRAPH_CACHE_MAX) { + const oldestKey = networkGraphCache.keys().next().value; + if (!oldestKey) break; + networkGraphCache.delete(oldestKey); + } + return graph; + } catch (error) { + lastError = error; + if (attempt < retries) await new Promise((resolve) => window.setTimeout(resolve, 120 * (attempt + 1))); + } + } + throw lastError || new Error('GRAPH_LOAD_FAILED'); + })(); + + networkGraphInflight.set(key, request); + try { + return await request; + } finally { + if (networkGraphInflight.get(key) === request) networkGraphInflight.delete(key); + } +} + +function historyNodeClearance(node) { + return (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; +} + +function cloneEngineModel(model) { + return { + ...model, + nodes: (Array.isArray(model?.nodes) ? model.nodes : []).map((node) => ({ + ...node, + edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []).map((edge) => ({ ...edge })), + })), + }; +} + +function modelNodePositions(model) { + const out = new Map(); + (Array.isArray(model?.nodes) ? model.nodes : []).forEach((node) => { + const key = normKey(node?.id); + const x = Number(node?.layoutX); + const y = Number(node?.layoutY); + if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + out.set(key, { x, y, clearance: historyNodeClearance(node), node }); + }); + return out; +} + +function translateSnapshot(snapshot, dx, dy) { + const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); + model.nodes = model.nodes.map((node) => { + const x = Number(node?.layoutX); + const y = Number(node?.layoutY); + if (!Number.isFinite(x) || !Number.isFinite(y)) return node; + return { ...node, layoutX: x + dx, layoutY: y + dy, fixedLayout: true }; + }); + return { ...snapshot, engineModel: model }; +} + +function placeCompactNodes(nodes, { + center = { x: 0, y: 0 }, + seed = '', + spacing = HISTORY_DIRECT_SPACING, + maxShell = HISTORY_LAYOUT_MAX_SHELL, + occupied = [], +} = {}) { + const rows = occupied.map((row) => ({ ...row })); + const positions = new Map(); + const slots = historyCompactSlots(Math.max(nodes.length + 48, 96), seed, spacing, maxShell); + + nodes.forEach((node, index) => { + const key = normKey(node?.id); + if (!key) return; + const clearance = historyNodeClearance(node); + let chosen = null; + for (const slot of slots) { + const x = center.x + slot.x; + const y = center.y + slot.y; + const collision = rows.some((row) => Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0)); + if (!collision) { + chosen = { x, y }; + break; + } + } + if (!chosen) { + const angle = historyHash01(`${seed}|${key}|fallback`) * Math.PI * 2; + const radius = spacing * (maxShell + 1 + Math.floor(index / 8)); + chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }; + } + positions.set(key, chosen); + rows.push({ key, x: chosen.x, y: chosen.y, clearance }); + }); + + return { positions, occupied: rows }; +} + +function layoutFirstLevelEngineModel(model, seed = '', { skipKeys = new Set() } = {}) { + const out = cloneEngineModel(model); + const focusKey = normKey(out?.focusId); + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const focus = nodes.find((node) => normKey(node?.id) === focusKey); + const peers = nodes.filter((node) => { + const key = normKey(node?.id); + return key !== focusKey && !skipKeys.has(key) && (Number(node?.tier) || 1) < 2; + }); + const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; + const placed = placeCompactNodes(peers, { center: { x: 0, y: 0 }, seed: `${seed}|tier1`, occupied }); + + out.nodes = nodes.map((node) => { + const key = normKey(node?.id); + if (key === focusKey) { + return { ...node, id: key, layoutX: 0, layoutY: 0, fixedLayout: true, tier: 1, keepVisible: true }; + } + if (skipKeys.has(key)) { + const clean = { ...node, id: key, parentId: normKey(node?.parentId || focusKey) }; + delete clean.layoutX; + delete clean.layoutY; + clean.fixedLayout = false; + return clean; + } + const pos = placed.positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + parentId: normKey(node?.parentId || focusKey), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + }; + }); + return out; +} + +function layoutX2EngineModel(model, seed = '') { + const out = cloneEngineModel(model); + const focusKey = normKey(out?.focusId); + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const direct = nodes.filter((node) => normKey(node?.id) !== focusKey && (Number(node?.tier) || 1) === 1); + const deep = nodes.filter((node) => (Number(node?.tier) || 1) >= 2); + const childCount = new Map(); + deep.forEach((node) => { + const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id); + if (!parentKey) return; + childCount.set(parentKey, (childCount.get(parentKey) || 0) + 1); + }); + + const positions = new Map([[focusKey, { x: 0, y: 0 }]]); + const zones = [{ key: focusKey, x: 0, y: 0, radius: 72 }]; + const candidates = historyCompactSlots(Math.max(240, direct.length * 14), `${seed}|x2parents`, 92, 18); + + direct.forEach((node, index) => { + const key = normKey(node?.id); + const count = childCount.get(key) || 0; + const zoneRadius = Math.max(54, 48 + Math.ceil(Math.sqrt(count)) * 24); + let chosen = null; + for (const slot of candidates) { + const x = slot.x; + const y = slot.y; + const collision = zones.some((zone) => Math.hypot(x - zone.x, y - zone.y) < zoneRadius + zone.radius + 10); + if (!collision) { chosen = { x, y }; break; } + } + if (!chosen) { + const angle = historyHash01(`${seed}|x2parent|${key}`) * Math.PI * 2; + const radius = 180 + index * 34; + chosen = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; + } + positions.set(key, chosen); + zones.push({ key, x: chosen.x, y: chosen.y, radius: zoneRadius }); + }); + + const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; + direct.forEach((node) => { + const key = normKey(node?.id); + const pos = positions.get(key); + if (pos) occupied.push({ key, x: pos.x, y: pos.y, clearance: HISTORY_NODE_CLEARANCE }); + }); + + const deepByParent = new Map(); + deep.forEach((node) => { + const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id || focusKey); + const rows = deepByParent.get(parentKey) || []; + rows.push(node); + deepByParent.set(parentKey, rows); + }); + + for (const [parentKey, children] of deepByParent.entries()) { + const parentPos = positions.get(parentKey) || { x: 0, y: 0 }; + const placed = placeCompactNodes(children, { + center: parentPos, + seed: `${seed}|x2children|${parentKey}`, + spacing: HISTORY_TIER2_SPACING, + maxShell: 12, + occupied, + }); + placed.positions.forEach((pos, key) => positions.set(key, pos)); + occupied.splice(0, occupied.length, ...placed.occupied); + } + + out.nodes = nodes.map((node) => { + const key = normKey(node?.id); + const pos = positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + keepVisible: key === focusKey || Boolean(node?.keepVisible), + alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + }; + }); + return out; +} + +function refreshFirstLevelSnapshot(previousSnapshot, nextEngineModel, seed = '') { + const previousPositions = modelNodePositions(previousSnapshot?.engineModel); + if (!previousPositions.size) return layoutFirstLevelEngineModel(nextEngineModel, seed); + + const out = cloneEngineModel(nextEngineModel); + const focusKey = normKey(out?.focusId); + const keepKeys = new Set((Array.isArray(out.nodes) ? out.nodes : []).map((node) => normKey(node?.id)).filter(Boolean)); + const occupied = []; + const positions = new Map(); + + previousPositions.forEach((row, key) => { + if (!keepKeys.has(key)) return; + positions.set(key, { x: row.x, y: row.y }); + occupied.push({ key, x: row.x, y: row.y, clearance: row.clearance || HISTORY_NODE_CLEARANCE }); + }); + if (!positions.has(focusKey)) { + positions.set(focusKey, { x: 0, y: 0 }); + occupied.push({ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }); + } + + const newNodes = (Array.isArray(out.nodes) ? out.nodes : []).filter((node) => { + const key = normKey(node?.id); + return key && key !== focusKey && !positions.has(key) && (Number(node?.tier) || 1) < 2; + }); + const placed = placeCompactNodes(newNodes, { center: positions.get(focusKey) || { x: 0, y: 0 }, seed: `${seed}|refresh`, occupied }); + placed.positions.forEach((pos, key) => positions.set(key, pos)); + + out.nodes = out.nodes.map((node) => { + const key = normKey(node?.id); + const pos = positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + parentId: key === focusKey ? '' : normKey(node?.parentId || focusKey), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), + keepVisible: key === focusKey || Boolean(node?.keepVisible), + }; + }); + return out; +} + +function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { + const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); + const snapshots = (Array.isArray(history) ? history : []).slice(-(keepPrevious + 1)); + const latest = snapshots[snapshots.length - 1]; + if (!latest?.engineModel) return { focusId: '', nodes: [] }; + + const centerKeys = new Set(snapshots.map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); + const latestCenterKey = normKey(latest.centerLogin); + const centerNodeByKey = new Map(); const nodeMap = new Map(); - const latestOwner = new Map(); const edgeMap = new Map(); - snapshots.forEach((snap, snapIndex) => { + snapshots.forEach((snap) => { const centerKey = normKey(snap?.centerLogin); - if (!centerKey) return; - centerOrder.set(centerKey, snapIndex); - const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; - modelNodes.forEach((rawNode) => { + const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; + const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); + if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); + + snapNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; - const node = { - ...nodeMap.get(key), - ...rawNode, - id: key, - login: rawNode?.login || rawNode?.id || key, - }; - if (key === centerKey) { - node.tier = 1; - node.keepVisible = true; - node.isHistoryCenter = true; - } - nodeMap.set(key, node); - latestOwner.set(key, snapIndex); + // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы + // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. + if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); if (key === centerKey) return; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; @@ -449,216 +731,115 @@ function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { child: key, relationType: String(ref?.relationType || rawNode?.relationType || 'contact'), strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)), - snapIndex, }); }); }); }); - const occupied = []; - const placed = new Map(); - const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; - const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => { - if (ignoreKey && row.key === ignoreKey) return false; - return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); - }); - - function ownedNodesForSnapshot(snapIndex) { - const centerKey = normKey(snapshots[snapIndex]?.centerLogin); - return [...nodeMap.entries()] - .filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key)) - .map(([key, node]) => ({ key, node })); - } - - function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) { - const snap = snapshots[snapIndex]; - const centerKey = normKey(snap?.centerLogin); - const localPlaced = new Map([[centerKey, { ...center }]]); - const rows = baseOccupied.map((row) => ({ ...row })); - if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) { - return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows }; - } - rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE }); - - const owned = ownedNodesForSnapshot(snapIndex); - const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2); - const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2); - const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING); - let maxDirectShell = 0; - - direct.forEach(({ key, node }) => { - let chosen = null; - const clearance = clearanceForNode(node); - for (const slot of directSlots) { - const x = center.x + slot.x; - const y = center.y + slot.y; - if (!collides(x, y, clearance, rows, key)) { - chosen = { x, y, shell: slot.shell }; - break; - } - } - if (!chosen) { - const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2; - const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1); - chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 }; - } - maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0); - localPlaced.set(key, chosen); - rows.push({ key, x: chosen.x, y: chosen.y, clearance }); - }); - - const deepByParent = new Map(); - deep.forEach((row) => { - const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey); - const list = deepByParent.get(parentKey) || []; - list.push(row); - deepByParent.set(parentKey, list); - }); - for (const [parentKey, children] of deepByParent.entries()) { - const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center; - const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6); - children.forEach(({ key, node }) => { - let chosen = null; - const clearance = clearanceForNode(node); - for (const slot of slots) { - const x = parentPos.x + slot.x; - const y = parentPos.y + slot.y; - if (!collides(x, y, clearance, rows, key)) { - chosen = { x, y, shell: slot.shell }; - break; - } - } - if (!chosen) { - const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2; - const radius = HISTORY_TIER2_SPACING * 7; - chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 }; - } - localPlaced.set(key, chosen); - rows.push({ key, x: chosen.x, y: chosen.y, clearance }); - }); - } - - const idealDirectShell = historyIdealShell(direct.length); - const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1); - if (commit) { - localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y })); - occupied.splice(0, occupied.length, ...rows); - } - return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows }; - } - - // Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно. - const latestIndex = snapshots.length - 1; - tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true); - - // Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум: - // сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем - // наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap. - // Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку. - const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2]; - const compactnessScore = (rows, desiredAngleOffset = 0) => { - if (!rows.length) return 0; - let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; - rows.forEach((row) => { - const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE; - minX = Math.min(minX, row.x - c * 0.5); - maxX = Math.max(maxX, row.x + c * 0.5); - minY = Math.min(minY, row.y - c * 0.5); - maxY = Math.max(maxY, row.y + c * 0.5); - }); - const width = Math.max(1, maxX - minX); - const height = Math.max(1, maxY - minY); - const area = width * height; - const aspectPenalty = Math.abs(Math.log(width / height)); - // Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход - // от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот. - return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600; - }; - - for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) { - const newer = snapshots[snapIndex + 1]; - const newerKey = normKey(newer?.centerLogin); - const newerPos = placed.get(newerKey) || { x: 0, y: 0 }; - const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0; - let gap = HISTORY_CENTER_GAP_START; - let best = null; - - for (let attempt = 0; attempt < 40; attempt += 1) { - const candidates = []; - angleOffsets.forEach((offset) => { - const angle = baseAngle + offset; - const candidate = { - x: newerPos.x - Math.cos(angle) * gap, - y: newerPos.y - Math.sin(angle) * gap, - }; - const trial = tryPlaceCluster(snapIndex, candidate, occupied, false); - if (!trial.ok) return; - candidates.push({ - candidate, - offset, - score: compactnessScore(trial.rows, offset), - }); - }); - if (candidates.length) { - candidates.sort((a, b) => a.score - b.score); - best = candidates[0]; - break; - } - gap += HISTORY_CENTER_GAP_STEP; - } - - const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8); - const candidate = best?.candidate || { - x: newerPos.x - Math.cos(fallbackAngle) * gap, - y: newerPos.y - Math.sin(fallbackAngle) * gap, - }; - tryPlaceCluster(snapIndex, candidate, occupied, true); - } - - // На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot. - snapshots.forEach((snap) => { - const key = normKey(snap?.centerLogin); - if (!placed.has(key)) placed.set(key, { x: 0, y: 0 }); + centerNodeByKey.forEach((node, key) => { + if (key !== latestCenterKey) nodeMap.set(key, { ...node, id: key }); }); const edgeParentsByChild = new Map(); - const addEdgeParent = (childKey, parentKey, edge) => { - if (!childKey || !parentKey || childKey === parentKey) return; - const list = edgeParentsByChild.get(childKey) || []; - if (!list.some((row) => normKey(row?.id) === parentKey)) { - list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength }); - } - edgeParentsByChild.set(childKey, list); - }; for (const edge of edgeMap.values()) { if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; - addEdgeParent(edge.child, edge.parent, edge); + const list = edgeParentsByChild.get(edge.child) || []; + if (!list.some((row) => normKey(row?.id) === edge.parent)) { + list.push({ id: edge.parent, relationType: edge.relationType, strength: edge.strength }); + } + edgeParentsByChild.set(edge.child, list); } - const nodes = [...nodeMap.entries()].map(([key, node]) => { - const pos = placed.get(key) || { x: 0, y: 0 }; - const isCenter = centerOrder.has(key); - return { - ...node, - id: key, - login: node.login || node.id || key, - tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1), - layoutX: pos.x, - layoutY: pos.y, - fixedLayout: true, - keepVisible: isCenter || Boolean(node.keepVisible), - alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), - edgeParents: edgeParentsByChild.get(key) || [], - }; - }); + const nodes = [...nodeMap.entries()].map(([key, node]) => ({ + ...node, + id: key, + login: node?.login || node?.id || key, + tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), + fixedLayout: true, + keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), + alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + edgeParents: edgeParentsByChild.get(key) || [], + })); return { - focusId: normKey(latest.engineModel.focusId), + focusId: latestCenterKey, nodes, preserveHistory: snapshots.length > 1, }; } +function historyExistingCenterKeys(history) { + return new Set((Array.isArray(history) ? history : []).map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); +} + +function chooseNewClusterCenter(history, newLocalModel, { transitionAngle = 0, transitionX = 0, transitionY = 0 } = {}) { + const currentModel = buildStableHistoryEngineModel(history, HISTORY_MAX_PREVIOUS); + const existing = modelNodePositions(currentModel); + const existingCenters = historyExistingCenterKeys(history); + const focusKey = normKey(newLocalModel?.focusId); + const localPositions = modelNodePositions(newLocalModel); + const movingKeys = new Set([...localPositions.keys()].filter((key) => !existingCenters.has(key) || key === focusKey)); + const blockers = [...existing.entries()] + .filter(([key]) => !movingKeys.has(key)) + .map(([key, row]) => ({ key, ...row })); + + const directDistance = Math.hypot(Number(transitionX) || 0, Number(transitionY) || 0); + let radius = Math.max(104, directDistance + 24); + const baseAngle = Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0; + const offsets = [0, Math.PI / 6, -Math.PI / 6, Math.PI / 3, -Math.PI / 3, Math.PI / 2, -Math.PI / 2, Math.PI]; + + for (let attempt = 0; attempt < 56; attempt += 1) { + let best = null; + offsets.forEach((offset) => { + const angle = baseAngle + offset; + const cx = Math.cos(angle) * radius; + const cy = Math.sin(angle) * radius; + let ok = true; + for (const [key, row] of localPositions.entries()) { + if (!movingKeys.has(key)) continue; + const x = cx + row.x; + const y = cy + row.y; + const hit = blockers.some((blocker) => Math.hypot(x - blocker.x, y - blocker.y) < Math.max(row.clearance || HISTORY_NODE_CLEARANCE, blocker.clearance || HISTORY_NODE_CLEARANCE)); + if (hit) { ok = false; break; } + } + if (!ok) return; + const anglePenalty = Math.abs(offset) * 18; + const score = radius + anglePenalty; + if (!best || score < best.score) best = { x: cx, y: cy, score }; + }); + if (best) return best; + radius += HISTORY_CENTER_GAP_STEP; + } + + return { x: Math.cos(baseAngle) * radius, y: Math.sin(baseAngle) * radius }; +} + +function appendStableSnapshot(history, snapshot, transition = {}) { + let baseHistory = (Array.isArray(history) ? history : []).filter((row) => normKey(row?.centerLogin) !== normKey(snapshot?.centerLogin)); + const existingCenters = historyExistingCenterKeys(baseHistory); + const local = layoutFirstLevelEngineModel(snapshot.engineModel, normKey(snapshot.centerLogin), { skipKeys: existingCenters }); + const candidate = chooseNewClusterCenter(baseHistory, local, transition); + + // Новый центр становится (0,0), поэтому старую карту переносим ЦЕЛИКОМ на противоположный вектор. + // Внутренние координаты старых кластеров не пересчитываются — они остаются визуально теми же блоками. + baseHistory = baseHistory.map((row) => translateSnapshot(row, -candidate.x, -candidate.y)); + const shiftedExisting = modelNodePositions(buildStableHistoryEngineModel(baseHistory, HISTORY_MAX_PREVIOUS)); + + // Если в новом круге встречается уже бывший исторический центр, не затаскиваем его обратно к новому центру: + // он остаётся якорем своего старого кластера, а новая связь просто тянется к нему. + const finalModel = cloneEngineModel(local); + finalModel.nodes = finalModel.nodes.map((node) => { + const key = normKey(node?.id); + if (key !== normKey(finalModel.focusId) && existingCenters.has(key)) { + const old = shiftedExisting.get(key); + if (old) return { ...node, layoutX: old.x, layoutY: old.y, fixedLayout: true, tier: 1, keepVisible: true }; + } + return node; + }); + + return [...baseHistory, { ...snapshot, engineModel: finalModel }]; +} + export function render({ navigate, route, chrome } = {}) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); @@ -684,7 +865,6 @@ export function render({ navigate, route, chrome } = {}) { let x2Enabled = Boolean(persistedX2Enabled); let engine = null; let loadSeq = 0; - const graphCache = new Map(); let historyChip = null; let x2Chip = null; @@ -732,7 +912,7 @@ export function render({ navigate, route, chrome } = {}) { } function rebuildEngineFromHistory() { - const engineModel = buildHistoryEngineModel(graphHistory, historyDepth); + const engineModel = buildStableHistoryEngineModel(graphHistory, historyDepth); ensureEngine(engineModel); if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); } @@ -742,9 +922,13 @@ export function render({ navigate, route, chrome } = {}) { historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История'; historyChip.classList.toggle('is-active', historyDepth > 0); historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false'); - historyChip.title = historyDepth > 0 - ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` - : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'; + historyChip.disabled = x2Enabled; + historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false'); + historyChip.title = x2Enabled + ? 'X2 показывает отдельную карту и временно не использует историю.' + : (historyDepth > 0 + ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` + : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'); } function cycleHistoryDepth() { @@ -762,13 +946,29 @@ export function render({ navigate, route, chrome } = {}) { x2Chip.classList.toggle('is-active', x2Enabled); x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; + updateHistoryChip(); } async function toggleX2() { - x2Enabled = !x2Enabled; + const previousHistory = [...graphHistory]; + const previousCenterHistory = [...centerHistory]; + const enabling = !x2Enabled; + x2Enabled = enabling; + // X2 — отдельный режим одной центральной карты. При входе и выходе из него история очищается: + // это не ещё один исторический слой, а полный снимок «центр → все друзья → все друзья друзей». + centerHistory = []; + graphHistory = []; updateX2Chip(); - persistedX2Enabled = x2Enabled; - await load(centerLogin, { pushHistory: false }); + persistHistory(); + await load(centerLogin, { pushHistory: false, resetHistory: true }); + // Если полный X2 не собрался, load выключает флаг. Возвращаем предыдущую обычную карту, + // чтобы сетевой сбой не стирал уже нарисованную историю пользователя. + if (enabling && !x2Enabled) { + graphHistory = previousHistory; + centerHistory = previousCenterHistory; + rebuildEngineFromHistory(); + persistHistory(); + } } function syncLinksUrl(login, { push = false } = {}) { @@ -898,6 +1098,32 @@ export function render({ navigate, route, chrome } = {}) { window.setTimeout(() => inputEl.focus(), 0); } + function persistManualNodePosition(nodeId, point) { + const key = normKey(nodeId); + const x = Number(point?.x); + const y = Number(point?.y); + if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + + // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все + // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. + graphHistory = graphHistory.map((snapshot) => { + const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); + let changed = false; + model.nodes = model.nodes.map((node) => { + if (normKey(node?.id) !== key) return node; + changed = true; + return { + ...node, + layoutX: x, + layoutY: y, + fixedLayout: true, + }; + }); + return changed ? { ...snapshot, engineModel: model } : snapshot; + }); + persistHistory(); + } + function ensureEngine(model) { if (engine) { engine.setModel(model); @@ -908,8 +1134,20 @@ export function render({ navigate, route, chrome } = {}) { model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет onNodeTap: (node) => { - const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0); - void load(node.login, { pushHistory: true, transitionAngle }); + const transitionX = Number(node?.x) || 0; + const transitionY = Number(node?.y) || 0; + const transitionAngle = Math.atan2(transitionY, transitionX); + if (x2Enabled) { + // Клик внутри X2 начинает новую обычную историю от выбранного человека. + x2Enabled = false; + centerHistory = []; + graphHistory = []; + updateX2Chip(); + persistHistory(); + void load(node.login, { pushHistory: false, resetHistory: true }); + return; + } + void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); }, // тап по центру — полноценный профиль onCenterTap: (node) => { @@ -930,70 +1168,97 @@ export function render({ navigate, route, chrome } = {}) { ], }); }, + // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра + // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. + onNodeMoveEnd: (node, point) => { + persistManualNodePosition(node?.id || node?.login, point); + }, }); } - async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) { + async function load(nextCenterLogin = '', { + pushHistory = false, + transitionAngle = 0, + transitionX = 0, + transitionY = 0, + resetHistory = false, + } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); try { - const graph = await authService.getUserConnectionsGraph(targetCenter); - graphCache.set(normKey(targetCenter), graph); + // Общий module-level cache переживает повторные открытия экрана. Одновременные запросы одного + // логина дедуплицируются; для X2 каждый друг первого уровня дополнительно получает один retry. + const graph = await getConnectionsGraphCached(targetCenter); if (requestId !== loadSeq) return; centerLogin = targetCenter; - if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { - centerHistory.push(prevCenter); - } syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); let snapshotModel = engineModelFromGraphModel(graphModel); + if (x2Enabled) { - snapshotModel = await buildSecondLevelEngineModel(snapshotModel, async (login) => { - const key = normKey(login); - if (graphCache.has(key)) return graphCache.get(key); - const childGraph = await authService.getUserConnectionsGraph(login); - graphCache.set(key, childGraph); - return childGraph; - }); + // СНАЧАЛА полностью собираем второй уровень для КАЖДОГО друга центра, и только после успешного + // завершения всех запросов один раз отдаём цельную X2-модель движку. Частичный X2 не рисуем. + snapshotModel = await buildSecondLevelEngineModel(snapshotModel, (login) => ( + getConnectionsGraphCached(login, { retries: 1 }) + )); if (requestId !== loadSeq) return; - } - - const snapshot = { - centerLogin: targetCenter, - engineModel: snapshotModel, - transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, - }; - - if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { - // X2 относится только к ТЕКУЩЕМУ центру. Как только центр уходит в историю, оставляем его - // первый уровень — иначе четыре исторических слоя с друзьями друзей быстро превратятся в кашу. - graphHistory = graphHistory.map((row) => ({ ...row, engineModel: stripSecondLevel(row.engineModel) })); - graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); - graphHistory.push(snapshot); - const maxSnapshots = historyDepth + 1; - graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); - centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; - } 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; + snapshotModel = layoutX2EngineModel(snapshotModel, normKey(targetCenter)); + centerHistory = []; + graphHistory = [{ + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: 0, + }]; + } else if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { + const rawSnapshot = { + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, + }; + if (historyDepth > 0) { + graphHistory = appendStableSnapshot(graphHistory, rawSnapshot, { + transitionAngle, + transitionX, + transitionY, + }); + graphHistory = graphHistory.slice(-(historyDepth + 1)); + centerHistory.push(prevCenter); + centerHistory = centerHistory.slice(-historyDepth); } else { - graphHistory = [snapshot]; + graphHistory = [{ ...rawSnapshot, engineModel: layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)) }]; + centerHistory = []; } + } else { + const last = !resetHistory ? graphHistory[graphHistory.length - 1] : null; + const stableModel = last && normKey(last.centerLogin) === normKey(targetCenter) + ? refreshFirstLevelSnapshot(last, snapshotModel, normKey(targetCenter)) + : layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)); + centerHistory = []; + graphHistory = [{ + centerLogin: targetCenter, + engineModel: stableModel, + transitionAngle: Number(last?.transitionAngle) || 0, + }]; } rebuildEngineFromHistory(); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; + // Если X2 не удалось собрать полностью, не оставляем интерфейс в ложном активном состоянии. + if (x2Enabled) { + x2Enabled = false; + updateX2Chip(); + persistedX2Enabled = false; + } window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); } } + const searchIconHtml = `