From f44ab75c6485517646c716a5785f4710f1d9bbd944803648dbfa74f908b0caba Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 17 Sep 2026 23:35:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=84=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/components/avatar-image.js | 12 +++++ shine-UI/js/pages/network-view.js | 58 ++++++++++++++++++------ shine-UI/js/pages/network/force-graph.js | 55 +++++++++++++++++++--- shine-UI/js/pages/profile-view.js | 1 + shine-UI/js/pages/user-profile-view.js | 1 + shine-UI/styles/components/avatar.css | 20 ++++++++ shine-UI/styles/features/profile.css | 5 +- shine-UI/styles/network-graph.css | 8 ++-- 9 files changed, 135 insertions(+), 27 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 93e1fe2e..12e6b19f 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.17 +client.version=1.12.18 server.version=1.10.7 diff --git a/shine-UI/js/components/avatar-image.js b/shine-UI/js/components/avatar-image.js index f4e677bc..9857f066 100644 --- a/shine-UI/js/components/avatar-image.js +++ b/shine-UI/js/components/avatar-image.js @@ -36,6 +36,7 @@ export function renderAvatar({ title = '', alt = 'Аватар', glow = false, + official = false, } = {}) { const wrap = document.createElement('div'); const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']); @@ -52,6 +53,15 @@ export function renderAvatar({ fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?'; wrap.append(fallback); + if (official) { + const badge = document.createElement('img'); + badge.className = 'avatar-official-badge'; + badge.src = '/assets/shine-official-badge.svg?v=2'; + badge.alt = ''; + badge.setAttribute('aria-hidden', 'true'); + wrap.append(badge); + } + const txId = String(avatar?.ar || '').trim(); if (!validateArweaveTxId(txId)) { return wrap; @@ -146,6 +156,7 @@ export function renderUserAvatar({ className = '', title = '', glow = false, + official = false, } = {}) { return renderAvatar({ initials: buildAvatarInitials({ login, firstName, lastName }), @@ -155,5 +166,6 @@ export function renderUserAvatar({ title, alt: 'Аватар', glow, + official, }); } diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 44018ee2..0f9f3ad3 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI const centerKey = normKey(snap?.centerLogin); const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); - if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); + if (ownCenter) centerNodeByKey.set(centerKey, { + ...ownCenter, + isHistoryCenter: true, + clusterOwnerId: centerKey, + keepVisible: true, + tier: 1, + }); snapNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. - if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); + if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) { + // Общий узел принадлежит последнему кластеру, возле которого он был перерисован. + nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey }); + } if (key === centerKey) return; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; @@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI id: key, login: node?.login || node?.id || key, tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), + isHistoryCenter: centerKeys.has(key), fixedLayout: true, keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + clusterOwnerId: normKey(node?.clusterOwnerId || key), edgeParents: edgeParentsByChild.get(key) || [], })); @@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) { window.setTimeout(() => inputEl.focus(), 0); } - function persistManualNodePosition(nodeId, point) { - const key = normKey(nodeId); - const x = Number(point?.x); - const y = Number(point?.y); - if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + function persistManualNodePositions(rows) { + const positions = new Map(); + (Array.isArray(rows) ? rows : []).forEach((row) => { + const key = normKey(row?.id || row?.login); + const x = Number(row?.x); + const y = Number(row?.y); + if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y }); + }); + if (!positions.size) return; // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. @@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) { const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); let changed = false; model.nodes = model.nodes.map((node) => { - if (normKey(node?.id) !== key) return node; + const point = positions.get(normKey(node?.id)); + if (!point) return node; changed = true; return { ...node, - layoutX: x, - layoutY: y, + layoutX: point.x, + layoutY: point.y, fixedLayout: true, }; }); @@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) { } void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); }, - // тап по центру — полноценный профиль + // Короткий тап по текущему центру сразу открывает его меню. onCenterTap: (node) => { - const routeTo = profileInfoRoute(node.login); - if (routeTo) navigate(routeTo); + const rect = node?.el?.getBoundingClientRect?.(); + openNodeMenu({ + login: normalizeLogin(node?.login), + displayName: String(node?.name || '').trim(), + relationType: node?.relationType, + point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined, + actions: [ + { label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } }, + { label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) }, + ], + }); }, // долгое нажатие — контекстное меню (вне масштабируемого холста) onNodeLongPress: (node, point) => { @@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) { // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. onNodeMoveEnd: (node, point) => { - persistManualNodePosition(node?.id || node?.login, point); + persistManualNodePositions([{ id: node?.id || node?.login, ...point }]); + }, + // Центр и узлы, которые в последний раз были нарисованы возле него, + // сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild. + onGroupMoveEnd: (movedNodes) => { + persistManualNodePositions(movedNodes); }, }); } diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 6be6d03d..d2b02ee0 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -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) diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index c6f71c00..bb7d426e 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -176,6 +176,7 @@ export function render({ navigate, chrome }) { size: 'xl', className: 'user-profile-hero-avatar', glow: shining, + official, })); status.textContent = ''; diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index b2f0f48b..4d74cb2b 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) { size: 'xl', className: 'user-profile-hero-avatar', glow: shining, + official, })); addMenu = body.querySelector('.user-profile-add-menu'); diff --git a/shine-UI/styles/components/avatar.css b/shine-UI/styles/components/avatar.css index 98c03094..9f693cad 100644 --- a/shine-UI/styles/components/avatar.css +++ b/shine-UI/styles/components/avatar.css @@ -269,3 +269,23 @@ pointer-events: none; z-index: -1; } + +/* Единый знак официального пользователя. Левый и нижний края зафиксированы, + а сам знак растёт вправо и вверх вместе с аватаром. */ +.avatar.avatar-image.avatar-framed > .avatar-official-badge { + position: absolute; + left: -2%; + bottom: -1%; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; + object-fit: contain; + object-position: center; + display: block; + opacity: 1; + border-radius: 0; + pointer-events: none; + z-index: 4; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index cb09eda5..6dd785ae 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -800,7 +800,10 @@ .user-profile-screen .user-profile-hero-avatar.avatar-glow::before { inset: -12%; - opacity: 0.5; + background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); + filter: blur(3.4px); + transform: scale(1.07); + opacity: 0.91; } diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 3a6c95da..6ca65f7d 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -210,10 +210,10 @@ position: absolute; left: -2%; bottom: -1%; - width: 16%; - height: 16%; - min-width: 9px; - min-height: 9px; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; object-fit: contain; object-position: center; display: block;