Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами. Движок (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>
85 lines
3.5 KiB
JavaScript
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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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;
|
||
}
|