Улучшить аватары и граф связей

This commit is contained in:
AidarKC
2026-09-17 23:35:53 +03:00
parent 7aa05dc609
commit f44ab75c64
9 changed files with 135 additions and 27 deletions
+48 -7
View File
@@ -219,13 +219,14 @@ function hash01(str) {
* @param {Object} opts
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
* @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
* @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
@@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.relationType = src.relationType;
node.shining = Boolean(src.shining);
node.official = Boolean(src.official);
node.isHistoryCenter = Boolean(src.isHistoryCenter);
node.clusterOwnerId = String(src.clusterOwnerId || src.id || '');
node.keepVisible = Boolean(src.keepVisible);
node.alwaysVisible = Boolean(src.alwaysVisible);
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
@@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let nodeDragActive = false;
let nodeDragStartX = 0;
let nodeDragStartY = 0;
let groupDragActive = false;
let groupDragNodes = [];
let groupDragStarts = new Map();
let longTimer = 0;
let longFired = false;
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
@@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
downNode = nodeFromEvent(ev);
nodeDragActive = false;
groupDragActive = false;
groupDragNodes = [];
groupDragStarts = new Map();
nodeDragStartX = Number(downNode?.x) || 0;
nodeDragStartY = Number(downNode?.y) || 0;
if (downNode?.isHistoryCenter) {
const ownerId = String(downNode.clusterOwnerId || downNode.id);
groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId);
groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }]));
}
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
if (downNode && typeof onNodeLongPress === 'function') {
@@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
moved = true;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
nodeDragActive = Boolean(downNode && !downNode.isFocus);
if (nodeDragActive && cssBloom) endCssBloom();
// Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту.
groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length);
nodeDragActive = Boolean(downNode && !groupDragActive);
if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom();
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
cancelTween(); // жест прерывает анимацию центрирования
dragging = true;
}
if (moved && groupDragActive) {
const zx = dx / Math.max(0.001, zoom);
const zy = dy / Math.max(0.001, zoom);
groupDragNodes.forEach((node) => {
const start = groupDragStarts.get(String(node.id));
if (!start) return;
const nx = start.x + zx;
const ny = start.y + zy;
node.x = nx; node.y = ny;
node.tx = nx; node.ty = ny;
node.bfx = nx; node.bfy = ny;
node.vx = 0; node.vy = 0;
node.fixedLayout = true;
});
renderNodes();
renderEdges();
return;
}
if (moved && nodeDragActive && downNode) {
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
@@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const wasMoved = moved;
const wasLong = longFired;
const movedNode = nodeDragActive ? downNode : null;
const movedGroup = groupDragActive ? [...groupDragNodes] : [];
pointerId = null;
dragging = false;
nodeDragActive = false;
groupDragActive = false;
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
@@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
renderEdges();
} else if (wasMoved && movedGroup.length) {
if (typeof onGroupMoveEnd === 'function') {
onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y })));
}
renderEdges();
} else if (wasMoved) {
// после pan даём физике чуть устаканиться и уснуть
wake();
@@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
const finalX = node.isFocus ? 0 : node.tx;
const finalY = node.isFocus ? 0 : node.ty;
// Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel.
const finalX = node.tx;
const finalY = node.ty;
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)