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

This commit is contained in:
AidarKC
2026-09-10 14:52:20 +03:00
parent d83f1d4cce
commit 849250bfa8
9 changed files with 613 additions and 131 deletions
+223 -49
View File
@@ -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(); // текстовый список графа для скринридеров