SHA256
Compare commits
2
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ea0098d705 | ||
|
|
4991a146d5 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.12
|
||||
client.version=1.12.14
|
||||
server.version=1.10.4
|
||||
|
||||
+752
-222
File diff suppressed because it is too large
Load Diff
@@ -222,9 +222,10 @@ function hash01(str) {
|
||||
* @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
|
||||
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
||||
*/
|
||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) {
|
||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
|
||||
// Слои DOM
|
||||
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||||
edgesSvg.setAttribute('class', 'fg-edges');
|
||||
@@ -471,6 +472,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
|
||||
fixedLayout,
|
||||
keepVisible: Boolean(src.keepVisible),
|
||||
alwaysVisible: Boolean(src.alwaysVisible),
|
||||
official: Boolean(src.official),
|
||||
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||
@@ -530,7 +532,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';
|
||||
const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg?v=2';
|
||||
function buildPngOrb(src, opts) {
|
||||
const o = opts || {};
|
||||
const wrap = document.createElement('div');
|
||||
@@ -657,6 +659,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.shining = Boolean(src.shining);
|
||||
node.official = Boolean(src.official);
|
||||
node.keepVisible = Boolean(src.keepVisible);
|
||||
node.alwaysVisible = Boolean(src.alwaysVisible);
|
||||
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
||||
const layoutX = Number(src.layoutX);
|
||||
const layoutY = Number(src.layoutY);
|
||||
@@ -772,6 +775,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
for (const tier of [2, 3]) {
|
||||
for (const n of nodes) {
|
||||
if (n.tier !== tier) continue;
|
||||
// Реальный X2 из network-view приходит уже с collision-aware фиксированной позицией и должен
|
||||
// быть виден сразу, без клика/hover по родителю. Лабораторные deep-ветки без fixedLayout
|
||||
// продолжают работать по старой схеме раскрытия expandP.
|
||||
if (n.fixedLayout && n.alwaysVisible) {
|
||||
n.x = n.tx;
|
||||
n.y = n.ty;
|
||||
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
|
||||
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
|
||||
n.opacity = n.hidden ? 0 : baseOp;
|
||||
n.scale = baseSc;
|
||||
n.targetOpacity = n.opacity;
|
||||
n.targetScale = n.scale;
|
||||
continue;
|
||||
}
|
||||
const p = nodeById.get(n.parentId);
|
||||
if (!p) { n.opacity = 0; continue; }
|
||||
const e = p.expandP || 0; // насколько раскрыт родитель
|
||||
@@ -1032,7 +1049,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
|
||||
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
||||
}
|
||||
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
|
||||
const pe = n.alwaysVisible ? 1 : (parent.expandP || 0); // X2 виден сразу; старые deep-ветки — по раскрытию
|
||||
if (n.tier >= 3) {
|
||||
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
||||
if (pe > 0.02) {
|
||||
@@ -1451,6 +1468,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let camStartY = 0;
|
||||
let moved = false;
|
||||
let downNodeEl = null;
|
||||
let downNode = null;
|
||||
let nodeDragActive = false;
|
||||
let nodeDragStartX = 0;
|
||||
let nodeDragStartY = 0;
|
||||
let longTimer = 0;
|
||||
let longFired = false;
|
||||
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
|
||||
@@ -1579,7 +1600,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
longFired = false;
|
||||
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
||||
const downNode = nodeFromEvent(ev);
|
||||
downNode = nodeFromEvent(ev);
|
||||
nodeDragActive = false;
|
||||
nodeDragStartX = Number(downNode?.x) || 0;
|
||||
nodeDragStartY = Number(downNode?.y) || 0;
|
||||
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
|
||||
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
|
||||
if (downNode && typeof onNodeLongPress === 'function') {
|
||||
@@ -1630,13 +1654,29 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
moved = true;
|
||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
|
||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
|
||||
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
|
||||
nodeDragActive = Boolean(downNode && !downNode.isFocus);
|
||||
if (nodeDragActive && cssBloom) endCssBloom();
|
||||
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
|
||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
|
||||
cancelTween(); // жест прерывает анимацию центрирования
|
||||
dragging = true;
|
||||
}
|
||||
if (moved && nodeDragActive && downNode) {
|
||||
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
|
||||
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
|
||||
const ny = nodeDragStartY + dy / Math.max(0.001, zoom);
|
||||
downNode.x = nx; downNode.y = ny;
|
||||
downNode.tx = nx; downNode.ty = ny;
|
||||
downNode.bfx = nx; downNode.bfy = ny;
|
||||
downNode.vx = 0; downNode.vy = 0;
|
||||
downNode.fixedLayout = true;
|
||||
renderNodes();
|
||||
renderEdges();
|
||||
return;
|
||||
}
|
||||
if (moved) {
|
||||
const newCamX = camStartX + dx;
|
||||
const newCamY = camStartY + dy;
|
||||
@@ -1667,14 +1707,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
|
||||
const wasMoved = moved;
|
||||
const wasLong = longFired;
|
||||
const movedNode = nodeDragActive ? downNode : null;
|
||||
pointerId = null;
|
||||
dragging = false;
|
||||
nodeDragActive = false;
|
||||
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
|
||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||
|
||||
if (wasMoved || wasLong) {
|
||||
if (wasMoved && movedNode) {
|
||||
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
|
||||
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
|
||||
renderEdges();
|
||||
} else if (wasMoved) {
|
||||
// после pan даём физике чуть устаканиться и уснуть
|
||||
if (wasMoved) wake();
|
||||
wake();
|
||||
}
|
||||
downNode = null;
|
||||
return;
|
||||
}
|
||||
// это был тап
|
||||
@@ -1690,6 +1739,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
}
|
||||
if (tapNode.isFocus) {
|
||||
if (typeof onCenterTap === 'function') onCenterTap(tapNode);
|
||||
downNode = null;
|
||||
return;
|
||||
}
|
||||
if (typeof onNodeTap === 'function') {
|
||||
@@ -1700,6 +1750,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// нет внешнего обработчика — внутреннее перецентрирование (фолбэк)
|
||||
startRecenterTween(tapNode.id);
|
||||
}
|
||||
downNode = null;
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
|
||||
@@ -203,17 +203,24 @@
|
||||
|
||||
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
|
||||
масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */
|
||||
.fg-official-badge {
|
||||
/* ВАЖНО: селектор намеренно специфичнее глобального `.node-dot img` из features/network.css,
|
||||
где обычные фото стартуют с opacity:0 и width/height:100%. Иначе badge тоже наследует эти
|
||||
правила и становится полностью невидимым. */
|
||||
.fg-node .node-dot .fg-official-badge {
|
||||
position: absolute;
|
||||
left: -2%;
|
||||
bottom: 0;
|
||||
width: 15%;
|
||||
height: 15%;
|
||||
min-width: 8px;
|
||||
min-height: 8px;
|
||||
bottom: -1%;
|
||||
width: 16%;
|
||||
height: 16%;
|
||||
min-width: 9px;
|
||||
min-height: 9px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
z-index: 5;
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
transition: none;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
@@ -452,6 +459,20 @@
|
||||
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28);
|
||||
}
|
||||
|
||||
/* Служебные переключатели карты: история и X2 намеренно чуть компактнее основных фильтров. */
|
||||
.fg-history-chip {
|
||||
padding-left: 11px;
|
||||
padding-right: 11px;
|
||||
}
|
||||
|
||||
.fg-x2-chip {
|
||||
min-width: 36px;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */
|
||||
.fg-menu-overlay {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user