Связи - интересно но надо дорабатывать

This commit is contained in:
AidarKC
2026-09-10 14:52:20 +03:00
parent d83f1d4cce
commit 849250bfa8
9 changed files with 613 additions and 131 deletions
+318 -14
View File
@@ -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;