SHA256
Связи: интерактивная карта связей (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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
885cf463a7
commit
f56e531384
@@ -0,0 +1,84 @@
|
||||
// Общее контекстное меню узла (долгое нажатие) для карты связей.
|
||||
// Рендерится в #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;
|
||||
}
|
||||
Reference in New Issue
Block a user