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

This commit is contained in:
AidarKC
2026-09-10 14:52:20 +03:00
parent d83f1d4cce
commit 849250bfa8
9 changed files with 613 additions and 131 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.12.11 client.version=1.12.12
server.version=1.10.4 server.version=1.10.4
@@ -131,6 +131,12 @@ dm_dialog_state хранит:
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
историю у своего единственного access-сервера. историю у своего единственного access-сервера.
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
видимого чтения/receipt отмечает его прочитанным.
## 9. Подтверждения прочтения ## 9. Подтверждения прочтения
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад. - если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview. - в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
## 9. Контент типов `5/6` ## 9. Контент типов `5/6`
Типы: Типы:
+25
View File
@@ -0,0 +1,25 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="14" y1="10" x2="52" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="#56C7FF"/>
<stop offset="0.48" stop-color="#1687F8"/>
<stop offset="1" stop-color="#075EE8"/>
</linearGradient>
<linearGradient id="ring" x1="10" y1="8" x2="54" y2="58" gradientUnits="userSpaceOnUse">
<stop stop-color="#A8EDFF"/>
<stop offset="0.5" stop-color="#4BC7FF"/>
<stop offset="1" stop-color="#2473FF"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="29" fill="url(#bg)"/>
<circle cx="32" cy="32" r="28" stroke="url(#ring)" stroke-width="2"/>
<circle cx="32" cy="32" r="24.5" stroke="#BCEEFF" stroke-opacity="0.72" stroke-width="1.5"/>
<path d="M19.5 32.7L27.7 40.9L45 23.6"
stroke="white"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

+3 -6
View File
@@ -1615,12 +1615,9 @@ export function render({ navigate, route, chrome }) {
const handleIncomingChatRefresh = async (event) => { const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId); const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return; if (updatedChatId !== chatId) return;
if (Number(event?.detail?.messageType || 0) === 1) { // Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт
if (!unreadSeparatorVisible) { // новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey; // непрочитанным, которые существовали ДО открытия экрана чата.
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
}
}
preserveComposerSelection(input, () => { preserveComposerSelection(input, () => {
renderChatLog({ scrollMode: 'latest' }); renderChatLog({ scrollMode: 'latest' });
}); });
+318 -14
View File
@@ -201,6 +201,269 @@ function buildGraphModel(graph, centerLogin) {
let persistedCenterLogin = ''; let persistedCenterLogin = '';
let persistedCenterHistory = []; 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 } = {}) { export function render({ navigate, route, chrome } = {}) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
@@ -208,6 +471,7 @@ export function render({ navigate, route, chrome } = {}) {
if (!keepHistory) { if (!keepHistory) {
persistedCenterLogin = ''; persistedCenterLogin = '';
persistedCenterHistory = []; persistedCenterHistory = [];
persistedGraphHistory = [];
} }
const screen = document.createElement('section'); const screen = document.createElement('section');
@@ -221,27 +485,36 @@ 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 engine = null; let engine = null;
let loadSeq = 0; let loadSeq = 0;
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. // Независимые фильтры карты. Оба выключены = показываем всё.
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
const FILTERS = { const FILTERS = {
all: { label: 'Все', pred: () => true }, close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
}; };
const FILTER_ORDER = ['all', 'friends', 'shining']; const FILTER_ORDER = ['close', 'shining'];
let activeFilter = 'all'; const activeFilters = new Set();
const filterChips = {}; const filterChips = {};
function currentFilterPredicate(node) {
for (const key of activeFilters) {
if (!FILTERS[key].pred(node)) return false;
}
return true;
}
function applyFilter(key) { function applyFilter(key) {
if (!FILTERS[key]) return; if (!FILTERS[key]) return;
activeFilter = key; if (activeFilters.has(key)) activeFilters.delete(key);
else activeFilters.add(key);
FILTER_ORDER.forEach((k) => { FILTER_ORDER.forEach((k) => {
const el = filterChips[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) { function profileInfoRoute(login) {
@@ -254,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) {
function persistHistory() { function persistHistory() {
persistedCenterLogin = centerLogin; persistedCenterLogin = centerLogin;
persistedCenterHistory = [...centerHistory]; persistedCenterHistory = [...centerHistory];
persistedGraphHistory = [...graphHistory];
} }
function syncLinksUrl(login, { push = false } = {}) { function syncLinksUrl(login, { push = false } = {}) {
@@ -392,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) {
stage: board, stage: board,
model, 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) => { onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login); 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 requestId = ++loadSeq;
const prevCenter = centerLogin; const prevCenter = centerLogin;
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
@@ -430,10 +707,35 @@ 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 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); ensureEngine(engineModel);
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход) if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
persistHistory(); persistHistory();
} catch (error) { } catch (error) {
@@ -475,6 +777,7 @@ export function render({ navigate, route, chrome } = {}) {
if (routeLogin) { if (routeLogin) {
centerLogin = routeLogin; centerLogin = routeLogin;
centerHistory = []; centerHistory = [];
graphHistory = [];
persistHistory(); persistHistory();
void load(centerLogin, { pushHistory: false }); void load(centerLogin, { pushHistory: false });
} else if (keepHistory && centerLogin) { } else if (keepHistory && centerLogin) {
@@ -482,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) {
} else { } else {
centerLogin = normalizeLogin(state.session.login || ''); centerLogin = normalizeLogin(state.session.login || '');
centerHistory = []; centerHistory = [];
graphHistory = [];
persistHistory(); persistHistory();
if (centerLogin) { if (centerLogin) {
void load(centerLogin, { pushHistory: false }); void load(centerLogin, { pushHistory: false });
@@ -499,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) {
FILTER_ORDER.forEach((key) => { FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button'); const chip = document.createElement('button');
chip.type = '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.textContent = FILTERS[key].label;
chip.addEventListener('click', () => applyFilter(key)); chip.addEventListener('click', () => applyFilter(key));
filterChips[key] = chip; filterChips[key] = chip;
+2
View File
@@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) {
relationType: 'self', relationType: 'self',
strength: 1, strength: 1,
shining: Boolean(centerMark?.shine), shining: Boolean(centerMark?.shine),
official: Boolean(centerMark?.official),
tier: 1, tier: 1,
}; };
@@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) {
relationType: relationTypeFromRelation(r), relationType: relationTypeFromRelation(r),
strength: deriveStrength(r || {}), strength: deriveStrength(r || {}),
shining: Boolean(r?.mark?.shine), shining: Boolean(r?.mark?.shine),
official: Boolean(r?.mark?.official),
tier: 1, tier: 1,
}; };
}) })
+223 -49
View File
@@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина» const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется) const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники) const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
@@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const rebuildIndex = () => { const rebuildIndex = () => {
nodeById = new Map(nodes.map((n) => [String(n.id), n])); nodeById = new Map(nodes.map((n) => [String(n.id), n]));
hasDeep = nodes.some((n) => n.tier >= 2); hasDeep = nodes.some((n) => n.tier >= 2);
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
childCountByParent = new Map(); childCountByParent = new Map();
degreeById = new Map(); degreeById = new Map();
const hasExplicitEdges = nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0);
if (hasExplicitEdges) {
for (const n of nodes) {
const refs = Array.isArray(n.edgeParents) ? n.edgeParents : [];
for (const ref of refs) {
const parentId = String(ref?.id || '');
if (!parentId || !nodeById.has(parentId)) continue;
degreeById.set(String(n.id), (degreeById.get(String(n.id)) || 0) + 1);
degreeById.set(parentId, (degreeById.get(parentId) || 0) + 1);
}
}
return;
}
let tier1count = 0; let tier1count = 0;
for (const n of nodes) { for (const n of nodes) {
if (n.tier >= 2 && n.parentId) { if (n.tier >= 2 && n.parentId) {
const i = childCountByParent.get(n.parentId) || 0; const i = childCountByParent.get(n.parentId) || 0;
n.sibIndex = i; n.sibIndex = i;
childCountByParent.set(n.parentId, i + 1); childCountByParent.set(n.parentId, i + 1);
degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1);
} else if (n.tier === 1 && String(n.id) !== focusId) { } else if (n.tier === 1 && String(n.id) !== focusId) {
tier1count += 1; tier1count += 1;
} }
} }
degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня degreeById.set(focusId, tier1count);
}; };
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
@@ -410,7 +425,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const focusSrc = list.find((n) => String(n.id) === fId) || list[0]; const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
const tier1Orbit = buildOrbitPlacements(tier1.length); const tier1Orbit = buildOrbitPlacements(tier1.length);
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null }); if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] })); tier1.forEach((p, i) => specs.push({
src: p,
id: String(p.id),
isFocus: false,
index: i,
total: tier1.length,
dotOnly: i >= MAX_FULL_NODES && !p.keepVisible,
orbit: p.fixedLayout ? null : tier1Orbit[i],
}));
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 })); deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
return { focusId: fId, specs }; return { focusId: fId, specs };
@@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) { function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5)); const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
const tier = Number(src.tier) || 1; const tier = Number(src.tier) || 1;
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера: // Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками. // может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R); // кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер.
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)); const layoutX = Number(src.layoutX);
const layoutY = Number(src.layoutY);
const fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY);
const targetR = fixedLayout ? Math.hypot(layoutX, layoutY) : (isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R));
const angle = fixedLayout ? Math.atan2(layoutY, layoutX) : (isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)));
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда. // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR);
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR);
const tx = isFocus ? 0 : Math.cos(angle) * targetR;
const ty = isFocus ? 0 : Math.sin(angle) * targetR;
const el = buildNodeElement(src, isFocus, tier, dotOnly); const el = buildNodeElement(src, isFocus, tier, dotOnly);
world.append(el); world.append(el);
return { return {
...src, ...src,
isFocus, isFocus,
tier, tier,
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус) parentId: String(src.parentId || ''), // layout/deep parent; для history-рёбер используется edgeParents
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
fixedLayout,
keepVisible: Boolean(src.keepVisible),
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), // «трек прохождения» — линия к этому узлу горит ярко
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво» pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
@@ -501,6 +530,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';
function buildPngOrb(src, opts) { function buildPngOrb(src, opts) {
const o = opts || {}; const o = opts || {};
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@@ -528,6 +558,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return wrap; return wrap;
} }
function syncOfficialBadge(el, official) {
if (!(el instanceof HTMLElement)) return;
const dot = el.querySelector('.node-dot');
if (!(dot instanceof HTMLElement)) return;
const existing = dot.querySelector('.fg-official-badge');
if (!official) {
existing?.remove();
return;
}
if (existing) return;
const badge = document.createElement('img');
badge.className = 'fg-official-badge';
badge.src = OFFICIAL_BADGE_SRC;
badge.alt = '';
badge.setAttribute('aria-hidden', 'true');
dot.append(badge);
}
function buildNodeElement(src, isFocus, tier, dotOnly = false) { function buildNodeElement(src, isFocus, tier, dotOnly = false) {
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button'; el.type = 'button';
@@ -537,6 +586,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
'ui-button', 'fg-node', 'fg-dot', 'ui-button', 'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка) tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
src.shining ? 'is-shine' : '', src.shining ? 'is-shine' : '',
src.official ? 'is-official' : '',
`is-${src.relationType || 'contact'}`, `is-${src.relationType || 'contact'}`,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id); el.dataset.nodeId = String(src.id);
@@ -549,6 +599,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
'ui-button', 'fg-node', 'ui-button', 'fg-node',
isFocus ? 'is-focus' : '', isFocus ? 'is-focus' : '',
src.shining ? 'is-shine' : '', src.shining ? 'is-shine' : '',
src.official ? 'is-official' : '',
`is-${src.relationType || 'contact'}`, `is-${src.relationType || 'contact'}`,
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
tier >= 2 ? 'is-secondary' : '', tier >= 2 ? 'is-secondary' : '',
@@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут. // Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials })); dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
el.append(dot); el.append(dot);
syncOfficialBadge(el, Boolean(src.official));
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
const badge = document.createElement('span');
badge.className = 'fg-node-badge';
badge.hidden = true;
el.append(badge);
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'fg-node-label'; label.className = 'fg-node-label';
@@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return el; return el;
} }
// Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex. // Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой.
function updateBadges() { function updateBadges() {}
for (const n of nodes) {
const badge = n.el.querySelector('.fg-node-badge');
if (!badge) continue; // у точек (dotOnly) бейджа нет
const deg = degreeById.get(String(n.id)) || 0;
if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; }
else { badge.hidden = true; }
}
}
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком). // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
function updateA11y() { function updateA11y() {
@@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.strength = strength; node.strength = strength;
node.relationType = src.relationType; node.relationType = src.relationType;
node.shining = Boolean(src.shining); node.shining = Boolean(src.shining);
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R); node.official = Boolean(src.official);
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)); node.keepVisible = Boolean(src.keepVisible);
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
const layoutX = Number(src.layoutX);
const layoutY = Number(src.layoutY);
node.fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY);
node.targetR = node.fixedLayout ? Math.hypot(layoutX, layoutY) : (spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R));
node.angle = node.fixedLayout ? Math.atan2(layoutY, layoutX) : (spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)));
node.orbitRing = spec.orbit?.ring || 0; node.orbitRing = spec.orbit?.ring || 0;
node.tx = Math.cos(node.angle) * node.targetR; node.tx = node.fixedLayout ? layoutX : Math.cos(node.angle) * node.targetR;
node.ty = Math.sin(node.angle) * node.targetR; node.ty = node.fixedLayout ? layoutY : Math.sin(node.angle) * node.targetR;
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
node.hidden = false; node.hidden = false;
@@ -628,8 +673,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26))); node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
node.el.className = spec.dotOnly node.el.className = spec.dotOnly
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') ? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); : ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
if (!spec.dotOnly) syncOfficialBadge(node.el, node.official);
} }
// --- Рендер ---------------------------------------------------------------- // --- Рендер ----------------------------------------------------------------
@@ -637,6 +683,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`; world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
} }
// Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает
// обе центральные оси viewport. На крайнем положении к центральной линии прижимается край
// крайнего аватара — полностью выбросить весь граф за экран больше нельзя.
function clampCameraPosition(nextX, nextY) {
const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02);
if (!visible.length) return { x: 0, y: 0 };
let minX = Infinity; let maxX = -Infinity;
let minY = Infinity; let maxY = -Infinity;
for (const n of visible) {
const baseR = n.dotOnly ? (Number(n.dotRadius) || 5) : ORB_R;
const r = baseR * (Number(n.scale) || 1) * (Number(n.depthScale) || 1);
minX = Math.min(minX, n.x - r);
maxX = Math.max(maxX, n.x + r);
minY = Math.min(minY, n.y - r);
maxY = Math.max(maxY, n.y + r);
}
if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return { x: nextX, y: nextY };
const minCamX = -maxX * zoom;
const maxCamX = -minX * zoom;
const minCamY = -maxY * zoom;
const maxCamY = -minY * zoom;
return {
x: Math.max(minCamX, Math.min(maxCamX, nextX)),
y: Math.max(minCamY, Math.min(maxCamY, nextY)),
};
}
// Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся // Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
// в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он // в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет. // упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
@@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
zoom = z1; zoom = z1;
camX = sx - centerX - wx * z1; camX = sx - centerX - wx * z1;
camY = sy - centerY - wy * z1; camY = sy - centerY - wy * z1;
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
applyWorldTransform(); applyWorldTransform();
renderEdges(); renderEdges();
@@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
n.lod = full ? 'full' : 'dot'; n.lod = full ? 'full' : 'dot';
n.dotOnly = !full; n.dotOnly = !full;
n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей
if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } } }
function renderHistoryEdges() {
const Z = zoom;
const parts = [];
const tx = (n) => centerX + camX + n.x * Z;
const ty = (n) => centerY + camY + n.y * Z;
for (const n of nodes) {
const refs = Array.isArray(n.edgeParents) ? n.edgeParents : [];
if (!refs.length) continue;
const childOpacity = (typeof n.opacity === 'number' ? n.opacity : 1) * (n.spotCur ?? 1);
if (n.hidden && childOpacity <= 0.02) continue;
for (const ref of refs) {
const parent = nodeById.get(String(ref?.id || ''));
if (!parent || parent === n) continue;
const parentOpacity = (typeof parent.opacity === 'number' ? parent.opacity : 1) * (parent.spotCur ?? 1);
if (parent.hidden && parentOpacity <= 0.02) continue;
const fx = tx(parent);
const fy = ty(parent);
const nx = tx(n);
const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
const dx = nx - fx;
const dy = ny - fy;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z;
const x1 = fx + ux * fr;
const y1 = fy + uy * fr;
const x2 = nx - ux * nr;
const y2 = ny - uy * nr;
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const segLen = Math.hypot(x2 - x1, y2 - y1);
const bow = Math.max(7, Math.min(22, segLen * 0.10));
const sign = hash01(`${parent.id}|${n.id}|edge`) > 0.5 ? 1 : -1;
const desX = mx + (-uy) * bow * sign;
const desY = my + ux * bow * sign;
const cpx = 2 * desX - mx;
const cpy = 2 * desY - my;
const relationType = String(ref?.relationType || n.relationType || 'contact');
const strength = Math.max(0, Math.min(1, Number(ref?.strength) || Number(n.strength) || 0.5));
const opacity = Math.min(childOpacity, parentOpacity);
const shine = Boolean(parent.shining && n.shining) && !n.hidden;
if (shine) {
const pnx = -uy;
const pny = ux;
const amp = Math.min(13, 5 + segLen * 0.05);
const bowX = desX - mx;
const bowY = desY - my;
const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp;
const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp;
const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp;
const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp;
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
parts.push(`<path class="fg-plasma-flare" d="${d}" opacity="${(0.42 * opacity).toFixed(3)}" />`);
parts.push(`<path class="fg-plasma-tube" d="${d}" opacity="${(0.85 * opacity).toFixed(3)}" />`);
parts.push(`<path class="fg-plasma-core" d="${d}" opacity="${opacity.toFixed(3)}" />`);
} else {
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
const col = relationColor(relationType);
const haloWidth = (2.6 + strength * 1.4).toFixed(2);
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${haloWidth}" stroke-linecap="round" opacity="${(0.22 * opacity).toFixed(2)}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="1.2" stroke-linecap="round" opacity="${(0.70 * opacity).toFixed(2)}" />`);
}
}
}
edgesSvg.innerHTML = parts.join('');
} }
function renderEdges() { function renderEdges() {
if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) {
renderHistoryEdges();
return;
}
const focus = nodes.find((n) => n.id === focusId); const focus = nodes.find((n) => n.id === focusId);
if (!focus) { if (!focus) {
edgesSvg.innerHTML = ''; edgesSvg.innerHTML = '';
@@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность; // • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
const shine = Boolean(n.shining) && !n.hidden; const shine = Boolean(parent.shining && n.shining) && !n.hidden;
const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом
const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
@@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let ax = 0; let ax = 0;
let ay = 0; let ay = 0;
if (n.isFocus) { if (n.fixedLayout) {
// Историческая карта уже получила приоритетную раскладку от network-view. Не разрушаем её
// общей радиальной физикой: только мягко возвращаем узел к назначенной точке после жестов/анимаций.
ax += K_FIXED_LAYOUT * (n.tx - n.x);
ay += K_FIXED_LAYOUT * (n.ty - n.y);
} else if (n.isFocus) {
// пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается) // пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается)
ax += K_FOCUS * (0 - n.x); ax += K_FOCUS * (0 - n.x);
ay += K_FOCUS * (0 - n.y); ay += K_FOCUS * (0 - n.y);
@@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let dist2 = dx * dx + dy * dy; let dist2 = dx * dx + dy * dy;
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST; if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
const dist = Math.sqrt(dist2); const dist = Math.sqrt(dist2);
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
const ex = Math.max(n.expandP || 0, m.expandP || 0); const ex = Math.max(n.expandP || 0, m.expandP || 0);
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2; const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
ax += (dx / dist) * f; ax += (dx / dist) * f;
@@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const visiblePeers = []; const visiblePeers = [];
nodes.forEach((n) => { nodes.forEach((n) => {
if (n.isFocus) { n.hidden = false; return; } if (n.isFocus || n.keepVisible) { n.hidden = false; return; }
n.hidden = !pred(n); n.hidden = !pred(n);
n.vx = 0; n.vx = 0;
n.vy = 0; n.vy = 0;
@@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}; };
const focus = nodes.find((n) => n.isFocus); const focus = nodes.find((n) => n.isFocus);
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1); if (focus) apply(focus, focus.tx || 0, focus.ty || 0, FOCUS_SCALE, 1);
applyOrbitTargets(visiblePeers); const fixedLayout = visiblePeers.some((n) => n.fixedLayout) || nodes.some((n) => n.fixedLayout);
if (!fixedLayout) applyOrbitTargets(visiblePeers);
visiblePeers.forEach((n) => { visiblePeers.forEach((n) => {
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
apply(n, n.tx, n.ty, sc, 1); apply(n, n.tx, n.ty, sc, 1);
@@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// инерция панорамирования (kinematic): камера докатывается с трением // инерция панорамирования (kinematic): камера докатывается с трением
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15); const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
if (panActive) { if (panActive) {
camX += panVelX; const proposedX = camX + panVelX;
camY += panVelY; const proposedY = camY + panVelY;
const clamped = clampCameraPosition(proposedX, proposedY);
camX = clamped.x;
camY = clamped.y;
if (camX !== proposedX) panVelX = 0;
if (camY !== proposedY) panVelY = 0;
panVelX *= PAN_FRICTION; panVelX *= PAN_FRICTION;
panVelY *= PAN_FRICTION; panVelY *= PAN_FRICTION;
applyWorldTransform(); applyWorldTransform();
@@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const newCamY = camStartY + dy; const newCamY = camStartY + dy;
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания) panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
panVelY = newCamY - camY; panVelY = newCamY - camY;
camX = newCamX; const clamped = clampCameraPosition(newCamX, newCamY);
camY = newCamY; camX = clamped.x;
camY = clamped.y;
if (camX !== newCamX) panVelX = 0;
if (camY !== newCamY) panVelY = 0;
applyWorldTransform(); applyWorldTransform();
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
renderEdges(); // рёбра следуют за камерой синхронно (дёшево) renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
@@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
viewH = stage.clientHeight || window.innerHeight; viewH = stage.clientHeight || window.innerHeight;
centerX = viewW / 2; centerX = viewW / 2;
centerY = viewH / 2; centerY = viewH / 2;
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
applyWorldTransform();
renderEdges(); renderEdges();
} }
@@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
freezeGraph(); freezeGraph();
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
applyWorldTransform();
renderEdges();
return; return;
} }
boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80) boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80)
@@ -1617,8 +1788,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// старый узел нового фокуса (если был) — фокус глайдит из его позиции // старый узел нового фокуса (если был) — фокус глайдит из его позиции
const focusOld = oldById.get(String(newFocusId)); const focusOld = oldById.get(String(newFocusId));
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира // В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят
spawnGhost(); // в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём.
if (!nextModel?.preserveHistory) spawnGhost();
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); }); nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
focusId = String(newFocusId); focusId = String(newFocusId);
@@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return node; return node;
}); });
pendingFocusOrigin = null; pendingFocusOrigin = null;
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум // Новый центр меняет положение карты, но пользовательский масштаб сохраняем.
// Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком.
diveTargetId = null; surfacing = false;
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
updateBadges(); // бейджи-счётчики связей под новый набор updateBadges(); // бейджи-счётчики связей под новый набор
updateA11y(); // текстовый список графа для скринридеров updateA11y(); // текстовый список графа для скринридеров
+29 -61
View File
@@ -200,6 +200,24 @@
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px; background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
} }
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */
.fg-official-badge {
position: absolute;
left: -2%;
bottom: 0;
width: 15%;
height: 15%;
min-width: 8px;
min-height: 8px;
object-fit: contain;
display: block;
z-index: 5;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
}
.fg-node.is-family .node-dot { .fg-node.is-family .node-dot {
background: linear-gradient(165deg, #785038, #5f3e2c); background: linear-gradient(165deg, #785038, #5f3e2c);
border-color: rgba(255, 194, 143, 0.6); border-color: rgba(255, 194, 143, 0.6);
@@ -241,15 +259,17 @@
.fg-node.is-pressed .node-dot { transform: none; } .fg-node.is-pressed .node-dot { transform: none; }
} }
/* «Сияние» постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи. /* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль: от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */
визуально сияющий пользователь всегда остаётся явно сияющим. */
.fg-node.is-shine .node-dot { .fg-node.is-shine .node-dot {
border-color: rgba(150, 240, 255, 0.62); border-color: rgba(150, 240, 255, 0.62);
animation: fg-shine-glow 3.6s ease-in-out infinite; box-shadow:
0 0 8.3px rgba(146, 243, 255, 0.57),
0 0 18.3px rgba(121, 235, 255, 0.39),
0 0 33px rgba(100, 220, 255, 0.24);
} }
/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */ /* Статичный размытый ореол позади аватарки — та же промежуточная интенсивность ~2/3. */
.fg-node.is-shine .node-dot::before { .fg-node.is-shine .node-dot::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -259,34 +279,8 @@
filter: url(#fg-shine-glow); filter: url(#fg-shine-glow);
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
animation: fg-shine-halo 3.6s ease-in-out infinite; transform: scale(1.07);
} opacity: 0.91;
/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */
@keyframes fg-shine-glow {
0%, 100% {
box-shadow:
0 0 7px rgba(138, 239, 255, 0.48),
0 0 15px rgba(118, 232, 255, 0.32),
0 0 27px rgba(100, 220, 255, 0.19);
}
50% {
box-shadow:
0 0 9px rgba(150, 245, 255, 0.62),
0 0 20px rgba(122, 236, 255, 0.42),
0 0 36px rgba(100, 220, 255, 0.26);
}
}
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
@keyframes fg-shine-halo {
0%, 100% { transform: scale(0.98); opacity: 0.72; }
50% { transform: scale(1.12); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.fg-node.is-shine .node-dot { animation: none; }
.fg-node.is-shine .node-dot::before { animation: none; }
} }
/* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */ /* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */
@@ -524,37 +518,11 @@
background: transparent; background: transparent;
} }
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ /* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
.fg-node-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: rgba(16, 24, 40, 0.92);
border: 1px solid rgba(150, 200, 255, 0.5);
color: #d9ecff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.fg-node.is-focus .fg-node-badge {
background: rgba(61, 196, 223, 0.95);
border-color: rgba(220, 245, 255, 0.8);
color: #06131c;
}
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу /* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
box-shadow не навязываем у них свой эффект свечения (is-shine/is-focus выше). */ box-shadow не навязываем у них свой эффект свечения (is-shine/is-focus выше). */
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } .fg-node.is-focus:not(.is-shine) .node-dot { box-shadow: none; }
/* Строка поиска (оверлей вверху, под панелью фильтров) */ /* Строка поиска (оверлей вверху, под панелью фильтров) */
.fg-search { .fg-search {