Files
SHiNE-server/shine-UI/js/pages/network/node-menu.js
T
AidarKCandClaude Opus 4.8 f56e531384 Связи: интерактивная карта связей (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 12:43:56 +03:00

85 lines
3.5 KiB
JavaScript

// Общее контекстное меню узла (долгое нажатие) для карты связей.
// Рендерится в #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;
}