SHA256
Связи - интересно но надо дорабатывать
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.11
|
||||
client.version=1.12.12
|
||||
server.version=1.10.4
|
||||
|
||||
@@ -131,6 +131,12 @@ dm_dialog_state хранит:
|
||||
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
||||
историю у своего единственного access-сервера.
|
||||
|
||||
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
|
||||
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
|
||||
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
|
||||
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
|
||||
видимого чтения/receipt отмечает его прочитанным.
|
||||
|
||||
## 9. Подтверждения прочтения
|
||||
|
||||
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||
|
||||
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
|
||||
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
|
||||
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
|
||||
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
|
||||
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
|
||||
@@ -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 |
@@ -1615,12 +1615,9 @@ export function render({ navigate, route, chrome }) {
|
||||
const handleIncomingChatRefresh = async (event) => {
|
||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||
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, () => {
|
||||
renderChatLog({ scrollMode: 'latest' });
|
||||
});
|
||||
|
||||
@@ -201,6 +201,269 @@ function buildGraphModel(graph, centerLogin) {
|
||||
|
||||
let persistedCenterLogin = '';
|
||||
let persistedCenterHistory = [];
|
||||
let persistedGraphHistory = [];
|
||||
|
||||
const HISTORY_MAX_CENTERS = 4;
|
||||
const HISTORY_CENTER_GAP_MIN = 280;
|
||||
const HISTORY_CENTER_GAP_STEP = 72;
|
||||
const HISTORY_CENTER_CLEARANCE = 110;
|
||||
const HISTORY_ORBIT_FIRST_R = 104;
|
||||
const HISTORY_ORBIT_GAP = 88;
|
||||
const HISTORY_ORBIT_NODE_GAP = 84;
|
||||
const HISTORY_NODE_CLEARANCE = 92;
|
||||
|
||||
function historyHash01(value) {
|
||||
let h = 2166136261;
|
||||
const text = String(value || '');
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return ((h >>> 0) % 1000) / 1000;
|
||||
}
|
||||
|
||||
function historyOrbitCapacity(radius) {
|
||||
const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1);
|
||||
const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r)));
|
||||
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
|
||||
}
|
||||
|
||||
function historyOrbitPlacements(total, seed = '') {
|
||||
const count = Math.max(0, Number(total) || 0);
|
||||
const out = new Array(count);
|
||||
let start = 0;
|
||||
let ring = 0;
|
||||
const seedPhase = historyHash01(seed) * Math.PI * 2;
|
||||
while (start < count) {
|
||||
const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP;
|
||||
const capacity = historyOrbitCapacity(radius);
|
||||
const ringCount = Math.min(capacity, count - start);
|
||||
const phase = seedPhase + ring * 0.43;
|
||||
for (let i = 0; i < ringCount; i += 1) {
|
||||
out[start + i] = {
|
||||
radius,
|
||||
angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount),
|
||||
};
|
||||
}
|
||||
start += ringCount;
|
||||
ring += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function historyClusterRadius(snapshot) {
|
||||
const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : [];
|
||||
const centerKey = normKey(snapshot?.centerLogin);
|
||||
const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length;
|
||||
const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || '');
|
||||
const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0);
|
||||
return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE;
|
||||
}
|
||||
|
||||
function buildHistoryEngineModel(history) {
|
||||
const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS);
|
||||
const latest = snapshots[snapshots.length - 1];
|
||||
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
||||
|
||||
const centerPos = new Map();
|
||||
const centerOrder = new Map();
|
||||
const clusterRadius = new Map();
|
||||
centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 });
|
||||
snapshots.forEach((snap, index) => {
|
||||
const key = normKey(snap.centerLogin);
|
||||
centerOrder.set(key, index);
|
||||
clusterRadius.set(key, historyClusterRadius(snap));
|
||||
});
|
||||
|
||||
// Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода
|
||||
// настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались.
|
||||
for (let i = snapshots.length - 1; i > 0; i -= 1) {
|
||||
const current = snapshots[i];
|
||||
const previous = snapshots[i - 1];
|
||||
const currentKey = normKey(current.centerLogin);
|
||||
const previousKey = normKey(previous.centerLogin);
|
||||
const curPos = centerPos.get(currentKey) || { x: 0, y: 0 };
|
||||
const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0;
|
||||
const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE;
|
||||
const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE;
|
||||
let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR);
|
||||
let candidate = null;
|
||||
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
candidate = {
|
||||
x: curPos.x - Math.cos(angle) * gap,
|
||||
y: curPos.y - Math.sin(angle) * gap,
|
||||
};
|
||||
let collides = false;
|
||||
for (const [placedKey, placed] of centerPos.entries()) {
|
||||
const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE;
|
||||
const minDist = prevR + placedR;
|
||||
if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) {
|
||||
collides = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!collides) break;
|
||||
gap += HISTORY_CENTER_GAP_STEP;
|
||||
}
|
||||
centerPos.set(previousKey, candidate || {
|
||||
x: curPos.x - Math.cos(angle) * gap,
|
||||
y: curPos.y - Math.sin(angle) * gap,
|
||||
});
|
||||
}
|
||||
|
||||
const nodeMap = new Map();
|
||||
const latestPlacementParent = new Map();
|
||||
const edgeMap = new Map();
|
||||
|
||||
// Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям.
|
||||
snapshots.forEach((snap, snapIndex) => {
|
||||
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||
const centerKey = normKey(snap.centerLogin);
|
||||
const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0];
|
||||
if (center) {
|
||||
nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true });
|
||||
}
|
||||
|
||||
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
||||
relations.forEach((node) => {
|
||||
const key = normKey(node?.id);
|
||||
if (!key) return;
|
||||
nodeMap.set(key, { ...nodeMap.get(key), ...node });
|
||||
|
||||
const a = centerKey;
|
||||
const b = key;
|
||||
const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
edgeMap.set(edgeKey, {
|
||||
a,
|
||||
b,
|
||||
relationType: node.relationType || 'contact',
|
||||
strength: Number(node.strength) || 0.5,
|
||||
snapIndex,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Все исторические центры сразу считаются занятыми местами.
|
||||
const occupied = [];
|
||||
for (const [centerKey, pos] of centerPos.entries()) {
|
||||
occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE });
|
||||
}
|
||||
|
||||
const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => {
|
||||
if (row.key === key) return false;
|
||||
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
|
||||
});
|
||||
|
||||
// Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь
|
||||
// встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге.
|
||||
for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) {
|
||||
const snap = snapshots[snapIndex];
|
||||
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||
const centerKey = normKey(snap.centerLogin);
|
||||
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
||||
const placements = historyOrbitPlacements(relations.length, snap.centerLogin);
|
||||
const base = centerPos.get(centerKey) || { x: 0, y: 0 };
|
||||
|
||||
relations.forEach((node, index) => {
|
||||
const key = normKey(node?.id);
|
||||
if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return;
|
||||
const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 };
|
||||
let chosen = null;
|
||||
// Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус.
|
||||
for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) {
|
||||
const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55;
|
||||
const angularSamples = 18 + radialStep * 2;
|
||||
for (let step = 0; step < angularSamples; step += 1) {
|
||||
const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1);
|
||||
const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples);
|
||||
const x = base.x + Math.cos(angle) * radius;
|
||||
const y = base.y + Math.sin(angle) * radius;
|
||||
if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) {
|
||||
chosen = { x, y };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55;
|
||||
chosen = {
|
||||
x: base.x + Math.cos(preferred.angle) * radius,
|
||||
y: base.y + Math.sin(preferred.angle) * radius,
|
||||
};
|
||||
}
|
||||
latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex });
|
||||
occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE });
|
||||
});
|
||||
}
|
||||
|
||||
for (const [centerKey, pos] of centerPos.entries()) {
|
||||
const node = nodeMap.get(centerKey);
|
||||
if (!node) continue;
|
||||
nodeMap.set(centerKey, {
|
||||
...node,
|
||||
layoutX: pos.x,
|
||||
layoutY: pos.y,
|
||||
fixedLayout: true,
|
||||
keepVisible: true,
|
||||
isHistoryCenter: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, placement] of latestPlacementParent.entries()) {
|
||||
const node = nodeMap.get(key);
|
||||
if (!node || centerOrder.has(key)) continue;
|
||||
nodeMap.set(key, {
|
||||
...node,
|
||||
layoutX: placement.x,
|
||||
layoutY: placement.y,
|
||||
fixedLayout: true,
|
||||
parentId: placement.centerKey,
|
||||
});
|
||||
}
|
||||
|
||||
const edgeParentsByChild = new Map();
|
||||
const addEdgeParent = (childKey, parentKey, edge) => {
|
||||
if (!childKey || !parentKey || childKey === parentKey) return;
|
||||
const list = edgeParentsByChild.get(childKey) || [];
|
||||
if (!list.some((row) => row.id === parentKey)) {
|
||||
list.push({
|
||||
id: parentKey,
|
||||
relationType: edge.relationType,
|
||||
strength: edge.strength,
|
||||
shining: edge.shining,
|
||||
});
|
||||
}
|
||||
edgeParentsByChild.set(childKey, list);
|
||||
};
|
||||
|
||||
for (const edge of edgeMap.values()) {
|
||||
const aCenterIndex = centerOrder.get(edge.a);
|
||||
const bCenterIndex = centerOrder.get(edge.b);
|
||||
if (aCenterIndex !== undefined && bCenterIndex !== undefined) {
|
||||
if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge);
|
||||
else addEdgeParent(edge.b, edge.a, edge);
|
||||
continue;
|
||||
}
|
||||
if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge);
|
||||
else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge);
|
||||
}
|
||||
|
||||
const nodes = [...nodeMap.entries()].map(([key, node]) => ({
|
||||
...node,
|
||||
id: key,
|
||||
login: node.login || node.id || key,
|
||||
tier: 1,
|
||||
fixedLayout: true,
|
||||
edgeParents: edgeParentsByChild.get(key) || [],
|
||||
}));
|
||||
|
||||
return {
|
||||
focusId: normKey(latest.engineModel.focusId),
|
||||
nodes,
|
||||
preserveHistory: snapshots.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome } = {}) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
@@ -208,6 +471,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
if (!keepHistory) {
|
||||
persistedCenterLogin = '';
|
||||
persistedCenterHistory = [];
|
||||
persistedGraphHistory = [];
|
||||
}
|
||||
|
||||
const screen = document.createElement('section');
|
||||
@@ -221,27 +485,36 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
|
||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
|
||||
let engine = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||
// Независимые фильтры карты. Оба выключены = показываем всё.
|
||||
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
|
||||
const FILTERS = {
|
||||
all: { label: 'Все', pred: () => true },
|
||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
|
||||
close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
|
||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
||||
};
|
||||
const FILTER_ORDER = ['all', 'friends', 'shining'];
|
||||
let activeFilter = 'all';
|
||||
const FILTER_ORDER = ['close', 'shining'];
|
||||
const activeFilters = new Set();
|
||||
const filterChips = {};
|
||||
|
||||
function currentFilterPredicate(node) {
|
||||
for (const key of activeFilters) {
|
||||
if (!FILTERS[key].pred(node)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFilter(key) {
|
||||
if (!FILTERS[key]) return;
|
||||
activeFilter = key;
|
||||
if (activeFilters.has(key)) activeFilters.delete(key);
|
||||
else activeFilters.add(key);
|
||||
FILTER_ORDER.forEach((k) => {
|
||||
const el = filterChips[k];
|
||||
if (el) el.classList.toggle('is-active', k === activeFilter);
|
||||
if (el) el.classList.toggle('is-active', activeFilters.has(k));
|
||||
});
|
||||
if (engine) engine.setFilter(FILTERS[key].pred);
|
||||
if (engine) engine.setFilter(currentFilterPredicate);
|
||||
}
|
||||
|
||||
function profileInfoRoute(login) {
|
||||
@@ -254,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
function persistHistory() {
|
||||
persistedCenterLogin = centerLogin;
|
||||
persistedCenterHistory = [...centerHistory];
|
||||
persistedGraphHistory = [...graphHistory];
|
||||
}
|
||||
|
||||
function syncLinksUrl(login, { push = false } = {}) {
|
||||
@@ -392,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
stage: board,
|
||||
model,
|
||||
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
||||
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
|
||||
onNodeTap: (node) => {
|
||||
const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0);
|
||||
void load(node.login, { pushHistory: true, transitionAngle });
|
||||
},
|
||||
// тап по центру — полноценный профиль
|
||||
onCenterTap: (node) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
@@ -415,7 +692,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function load(nextCenterLogin = '', { pushHistory = false } = {}) {
|
||||
async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) {
|
||||
const requestId = ++loadSeq;
|
||||
const prevCenter = centerLogin;
|
||||
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
||||
@@ -430,10 +707,35 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||
|
||||
const graphModel = buildGraphModel(graph, targetCenter);
|
||||
const engineModel = engineModelFromGraphModel(graphModel);
|
||||
const snapshotModel = engineModelFromGraphModel(graphModel);
|
||||
const snapshot = {
|
||||
centerLogin: targetCenter,
|
||||
engineModel: snapshotModel,
|
||||
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
|
||||
};
|
||||
|
||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||
// Один и тот же пользователь не хранится как два исторических центра: повторный переход
|
||||
// переносит его в конец истории, где он становится актуальным центром.
|
||||
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
|
||||
graphHistory.push(snapshot);
|
||||
if (graphHistory.length > HISTORY_MAX_CENTERS) {
|
||||
graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS);
|
||||
centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1));
|
||||
}
|
||||
} else {
|
||||
const last = graphHistory[graphHistory.length - 1];
|
||||
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
|
||||
snapshot.transitionAngle = Number(last.transitionAngle) || 0;
|
||||
graphHistory[graphHistory.length - 1] = snapshot;
|
||||
} else {
|
||||
graphHistory = [snapshot];
|
||||
}
|
||||
}
|
||||
|
||||
const engineModel = buildHistoryEngineModel(graphHistory);
|
||||
ensureEngine(engineModel);
|
||||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
||||
|
||||
persistHistory();
|
||||
} catch (error) {
|
||||
@@ -475,6 +777,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
if (routeLogin) {
|
||||
centerLogin = routeLogin;
|
||||
centerHistory = [];
|
||||
graphHistory = [];
|
||||
persistHistory();
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else if (keepHistory && centerLogin) {
|
||||
@@ -482,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
} else {
|
||||
centerLogin = normalizeLogin(state.session.login || '');
|
||||
centerHistory = [];
|
||||
graphHistory = [];
|
||||
persistHistory();
|
||||
if (centerLogin) {
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
@@ -499,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
FILTER_ORDER.forEach((key) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
|
||||
chip.className = `fg-filter-chip${activeFilters.has(key) ? ' is-active' : ''}`;
|
||||
chip.textContent = FILTERS[key].label;
|
||||
chip.addEventListener('click', () => applyFilter(key));
|
||||
filterChips[key] = chip;
|
||||
|
||||
@@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) {
|
||||
relationType: 'self',
|
||||
strength: 1,
|
||||
shining: Boolean(centerMark?.shine),
|
||||
official: Boolean(centerMark?.official),
|
||||
tier: 1,
|
||||
};
|
||||
|
||||
@@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) {
|
||||
relationType: relationTypeFromRelation(r),
|
||||
strength: deriveStrength(r || {}),
|
||||
shining: Boolean(r?.mark?.shine),
|
||||
official: Boolean(r?.mark?.official),
|
||||
tier: 1,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами
|
||||
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||
const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты
|
||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||||
@@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const rebuildIndex = () => {
|
||||
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||
hasDeep = nodes.some((n) => n.tier >= 2);
|
||||
// число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
|
||||
childCountByParent = 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;
|
||||
for (const n of nodes) {
|
||||
if (n.tier >= 2 && n.parentId) {
|
||||
const i = childCountByParent.get(n.parentId) || 0;
|
||||
n.sibIndex = i;
|
||||
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) {
|
||||
tier1count += 1;
|
||||
}
|
||||
}
|
||||
degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня
|
||||
degreeById.set(focusId, tier1count);
|
||||
};
|
||||
|
||||
// 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 tier1Orbit = buildOrbitPlacements(tier1.length);
|
||||
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-й — маленькими аватарками
|
||||
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 };
|
||||
@@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
|
||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||
const tier = Number(src.tier) || 1;
|
||||
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
|
||||
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
|
||||
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
|
||||
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
|
||||
// Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view
|
||||
// может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему
|
||||
// кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер.
|
||||
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-й — микрозвезда.
|
||||
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 tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
||||
const ty = isFocus ? 0 : Math.sin(angle) * targetR;
|
||||
const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR);
|
||||
const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR);
|
||||
const el = buildNodeElement(src, isFocus, tier, dotOnly);
|
||||
world.append(el);
|
||||
return {
|
||||
...src,
|
||||
isFocus,
|
||||
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,
|
||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
|
||||
@@ -501,6 +530,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';
|
||||
function buildPngOrb(src, opts) {
|
||||
const o = opts || {};
|
||||
const wrap = document.createElement('div');
|
||||
@@ -528,6 +558,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
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) {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
@@ -537,6 +586,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
'ui-button', 'fg-node', 'fg-dot',
|
||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
||||
src.shining ? 'is-shine' : '',
|
||||
src.official ? 'is-official' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
].filter(Boolean).join(' ');
|
||||
el.dataset.nodeId = String(src.id);
|
||||
@@ -549,6 +599,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
'ui-button', 'fg-node',
|
||||
isFocus ? 'is-focus' : '',
|
||||
src.shining ? 'is-shine' : '',
|
||||
src.official ? 'is-official' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
|
||||
tier >= 2 ? 'is-secondary' : '',
|
||||
@@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
|
||||
dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
|
||||
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');
|
||||
label.className = 'fg-node-label';
|
||||
@@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return el;
|
||||
}
|
||||
|
||||
// Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex.
|
||||
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; }
|
||||
}
|
||||
}
|
||||
// Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой.
|
||||
function updateBadges() {}
|
||||
|
||||
// Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
|
||||
function updateA11y() {
|
||||
@@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.strength = strength;
|
||||
node.relationType = src.relationType;
|
||||
node.shining = Boolean(src.shining);
|
||||
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
|
||||
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
|
||||
node.official = Boolean(src.official);
|
||||
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.tx = Math.cos(node.angle) * node.targetR;
|
||||
node.ty = Math.sin(node.angle) * node.targetR;
|
||||
node.tx = node.fixedLayout ? layoutX : Math.cos(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.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||
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)));
|
||||
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||
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', 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', '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' : '', 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})`;
|
||||
}
|
||||
|
||||
|
||||
// Ограничение ручного 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) и, если он
|
||||
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
|
||||
@@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
zoom = z1;
|
||||
camX = sx - centerX - wx * z1;
|
||||
camY = sy - centerY - wy * z1;
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
@@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
n.lod = full ? 'full' : 'dot';
|
||||
n.dotOnly = !full;
|
||||
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() {
|
||||
if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) {
|
||||
renderHistoryEdges();
|
||||
return;
|
||||
}
|
||||
const focus = nodes.find((n) => n.id === focusId);
|
||||
if (!focus) {
|
||||
edgesSvg.innerHTML = '';
|
||||
@@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
|
||||
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
|
||||
// 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 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)}`;
|
||||
@@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let ax = 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);
|
||||
ay += K_FOCUS * (0 - n.y);
|
||||
@@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
||||
const dist = Math.sqrt(dist2);
|
||||
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
|
||||
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
|
||||
const ex = Math.max(n.expandP || 0, m.expandP || 0);
|
||||
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
|
||||
ax += (dx / dist) * f;
|
||||
@@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
|
||||
const visiblePeers = [];
|
||||
nodes.forEach((n) => {
|
||||
if (n.isFocus) { n.hidden = false; return; }
|
||||
if (n.isFocus || n.keepVisible) { n.hidden = false; return; }
|
||||
n.hidden = !pred(n);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
@@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
};
|
||||
|
||||
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) => {
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
apply(n, n.tx, n.ty, sc, 1);
|
||||
@@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// инерция панорамирования (kinematic): камера докатывается с трением
|
||||
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
||||
if (panActive) {
|
||||
camX += panVelX;
|
||||
camY += panVelY;
|
||||
const proposedX = camX + panVelX;
|
||||
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;
|
||||
panVelY *= PAN_FRICTION;
|
||||
applyWorldTransform();
|
||||
@@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const newCamY = camStartY + dy;
|
||||
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
|
||||
panVelY = newCamY - camY;
|
||||
camX = newCamX;
|
||||
camY = newCamY;
|
||||
const clamped = clampCameraPosition(newCamX, newCamY);
|
||||
camX = clamped.x;
|
||||
camY = clamped.y;
|
||||
if (camX !== newCamX) panVelX = 0;
|
||||
if (camY !== newCamY) panVelY = 0;
|
||||
applyWorldTransform();
|
||||
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
|
||||
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
||||
@@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
viewH = stage.clientHeight || window.innerHeight;
|
||||
centerX = viewW / 2;
|
||||
centerY = viewH / 2;
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
}
|
||||
|
||||
@@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||
freezeGraph();
|
||||
({ x: camX, y: camY } = clampCameraPosition(camX, camY));
|
||||
applyWorldTransform();
|
||||
renderEdges();
|
||||
return;
|
||||
}
|
||||
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));
|
||||
|
||||
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
||||
spawnGhost();
|
||||
// В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят
|
||||
// в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём.
|
||||
if (!nextModel?.preserveHistory) spawnGhost();
|
||||
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
||||
|
||||
focusId = String(newFocusId);
|
||||
@@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return node;
|
||||
});
|
||||
pendingFocusOrigin = null;
|
||||
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
|
||||
// Новый центр меняет положение карты, но пользовательский масштаб сохраняем.
|
||||
// Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком.
|
||||
diveTargetId = null; surfacing = false;
|
||||
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
|
||||
updateBadges(); // бейджи-счётчики связей под новый набор
|
||||
updateA11y(); // текстовый список графа для скринридеров
|
||||
|
||||
@@ -200,6 +200,24 @@
|
||||
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 {
|
||||
background: linear-gradient(165deg, #785038, #5f3e2c);
|
||||
border-color: rgba(255, 194, 143, 0.6);
|
||||
@@ -241,15 +259,17 @@
|
||||
.fg-node.is-pressed .node-dot { transform: none; }
|
||||
}
|
||||
|
||||
/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
||||
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
|
||||
визуально сияющий пользователь всегда остаётся явно сияющим. */
|
||||
/* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути
|
||||
от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */
|
||||
.fg-node.is-shine .node-dot {
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -259,34 +279,8 @@
|
||||
filter: url(#fg-shine-glow);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
animation: fg-shine-halo 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */
|
||||
@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; }
|
||||
transform: scale(1.07);
|
||||
opacity: 0.91;
|
||||
}
|
||||
|
||||
/* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */
|
||||
@@ -524,37 +518,11 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* === Партия 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); }
|
||||
/* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */
|
||||
|
||||
/* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user