SHiNE-server/shine-UI/js/pages/network/node-menu.js
Pixel e0f0726e68 Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами.

Движок (js/pages/network/force-graph.js):
- diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте);
- мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса;
- динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски;
- жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи;
- линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией;
- хард-лимит DOM-аватарок (остальное — SVG-точки).

Интеграция и UX:
- adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only);
- фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль;
- прицел в центре, дыхание фокуса, свечение сияющих;
- лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда.

Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md.
Бамп client.version 1.2.135 -> 1.2.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:16 +03:00

85 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Общее контекстное меню узла (долгое нажатие) для карты связей.
// Рендерится в #modal-root (вне масштабируемого холста), позиционируется по экранному rect узла.
// Используется и реальным путём (network-view.js), и лабораторией (lab.js).
const REL_RU = {
family: 'Семья',
friend: 'Друг',
business: 'Бизнес',
contact: 'Контакт',
self: 'Вы',
};
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function relationLabelRu(relationType) {
return REL_RU[relationType] || 'Контакт';
}
/**
* Открывает контекстное меню узла.
* @param {Object} opts
* @param {string} opts.login - логин/идентификатор для заголовка
* @param {string} opts.relationType - тип связи (для подписи)
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
* @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню
*/
export function openNodeMenu({ login, relationType, point, actions = [] } = {}) {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return;
const itemsHtml = actions
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
.join('');
root.innerHTML = `
<div class="fg-menu-overlay" id="fg-menu-overlay">
<div class="fg-menu" id="fg-menu" role="menu">
<div class="fg-menu-head">
<span class="fg-menu-login">${escapeHtml(login)}</span>
<span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span>
</div>
${itemsHtml}
</div>
</div>
`;
const overlay = root.querySelector('#fg-menu-overlay');
const menu = root.querySelector('#fg-menu');
if (!(overlay instanceof HTMLElement) || !(menu instanceof HTMLElement)) { root.innerHTML = ''; return; }
// позиционируем рядом с узлом по его экранному rect (синхронно, без rAF — он может троттлиться)
const rect = point?.rect;
const px = point?.x ?? (rect ? rect.left + rect.width / 2 : window.innerWidth / 2);
const belowY = rect ? rect.bottom + 8 : (point?.y ?? 80);
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
let left = px - mw / 2;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
let top = belowY;
if (top + mh > window.innerHeight - 8) top = Math.max(8, (rect ? rect.top : belowY) - mh - 8);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
const close = () => { root.innerHTML = ''; };
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
menu.addEventListener('click', (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-i]') : null;
if (!(btn instanceof HTMLElement)) return;
const idx = Number(btn.dataset.i);
const action = actions[idx];
if (!action || action.disabled) return;
close();
if (typeof action.onClick === 'function') action.onClick();
});
return close;
}