SHA256
Связи - интересно но надо дорабатывать
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user