SHA256
Связи - интересная версия
This commit is contained in:
+477
-212
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user