SHA256
Связи - интересная версия
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.12
|
client.version=1.12.13
|
||||||
server.version=1.10.4
|
server.version=1.10.4
|
||||||
|
|||||||
+463
-198
@@ -92,8 +92,14 @@ function getMarkByLogin(allUsers) {
|
|||||||
relationType: String(row?.relationType || '').trim().toLowerCase(),
|
relationType: String(row?.relationType || '').trim().toLowerCase(),
|
||||||
primaryConfirmed: Boolean(row?.primaryConfirmed),
|
primaryConfirmed: Boolean(row?.primaryConfirmed),
|
||||||
shineConfirmed: Boolean(row?.shineConfirmed),
|
shineConfirmed: Boolean(row?.shineConfirmed),
|
||||||
official: Boolean(row?.official),
|
// Основной источник — server official. accountRole оставляем как совместимый fallback,
|
||||||
shine: Boolean(row?.shine),
|
// чтобы 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 ? 'официальный' : 'неофициальный')),
|
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
||||||
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
|
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
|
||||||
avatar: normalizeAvatar(row),
|
avatar: normalizeAvatar(row),
|
||||||
@@ -202,15 +208,18 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
let persistedCenterLogin = '';
|
let persistedCenterLogin = '';
|
||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
let persistedGraphHistory = [];
|
let persistedGraphHistory = [];
|
||||||
|
let persistedHistoryDepth = 4;
|
||||||
|
let persistedX2Enabled = false;
|
||||||
|
|
||||||
const HISTORY_MAX_CENTERS = 4;
|
const HISTORY_MAX_PREVIOUS = 4;
|
||||||
const HISTORY_CENTER_GAP_MIN = 280;
|
const HISTORY_CENTER_GAP_START = 108;
|
||||||
const HISTORY_CENTER_GAP_STEP = 72;
|
const HISTORY_CENTER_GAP_STEP = 28;
|
||||||
const HISTORY_CENTER_CLEARANCE = 110;
|
const HISTORY_CENTER_CLEARANCE = 88;
|
||||||
const HISTORY_ORBIT_FIRST_R = 104;
|
const HISTORY_NODE_CLEARANCE = 82;
|
||||||
const HISTORY_ORBIT_GAP = 88;
|
const HISTORY_TIER2_CLEARANCE = 48;
|
||||||
const HISTORY_ORBIT_NODE_GAP = 84;
|
const HISTORY_DIRECT_SPACING = 92;
|
||||||
const HISTORY_NODE_CLEARANCE = 92;
|
const HISTORY_TIER2_SPACING = 54;
|
||||||
|
const HISTORY_LAYOUT_MAX_SHELL = 9;
|
||||||
|
|
||||||
function historyHash01(value) {
|
function historyHash01(value) {
|
||||||
let h = 2166136261;
|
let h = 2166136261;
|
||||||
@@ -219,244 +228,429 @@ function historyHash01(value) {
|
|||||||
h ^= text.charCodeAt(i);
|
h ^= text.charCodeAt(i);
|
||||||
h = Math.imul(h, 16777619);
|
h = Math.imul(h, 16777619);
|
||||||
}
|
}
|
||||||
return ((h >>> 0) % 1000) / 1000;
|
return ((h >>> 0) % 100000) / 100000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyOrbitCapacity(radius) {
|
function historySquareShellCells(shell) {
|
||||||
const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1);
|
const r = Math.max(1, Math.trunc(shell));
|
||||||
const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r)));
|
const cells = [];
|
||||||
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
|
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);
|
// причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей
|
||||||
const out = new Array(count);
|
// не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра.
|
||||||
let start = 0;
|
function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) {
|
||||||
let ring = 0;
|
const count = Math.max(0, Math.trunc(Number(total) || 0));
|
||||||
const seedPhase = historyHash01(seed) * Math.PI * 2;
|
const out = [];
|
||||||
while (start < count) {
|
let globalIndex = 0;
|
||||||
const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP;
|
for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) {
|
||||||
const capacity = historyOrbitCapacity(radius);
|
const remaining = historySquareShellCells(shell);
|
||||||
const ringCount = Math.min(capacity, count - start);
|
const ordered = [];
|
||||||
const phase = seedPhase + ring * 0.43;
|
const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0;
|
||||||
for (let i = 0; i < ringCount; i += 1) {
|
if (remaining.length) ordered.push(remaining.splice(start, 1)[0]);
|
||||||
out[start + i] = {
|
while (remaining.length) {
|
||||||
radius,
|
let bestIndex = 0;
|
||||||
angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount),
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyClusterRadius(snapshot) {
|
function historyIdealShell(count) {
|
||||||
const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : [];
|
const n = Math.max(0, Math.trunc(Number(count) || 0));
|
||||||
const centerKey = normKey(snapshot?.centerLogin);
|
if (!n) return 0;
|
||||||
const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length;
|
let shell = 1;
|
||||||
const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || '');
|
while (4 * shell * (shell + 1) < n) shell += 1;
|
||||||
const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0);
|
return shell;
|
||||||
return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHistoryEngineModel(history) {
|
function relationEdge(parentId, node) {
|
||||||
const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS);
|
return {
|
||||||
|
id: normKey(parentId),
|
||||||
|
relationType: String(node?.relationType || 'contact'),
|
||||||
|
strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const directFriends = [...byKey.values()].filter((node) => (
|
||||||
|
normKey(node.id) !== focusKey
|
||||||
|
&& (node.relationType === 'friend' || node.relationType === 'close_friend')
|
||||||
|
));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
byKey.set(childKey, {
|
||||||
|
...child,
|
||||||
|
id: childKey,
|
||||||
|
login: child?.login || child?.id || childKey,
|
||||||
|
tier: 2,
|
||||||
|
parentId: parentKey,
|
||||||
|
alwaysVisible: true,
|
||||||
|
edgeParents: [edge],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
const latest = snapshots[snapshots.length - 1];
|
||||||
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
||||||
|
|
||||||
const centerPos = new Map();
|
|
||||||
const centerOrder = 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 nodeMap = new Map();
|
||||||
const latestPlacementParent = new Map();
|
const latestOwner = new Map();
|
||||||
const edgeMap = new Map();
|
const edgeMap = new Map();
|
||||||
|
|
||||||
// Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям.
|
|
||||||
snapshots.forEach((snap, snapIndex) => {
|
snapshots.forEach((snap, snapIndex) => {
|
||||||
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
const centerKey = normKey(snap?.centerLogin);
|
||||||
const centerKey = normKey(snap.centerLogin);
|
if (!centerKey) return;
|
||||||
const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0];
|
centerOrder.set(centerKey, snapIndex);
|
||||||
if (center) {
|
const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||||
nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true });
|
modelNodes.forEach((rawNode) => {
|
||||||
}
|
const key = normKey(rawNode?.id);
|
||||||
|
|
||||||
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
|
||||||
relations.forEach((node) => {
|
|
||||||
const key = normKey(node?.id);
|
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
nodeMap.set(key, { ...nodeMap.get(key), ...node });
|
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);
|
||||||
|
|
||||||
const a = centerKey;
|
if (key === centerKey) return;
|
||||||
const b = key;
|
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
|
||||||
const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`;
|
if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)];
|
||||||
edgeMap.set(edgeKey, {
|
parents.forEach((ref) => {
|
||||||
a,
|
const parentKey = normKey(ref?.id || centerKey);
|
||||||
b,
|
if (!parentKey || parentKey === key) return;
|
||||||
relationType: node.relationType || 'contact',
|
const a = parentKey < key ? parentKey : key;
|
||||||
strength: Number(node.strength) || 0.5,
|
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,
|
snapIndex,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Все исторические центры сразу считаются занятыми местами.
|
|
||||||
const occupied = [];
|
const occupied = [];
|
||||||
for (const [centerKey, pos] of centerPos.entries()) {
|
const placed = new Map();
|
||||||
occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE });
|
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;
|
||||||
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);
|
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь
|
function ownedNodesForSnapshot(snapIndex) {
|
||||||
// встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге.
|
const centerKey = normKey(snapshots[snapIndex]?.centerLogin);
|
||||||
for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) {
|
return [...nodeMap.entries()]
|
||||||
const snap = snapshots[snapIndex];
|
.filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key))
|
||||||
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
.map(([key, node]) => ({ key, node }));
|
||||||
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) => {
|
function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) {
|
||||||
const key = normKey(node?.id);
|
const snap = snapshots[snapIndex];
|
||||||
if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return;
|
const centerKey = normKey(snap?.centerLogin);
|
||||||
const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 };
|
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;
|
let chosen = null;
|
||||||
// Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус.
|
const clearance = clearanceForNode(node);
|
||||||
for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) {
|
for (const slot of directSlots) {
|
||||||
const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55;
|
const x = center.x + slot.x;
|
||||||
const angularSamples = 18 + radialStep * 2;
|
const y = center.y + slot.y;
|
||||||
for (let step = 0; step < angularSamples; step += 1) {
|
if (!collides(x, y, clearance, rows, key)) {
|
||||||
const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1);
|
chosen = { x, y, shell: slot.shell };
|
||||||
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;
|
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) {
|
if (!chosen) {
|
||||||
const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55;
|
const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2;
|
||||||
chosen = {
|
const radius = HISTORY_TIER2_SPACING * 7;
|
||||||
x: base.x + Math.cos(preferred.angle) * radius,
|
chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 };
|
||||||
y: base.y + Math.sin(preferred.angle) * radius,
|
}
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex });
|
for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) {
|
||||||
occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE });
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [centerKey, pos] of centerPos.entries()) {
|
const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8);
|
||||||
const node = nodeMap.get(centerKey);
|
const candidate = best?.candidate || {
|
||||||
if (!node) continue;
|
x: newerPos.x - Math.cos(fallbackAngle) * gap,
|
||||||
nodeMap.set(centerKey, {
|
y: newerPos.y - Math.sin(fallbackAngle) * gap,
|
||||||
...node,
|
};
|
||||||
layoutX: pos.x,
|
tryPlaceCluster(snapIndex, candidate, occupied, true);
|
||||||
layoutY: pos.y,
|
|
||||||
fixedLayout: true,
|
|
||||||
keepVisible: true,
|
|
||||||
isHistoryCenter: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, placement] of latestPlacementParent.entries()) {
|
// На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot.
|
||||||
const node = nodeMap.get(key);
|
snapshots.forEach((snap) => {
|
||||||
if (!node || centerOrder.has(key)) continue;
|
const key = normKey(snap?.centerLogin);
|
||||||
nodeMap.set(key, {
|
if (!placed.has(key)) placed.set(key, { x: 0, y: 0 });
|
||||||
...node,
|
|
||||||
layoutX: placement.x,
|
|
||||||
layoutY: placement.y,
|
|
||||||
fixedLayout: true,
|
|
||||||
parentId: placement.centerKey,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const edgeParentsByChild = new Map();
|
const edgeParentsByChild = new Map();
|
||||||
const addEdgeParent = (childKey, parentKey, edge) => {
|
const addEdgeParent = (childKey, parentKey, edge) => {
|
||||||
if (!childKey || !parentKey || childKey === parentKey) return;
|
if (!childKey || !parentKey || childKey === parentKey) return;
|
||||||
const list = edgeParentsByChild.get(childKey) || [];
|
const list = edgeParentsByChild.get(childKey) || [];
|
||||||
if (!list.some((row) => row.id === parentKey)) {
|
if (!list.some((row) => normKey(row?.id) === parentKey)) {
|
||||||
list.push({
|
list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength });
|
||||||
id: parentKey,
|
|
||||||
relationType: edge.relationType,
|
|
||||||
strength: edge.strength,
|
|
||||||
shining: edge.shining,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
edgeParentsByChild.set(childKey, list);
|
edgeParentsByChild.set(childKey, list);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const edge of edgeMap.values()) {
|
for (const edge of edgeMap.values()) {
|
||||||
const aCenterIndex = centerOrder.get(edge.a);
|
if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue;
|
||||||
const bCenterIndex = centerOrder.get(edge.b);
|
addEdgeParent(edge.child, edge.parent, edge);
|
||||||
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]) => ({
|
const nodes = [...nodeMap.entries()].map(([key, node]) => {
|
||||||
|
const pos = placed.get(key) || { x: 0, y: 0 };
|
||||||
|
const isCenter = centerOrder.has(key);
|
||||||
|
return {
|
||||||
...node,
|
...node,
|
||||||
id: key,
|
id: key,
|
||||||
login: node.login || node.id || key,
|
login: node.login || node.id || key,
|
||||||
tier: 1,
|
tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1),
|
||||||
|
layoutX: pos.x,
|
||||||
|
layoutY: pos.y,
|
||||||
fixedLayout: true,
|
fixedLayout: true,
|
||||||
|
keepVisible: isCenter || Boolean(node.keepVisible),
|
||||||
|
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
|
||||||
edgeParents: edgeParentsByChild.get(key) || [],
|
edgeParents: edgeParentsByChild.get(key) || [],
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focusId: normKey(latest.engineModel.focusId),
|
focusId: normKey(latest.engineModel.focusId),
|
||||||
@@ -486,8 +680,13 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||||
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
|
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 engine = null;
|
||||||
let loadSeq = 0;
|
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;
|
persistedCenterLogin = centerLogin;
|
||||||
persistedCenterHistory = [...centerHistory];
|
persistedCenterHistory = [...centerHistory];
|
||||||
persistedGraphHistory = [...graphHistory];
|
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 } = {}) {
|
function syncLinksUrl(login, { push = false } = {}) {
|
||||||
@@ -699,6 +940,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
||||||
|
graphCache.set(normKey(targetCenter), graph);
|
||||||
if (requestId !== loadSeq) return;
|
if (requestId !== loadSeq) return;
|
||||||
centerLogin = targetCenter;
|
centerLogin = targetCenter;
|
||||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||||
@@ -707,7 +949,18 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||||
|
|
||||||
const graphModel = buildGraphModel(graph, targetCenter);
|
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 = {
|
const snapshot = {
|
||||||
centerLogin: targetCenter,
|
centerLogin: targetCenter,
|
||||||
engineModel: snapshotModel,
|
engineModel: snapshotModel,
|
||||||
@@ -715,14 +968,14 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
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 = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
|
||||||
graphHistory.push(snapshot);
|
graphHistory.push(snapshot);
|
||||||
if (graphHistory.length > HISTORY_MAX_CENTERS) {
|
const maxSnapshots = historyDepth + 1;
|
||||||
graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS);
|
graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1);
|
||||||
centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1));
|
centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : [];
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const last = graphHistory[graphHistory.length - 1];
|
const last = graphHistory[graphHistory.length - 1];
|
||||||
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
|
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
|
||||||
@@ -733,10 +986,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const engineModel = buildHistoryEngineModel(graphHistory);
|
rebuildEngineFromHistory();
|
||||||
ensureEngine(engineModel);
|
|
||||||
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
|
||||||
|
|
||||||
persistHistory();
|
persistHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== loadSeq) return;
|
if (requestId !== loadSeq) return;
|
||||||
@@ -810,6 +1060,21 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
filterBar.append(chip);
|
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);
|
chrome?.setTopbar(header);
|
||||||
stage.append(board, filterBar);
|
stage.append(board, filterBar);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
|
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
|
||||||
fixedLayout,
|
fixedLayout,
|
||||||
keepVisible: Boolean(src.keepVisible),
|
keepVisible: Boolean(src.keepVisible),
|
||||||
|
alwaysVisible: Boolean(src.alwaysVisible),
|
||||||
official: Boolean(src.official),
|
official: Boolean(src.official),
|
||||||
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
||||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||||
@@ -530,7 +531,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
|
||||||
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
|
||||||
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
|
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) {
|
function buildPngOrb(src, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -657,6 +658,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.shining = Boolean(src.shining);
|
node.shining = Boolean(src.shining);
|
||||||
node.official = Boolean(src.official);
|
node.official = Boolean(src.official);
|
||||||
node.keepVisible = Boolean(src.keepVisible);
|
node.keepVisible = Boolean(src.keepVisible);
|
||||||
|
node.alwaysVisible = Boolean(src.alwaysVisible);
|
||||||
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
||||||
const layoutX = Number(src.layoutX);
|
const layoutX = Number(src.layoutX);
|
||||||
const layoutY = Number(src.layoutY);
|
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 tier of [2, 3]) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.tier !== tier) continue;
|
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);
|
const p = nodeById.get(n.parentId);
|
||||||
if (!p) { n.opacity = 0; continue; }
|
if (!p) { n.opacity = 0; continue; }
|
||||||
const e = p.expandP || 0; // насколько раскрыт родитель
|
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;
|
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)}"`;
|
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) {
|
if (n.tier >= 3) {
|
||||||
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
||||||
if (pe > 0.02) {
|
if (pe > 0.02) {
|
||||||
|
|||||||
@@ -203,17 +203,24 @@
|
|||||||
|
|
||||||
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
|
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
|
||||||
масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */
|
масштабируется вместе с аватаркой при 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;
|
position: absolute;
|
||||||
left: -2%;
|
left: -2%;
|
||||||
bottom: 0;
|
bottom: -1%;
|
||||||
width: 15%;
|
width: 16%;
|
||||||
height: 15%;
|
height: 16%;
|
||||||
min-width: 8px;
|
min-width: 9px;
|
||||||
min-height: 8px;
|
min-height: 9px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
display: block;
|
display: block;
|
||||||
z-index: 5;
|
opacity: 1;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: none;
|
||||||
|
z-index: 20;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
|
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);
|
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, поверх всего, не масштабируется */
|
/* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */
|
||||||
.fg-menu-overlay {
|
.fg-menu-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user