From 4991a146d5927e065bd595996a373a9f83480294941660b8b18074e45b451372 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 19:12:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20-=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D1=80=D0=B5=D1=81=D0=BD=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F?= 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 | 689 ++++++++++++++++------- shine-UI/js/pages/network/force-graph.js | 20 +- shine-UI/styles/network-graph.css | 35 +- 4 files changed, 524 insertions(+), 222 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 69c6d861..a41defa7 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.12 +client.version=1.12.13 server.version=1.10.4 diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 25f452ca..1cfd0983 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -92,8 +92,14 @@ function getMarkByLogin(allUsers) { relationType: String(row?.relationType || '').trim().toLowerCase(), primaryConfirmed: Boolean(row?.primaryConfirmed), shineConfirmed: Boolean(row?.shineConfirmed), - official: Boolean(row?.official), - shine: Boolean(row?.shine), + // Основной источник — server official. accountRole оставляем как совместимый fallback, + // чтобы badge не пропадал на серверах/ответах переходного периода. + official: row?.official === true + || String(row?.official || '').trim().toLowerCase() === 'true' + || String(row?.accountRole || '').trim().toLowerCase() === 'primary', + shine: row?.shine === true + || String(row?.shine || '').trim().toLowerCase() === 'true' + || String(row?.shineStatus || '').trim().toLowerCase() === 'shining', officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')), shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')), avatar: normalizeAvatar(row), @@ -202,15 +208,18 @@ function buildGraphModel(graph, centerLogin) { let persistedCenterLogin = ''; let persistedCenterHistory = []; let persistedGraphHistory = []; +let persistedHistoryDepth = 4; +let persistedX2Enabled = false; -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; +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; +const HISTORY_TIER2_CLEARANCE = 48; +const HISTORY_DIRECT_SPACING = 92; +const HISTORY_TIER2_SPACING = 54; +const HISTORY_LAYOUT_MAX_SHELL = 9; function historyHash01(value) { let h = 2166136261; @@ -219,244 +228,429 @@ function historyHash01(value) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 1000) / 1000; + return ((h >>> 0) % 100000) / 100000; } -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 historySquareShellCells(shell) { + const r = Math.max(1, Math.trunc(shell)); + const cells = []; + for (let y = -r; y <= r; y += 1) { + for (let x = -r; x <= r; x += 1) { + if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue; + cells.push({ x, y }); + } + } + return cells; } -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), - }; +// Компактная «квадратная» раскладка: сначала заполняем ближайший квадратный пояс, +// причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей +// не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра. +function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) { + const count = Math.max(0, Math.trunc(Number(total) || 0)); + const out = []; + let globalIndex = 0; + for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) { + const remaining = historySquareShellCells(shell); + const ordered = []; + const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0; + if (remaining.length) ordered.push(remaining.splice(start, 1)[0]); + while (remaining.length) { + let bestIndex = 0; + let bestScore = -Infinity; + remaining.forEach((cell, index) => { + let minD2 = Infinity; + for (const used of ordered) { + const dx = cell.x - used.x; + const dy = cell.y - used.y; + minD2 = Math.min(minD2, dx * dx + dy * dy); + } + const jitter = historyHash01(`${seed}|${shell}|${cell.x}|${cell.y}`) * 0.05; + const score = minD2 + jitter; + if (score > bestScore) { bestScore = score; bestIndex = index; } + }); + ordered.push(remaining.splice(bestIndex, 1)[0]); + } + + const quarterTurns = Math.floor(historyHash01(`${seed}|rotate`) * 4); + const turn = (cell) => { + let { x, y } = cell; + for (let i = 0; i < quarterTurns; i += 1) [x, y] = [-y, x]; + return { x, y }; + }; + for (const raw of ordered) { + if (out.length >= count) break; + const cell = turn(raw); + const jx = (historyHash01(`${seed}|${globalIndex}|x`) - 0.5) * spacing * 0.10; + const jy = (historyHash01(`${seed}|${globalIndex}|y`) - 0.5) * spacing * 0.10; + out.push({ + x: cell.x * spacing + jx, + y: cell.y * spacing + jy, + shell, + }); + globalIndex += 1; } - 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 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 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: [] }; +function relationEdge(parentId, node) { + return { + id: normKey(parentId), + relationType: String(node?.relationType || 'contact'), + strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)), + }; +} - 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)); +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); + let cursor = 0; + const runners = new Array(Math.min(Math.max(1, concurrency), list.length)).fill(0).map(async () => { + while (cursor < list.length) { + const index = cursor++; + try { out[index] = await worker(list[index], index); } + catch (error) { out[index] = { error }; } + } + }); + await Promise.all(runners); + return out; +} + +async function buildSecondLevelEngineModel(baseModel, getGraph) { + const focusKey = normKey(baseModel?.focusId); + const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : []; + const byKey = new Map(); + + baseNodes.forEach((src) => { + const key = normKey(src?.id); + if (!key) return; + const isFocus = key === focusKey; + const normalized = { + ...src, + id: key, + login: src?.login || src?.id || key, + tier: 1, + parentId: isFocus ? '' : focusKey, + edgeParents: isFocus ? [] : [relationEdge(focusKey, src)], + }; + byKey.set(key, normalized); }); - // Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода - // настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались. - 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; + const directFriends = [...byKey.values()].filter((node) => ( + normKey(node.id) !== focusKey + && (node.relationType === 'friend' || node.relationType === 'close_friend') + )); - 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; - } + 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) }; + }); + + fetched.forEach((row) => { + if (!row || row.error || !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; } - 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, + byKey.set(childKey, { + ...child, + id: childKey, + login: child?.login || child?.id || childKey, + tier: 2, + parentId: parentKey, + alwaysVisible: true, + edgeParents: [edge], }); }); }); - // Все исторические центры сразу считаются занятыми местами. - const occupied = []; - for (const [centerKey, pos] of centerPos.entries()) { - occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE }); - } + return { ...baseModel, nodes: [...byKey.values()] }; +} - const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => { - if (row.key === key) return false; +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 centerOrder = new Map(); + const nodeMap = new Map(); + const latestOwner = new Map(); + const edgeMap = new Map(); + + snapshots.forEach((snap, snapIndex) => { + 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 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 (key === centerKey) return; + let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; + if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)]; + parents.forEach((ref) => { + const parentKey = normKey(ref?.id || centerKey); + if (!parentKey || parentKey === key) return; + const a = parentKey < key ? parentKey : key; + const b = parentKey < key ? key : parentKey; + edgeMap.set(`${a}|${b}`, { + parent: parentKey, + 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); }); - // Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь - // встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге. - 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 }; + 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 })); + } - 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 }; + 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; - // Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус. - 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; - } + 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 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, - }; + 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 }; } - latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex }); - occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE }); + 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 }; } - 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, + // Текущий центр всегда в (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); } - 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, - }); - } + // На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot. + snapshots.forEach((snap) => { + const key = normKey(snap?.centerLogin); + if (!placed.has(key)) placed.set(key, { x: 0, y: 0 }); + }); 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, - }); + 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()) { - 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); + if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; + addEdgeParent(edge.child, edge.parent, 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) || [], - })); + 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) || [], + }; + }); return { focusId: normKey(latest.engineModel.focusId), @@ -486,8 +680,13 @@ 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 historyDepth = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Number(persistedHistoryDepth) || 0)); + let x2Enabled = Boolean(persistedX2Enabled); let engine = null; let loadSeq = 0; + const graphCache = new Map(); + let historyChip = null; + let x2Chip = null; // Независимые фильтры карты. Оба выключены = показываем всё. // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. @@ -528,6 +727,48 @@ export function render({ navigate, route, chrome } = {}) { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; persistedGraphHistory = [...graphHistory]; + persistedHistoryDepth = historyDepth; + persistedX2Enabled = x2Enabled; + } + + function rebuildEngineFromHistory() { + const engineModel = buildHistoryEngineModel(graphHistory, historyDepth); + ensureEngine(engineModel); + if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); + } + + function updateHistoryChip() { + if (!(historyChip instanceof HTMLButtonElement)) return; + 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 предыдущий центр.'; + } + + function cycleHistoryDepth() { + historyDepth = historyDepth >= HISTORY_MAX_PREVIOUS ? 0 : historyDepth + 1; + const maxSnapshots = historyDepth + 1; + graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); + centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; + updateHistoryChip(); + rebuildEngineFromHistory(); + persistHistory(); + } + + function updateX2Chip() { + if (!(x2Chip instanceof HTMLButtonElement)) return; + x2Chip.classList.toggle('is-active', x2Enabled); + x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); + x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; + } + + async function toggleX2() { + x2Enabled = !x2Enabled; + updateX2Chip(); + persistedX2Enabled = x2Enabled; + await load(centerLogin, { pushHistory: false }); } function syncLinksUrl(login, { push = false } = {}) { @@ -699,6 +940,7 @@ export function render({ navigate, route, chrome } = {}) { try { const graph = await authService.getUserConnectionsGraph(targetCenter); + graphCache.set(normKey(targetCenter), graph); if (requestId !== loadSeq) return; centerLogin = targetCenter; if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { @@ -707,7 +949,18 @@ export function render({ navigate, route, chrome } = {}) { syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); - const snapshotModel = engineModelFromGraphModel(graphModel); + 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; + }); + if (requestId !== loadSeq) return; + } + const snapshot = { centerLogin: targetCenter, engineModel: snapshotModel, @@ -715,14 +968,14 @@ export function render({ navigate, route, chrome } = {}) { }; 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); - if (graphHistory.length > HISTORY_MAX_CENTERS) { - graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS); - centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1)); - } + 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)) { @@ -733,10 +986,7 @@ export function render({ navigate, route, chrome } = {}) { } } - const engineModel = buildHistoryEngineModel(graphHistory); - ensureEngine(engineModel); - if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); - + rebuildEngineFromHistory(); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; @@ -810,6 +1060,21 @@ export function render({ navigate, route, chrome } = {}) { filterBar.append(chip); }); + historyChip = document.createElement('button'); + historyChip.type = 'button'; + historyChip.className = 'fg-filter-chip fg-history-chip'; + historyChip.addEventListener('click', cycleHistoryDepth); + filterBar.append(historyChip); + updateHistoryChip(); + + x2Chip = document.createElement('button'); + x2Chip.type = 'button'; + x2Chip.className = 'fg-filter-chip fg-x2-chip'; + x2Chip.textContent = 'X2'; + x2Chip.addEventListener('click', () => { void toggleX2(); }); + filterBar.append(x2Chip); + updateX2Chip(); + chrome?.setTopbar(header); stage.append(board, filterBar); screen.append(stage); diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index edd46228..0daed44a 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -471,6 +471,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [], fixedLayout, keepVisible: Boolean(src.keepVisible), + alwaysVisible: Boolean(src.alwaysVisible), official: Boolean(src.official), deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко @@ -530,7 +531,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'; + const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg?v=2'; function buildPngOrb(src, opts) { const o = opts || {}; const wrap = document.createElement('div'); @@ -657,6 +658,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.shining = Boolean(src.shining); node.official = Boolean(src.official); node.keepVisible = Boolean(src.keepVisible); + node.alwaysVisible = Boolean(src.alwaysVisible); node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; const layoutX = Number(src.layoutX); const layoutY = Number(src.layoutY); @@ -772,6 +774,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const tier of [2, 3]) { for (const n of nodes) { if (n.tier !== tier) continue; + // Реальный X2 из network-view приходит уже с collision-aware фиксированной позицией и должен + // быть виден сразу, без клика/hover по родителю. Лабораторные deep-ветки без fixedLayout + // продолжают работать по старой схеме раскрытия expandP. + if (n.fixedLayout && n.alwaysVisible) { + n.x = n.tx; + n.y = n.ty; + const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY; + const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1); + n.opacity = n.hidden ? 0 : baseOp; + n.scale = baseSc; + n.targetOpacity = n.opacity; + n.targetScale = n.scale; + continue; + } const p = nodeById.get(n.parentId); if (!p) { n.opacity = 0; continue; } const e = p.expandP || 0; // насколько раскрыт родитель @@ -1032,7 +1048,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2; dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; } - const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) + const pe = n.alwaysVisible ? 1 : (parent.expandP || 0); // X2 виден сразу; старые deep-ветки — по раскрытию if (n.tier >= 3) { // 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро). if (pe > 0.02) { diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 4736a2a6..3a6c95da 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -203,17 +203,24 @@ /* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */ -.fg-official-badge { +/* ВАЖНО: селектор намеренно специфичнее глобального `.node-dot img` из features/network.css, + где обычные фото стартуют с opacity:0 и width/height:100%. Иначе badge тоже наследует эти + правила и становится полностью невидимым. */ +.fg-node .node-dot .fg-official-badge { position: absolute; left: -2%; - bottom: 0; - width: 15%; - height: 15%; - min-width: 8px; - min-height: 8px; + bottom: -1%; + width: 16%; + height: 16%; + min-width: 9px; + min-height: 9px; object-fit: contain; + object-position: center; display: block; - z-index: 5; + opacity: 1; + border-radius: 0; + transition: none; + z-index: 20; pointer-events: none; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); } @@ -452,6 +459,20 @@ box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28); } +/* Служебные переключатели карты: история и X2 намеренно чуть компактнее основных фильтров. */ +.fg-history-chip { + padding-left: 11px; + padding-right: 11px; +} + +.fg-x2-chip { + min-width: 36px; + padding-left: 9px; + padding-right: 9px; + font-weight: 750; + letter-spacing: 0.02em; +} + /* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */ .fg-menu-overlay { position: fixed;