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
|
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`
|
||||||
|
|
||||||
Типы:
|
Типы:
|
||||||
|
|||||||
@@ -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 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' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(); // текстовый список графа для скринридеров
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user