SHA256
feat(relations): spouse 40/41 и новый UX вкладки Связи (проверено)
This commit is contained in:
+191
-110
@@ -1,6 +1,7 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
@@ -87,9 +88,94 @@ function getRelativeGenderMap(graph) {
|
||||
applyRelativeGender(map, graph?.parents);
|
||||
applyRelativeGender(map, graph?.children);
|
||||
applyRelativeGender(map, graph?.siblings);
|
||||
applyRelativeGender(map, graph?.spouses);
|
||||
return map;
|
||||
}
|
||||
|
||||
function relativeRoleLabel(role, gender) {
|
||||
const cleanGender = normalizeGender(gender);
|
||||
if (role === 'parent') {
|
||||
if (cleanGender === GENDER_MALE) return 'отец';
|
||||
if (cleanGender === GENDER_FEMALE) return 'мать';
|
||||
return 'родитель';
|
||||
}
|
||||
if (role === 'child') {
|
||||
if (cleanGender === GENDER_MALE) return 'сын';
|
||||
if (cleanGender === GENDER_FEMALE) return 'дочь';
|
||||
return 'потомок';
|
||||
}
|
||||
if (role === 'sibling') {
|
||||
if (cleanGender === GENDER_MALE) return 'брат';
|
||||
if (cleanGender === GENDER_FEMALE) return 'сестра';
|
||||
return 'брат/сестра';
|
||||
}
|
||||
if (role === 'spouse') {
|
||||
if (cleanGender === GENDER_MALE) return 'муж';
|
||||
if (cleanGender === GENDER_FEMALE) return 'жена';
|
||||
return 'жена/муж';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildNameLines(firstName, lastName) {
|
||||
const first = String(firstName || '').trim();
|
||||
const last = String(lastName || '').trim();
|
||||
if (first && last) {
|
||||
const full = `${first} ${last}`.trim();
|
||||
if (full.length <= 20) return [full];
|
||||
return [first, last];
|
||||
}
|
||||
if (first) return [first];
|
||||
if (last) return [last];
|
||||
return [];
|
||||
}
|
||||
|
||||
function applyNodeText(node, {
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
role = 'friend',
|
||||
gender = GENDER_UNKNOWN,
|
||||
mark = null,
|
||||
} = {}) {
|
||||
const loginText = normalizeLogin(login);
|
||||
const labelsWrap = node.querySelector('.node-label');
|
||||
const nameEl = node.querySelector('.node-name');
|
||||
const loginEl = node.querySelector('.node-login');
|
||||
const relationEl = node.querySelector('.node-relation');
|
||||
if (!(labelsWrap instanceof HTMLElement) || !(nameEl instanceof HTMLElement) || !(loginEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameLines = buildNameLines(firstName, lastName);
|
||||
nameEl.innerHTML = '';
|
||||
if (nameLines.length) {
|
||||
nameLines.forEach((line) => {
|
||||
const lineEl = document.createElement('span');
|
||||
lineEl.className = 'node-name-line';
|
||||
lineEl.textContent = line;
|
||||
nameEl.append(lineEl);
|
||||
});
|
||||
labelsWrap.classList.remove('is-login-only');
|
||||
} else {
|
||||
labelsWrap.classList.add('is-login-only');
|
||||
}
|
||||
loginEl.textContent = loginText;
|
||||
|
||||
const relLabel = relativeRoleLabel(role, gender);
|
||||
if (relationEl instanceof HTMLElement) {
|
||||
relationEl.textContent = relLabel;
|
||||
relationEl.hidden = !relLabel;
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (mark?.officialLabel) metaParts.push(mark.officialLabel);
|
||||
if (mark?.shineLabel) metaParts.push(mark.shineLabel);
|
||||
if (relLabel) metaParts.push(`роль: ${relLabel}`);
|
||||
const titleMain = nameLines.length ? `${nameLines.join(' ')} (${loginText})` : loginText;
|
||||
node.title = metaParts.length ? `${titleMain}\n${metaParts.join(', ')}` : titleMain;
|
||||
}
|
||||
|
||||
function spread(count, start, end) {
|
||||
if (count <= 0) return [];
|
||||
if (count === 1) return [(start + end) / 2];
|
||||
@@ -99,7 +185,16 @@ function spread(count, start, end) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = null }) {
|
||||
function buildNodeElement({
|
||||
login,
|
||||
kind = 'friend',
|
||||
isCenter = false,
|
||||
mark = null,
|
||||
role = 'friend',
|
||||
gender = GENDER_UNKNOWN,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
}) {
|
||||
const node = document.createElement('button');
|
||||
node.type = 'button';
|
||||
const classes = [
|
||||
@@ -112,12 +207,6 @@ function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = nul
|
||||
node.className = classes.join(' ');
|
||||
node.dataset.nodeLogin = login;
|
||||
|
||||
const metaParts = [];
|
||||
if (mark?.officialLabel) metaParts.push(mark.officialLabel);
|
||||
if (mark?.shineLabel) metaParts.push(mark.shineLabel);
|
||||
const metaText = metaParts.join(', ');
|
||||
node.title = metaText ? `${login}\n${metaText}` : login;
|
||||
|
||||
if (mark?.official) {
|
||||
const officialBadge = document.createElement('span');
|
||||
officialBadge.className = 'node-badge-official';
|
||||
@@ -131,10 +220,16 @@ function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = nul
|
||||
size: 'node',
|
||||
title: login,
|
||||
}));
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'node-label';
|
||||
label.textContent = login;
|
||||
label.innerHTML = `
|
||||
<span class="node-name"></span>
|
||||
<span class="node-login"></span>
|
||||
<span class="node-relation" hidden></span>
|
||||
`;
|
||||
node.append(label);
|
||||
applyNodeText(node, { login, firstName, lastName, role, gender, mark });
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -148,6 +243,8 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const inChildren = toSet(graph?.inChildren);
|
||||
const outSiblings = toSet(graph?.outSiblings);
|
||||
const inSiblings = toSet(graph?.inSiblings);
|
||||
const outSpouses = toSet(graph?.outSpouses);
|
||||
const inSpouses = toSet(graph?.inSpouses);
|
||||
|
||||
const relativesGender = getRelativeGenderMap(graph);
|
||||
const allMarks = getMarkByLogin(graph?.allUsers);
|
||||
@@ -161,6 +258,8 @@ function buildGraphModel(graph, centerLogin) {
|
||||
...(graph?.inChildren || []),
|
||||
...(graph?.outSiblings || []),
|
||||
...(graph?.inSiblings || []),
|
||||
...(graph?.outSpouses || []),
|
||||
...(graph?.inSpouses || []),
|
||||
]).filter((entry) => normKey(entry) !== normKey(login));
|
||||
|
||||
const relations = allLogins.map((targetLogin) => {
|
||||
@@ -170,12 +269,15 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const childIn = hasLogin(inParents, targetLogin);
|
||||
const siblingOut = hasLogin(outSiblings, targetLogin);
|
||||
const siblingIn = hasLogin(inSiblings, targetLogin);
|
||||
const spouseOut = hasLogin(outSpouses, targetLogin);
|
||||
const spouseIn = hasLogin(inSpouses, targetLogin);
|
||||
const friendOut = hasLogin(outFriends, targetLogin);
|
||||
const friendIn = hasLogin(inFriends, targetLogin);
|
||||
|
||||
let role = 'friend';
|
||||
if (parentOut || parentIn) role = 'parent';
|
||||
else if (childOut || childIn) role = 'child';
|
||||
else if (spouseOut || spouseIn) role = 'spouse';
|
||||
else if (siblingOut || siblingIn) role = 'sibling';
|
||||
|
||||
let forward = friendOut;
|
||||
@@ -186,6 +288,9 @@ function buildGraphModel(graph, centerLogin) {
|
||||
} else if (role === 'child') {
|
||||
forward = childOut;
|
||||
backward = childIn;
|
||||
} else if (role === 'spouse') {
|
||||
forward = spouseOut;
|
||||
backward = spouseIn;
|
||||
} else if (role === 'sibling') {
|
||||
forward = siblingOut;
|
||||
backward = siblingIn;
|
||||
@@ -240,16 +345,19 @@ function layoutNodes(model) {
|
||||
isCenter: true,
|
||||
kind: 'center',
|
||||
relation: null,
|
||||
gender: GENDER_UNKNOWN,
|
||||
mark: model.centerMark,
|
||||
};
|
||||
|
||||
const parents = sortByLogin(model.relations.filter((item) => item.role === 'parent'));
|
||||
const children = sortByLogin(model.relations.filter((item) => item.role === 'child'));
|
||||
const spouses = sortByLogin(model.relations.filter((item) => item.role === 'spouse'));
|
||||
const siblings = sortByLogin(model.relations.filter((item) => item.role === 'sibling'));
|
||||
const friends = sortByLogin(model.relations.filter((item) => item.role === 'friend'));
|
||||
|
||||
const parentSplit = splitByGender(parents);
|
||||
const childSplit = splitByGender(children);
|
||||
const spouseSplit = splitByGender(spouses);
|
||||
const siblingSplit = splitByGender(siblings);
|
||||
|
||||
const friendLeft = [];
|
||||
@@ -260,17 +368,20 @@ function layoutNodes(model) {
|
||||
});
|
||||
|
||||
const positioned = [
|
||||
...positionRows(parentSplit.left, 28, 14, 30),
|
||||
...positionRows(parentSplit.right, 72, 14, 30),
|
||||
...positionRows(parentSplit.left, 28, 16, 28),
|
||||
...positionRows(parentSplit.right, 72, 16, 28),
|
||||
...positionRows(parentSplit.center, 50, 10, 22),
|
||||
...positionRows(friendLeft, 12, 28, 72),
|
||||
...positionRows(friendRight, 88, 28, 72),
|
||||
...positionRows(siblingSplit.left, 30, 48, 70),
|
||||
...positionRows(siblingSplit.right, 70, 48, 70),
|
||||
...positionRows(siblingSplit.center, 50, 58, 74),
|
||||
...positionRows(childSplit.left, 28, 70, 90),
|
||||
...positionRows(childSplit.right, 72, 70, 90),
|
||||
...positionRows(childSplit.center, 50, 82, 94),
|
||||
...positionRows(friendLeft, 12, 30, 70),
|
||||
...positionRows(friendRight, 88, 30, 70),
|
||||
...positionRows(spouseSplit.left, 36, 38, 58),
|
||||
...positionRows(spouseSplit.right, 64, 38, 58),
|
||||
...positionRows(spouseSplit.center, 50, 40, 56),
|
||||
...positionRows(siblingSplit.left, 30, 46, 66),
|
||||
...positionRows(siblingSplit.right, 70, 46, 66),
|
||||
...positionRows(siblingSplit.center, 50, 54, 70),
|
||||
...positionRows(childSplit.left, 28, 68, 84),
|
||||
...positionRows(childSplit.right, 72, 68, 84),
|
||||
...positionRows(childSplit.center, 50, 78, 88),
|
||||
];
|
||||
|
||||
const nodes = [centerNode];
|
||||
@@ -286,6 +397,7 @@ function layoutNodes(model) {
|
||||
isCenter: false,
|
||||
kind: item.isRelative ? 'relative' : 'friend',
|
||||
relation: item.role,
|
||||
gender: item.gender,
|
||||
mark: item.mark,
|
||||
});
|
||||
edges.push({
|
||||
@@ -401,90 +513,74 @@ export function render({ navigate }) {
|
||||
<span><i class="legend-arrow"></i> Односторонняя связь</span>
|
||||
`;
|
||||
|
||||
let activeMenu = null;
|
||||
const profileCardCache = new Map();
|
||||
let centerLogin = state.session.login || '';
|
||||
let redrawEdges = () => {};
|
||||
let loadSeq = 0;
|
||||
|
||||
function closeNodeMenu() {
|
||||
if (!activeMenu) return;
|
||||
activeMenu.remove();
|
||||
activeMenu = null;
|
||||
function profileInfoRoute(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||||
return `user-profile-view/${encodeURIComponent(cleanLogin)}/network-view`;
|
||||
}
|
||||
|
||||
function openNodeMenu(node, login) {
|
||||
closeNodeMenu();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'node-menu card';
|
||||
menu.innerHTML = `
|
||||
<div class="node-menu-actions">
|
||||
<button class="ghost-btn" type="button" data-menu-action="show-info">Показать информацию</button>
|
||||
<button class="ghost-btn" type="button" data-menu-action="show-graph">Показать связи</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const boardRect = board.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2 - boardRect.left;
|
||||
const y = rect.bottom - boardRect.top + 8;
|
||||
|
||||
menu.style.left = `${Math.max(8, Math.min(x - 120, boardRect.width - 248))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
|
||||
|
||||
const infoBtn = menu.querySelector('[data-menu-action="show-info"]');
|
||||
const graphBtn = menu.querySelector('[data-menu-action="show-graph"]');
|
||||
infoBtn?.addEventListener('click', () => {
|
||||
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
|
||||
closeNodeMenu();
|
||||
});
|
||||
graphBtn?.addEventListener('click', async () => {
|
||||
closeNodeMenu();
|
||||
await load(login);
|
||||
});
|
||||
|
||||
board.append(menu);
|
||||
activeMenu = menu;
|
||||
function getProfileCardCached(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
const key = normKey(cleanLogin);
|
||||
if (!cleanLogin) return Promise.resolve(null);
|
||||
if (!profileCardCache.has(key)) {
|
||||
profileCardCache.set(key, loadUserProfileCard(cleanLogin).catch(() => null));
|
||||
}
|
||||
return profileCardCache.get(key);
|
||||
}
|
||||
|
||||
function bindNodeInteraction(node, login, onLongPress) {
|
||||
let timerId = 0;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let longPressTriggered = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (!timerId) return;
|
||||
window.clearTimeout(timerId);
|
||||
timerId = 0;
|
||||
};
|
||||
|
||||
node.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
longPressTriggered = false;
|
||||
clearTimer();
|
||||
timerId = window.setTimeout(async () => {
|
||||
longPressTriggered = true;
|
||||
closeNodeMenu();
|
||||
await onLongPress(login);
|
||||
}, 500);
|
||||
async function hydrateNodeProfiles(layout, nodeElements, requestId) {
|
||||
const uniqueNodes = [];
|
||||
const seen = new Set();
|
||||
layout.nodes.forEach((nodeModel) => {
|
||||
const key = normKey(nodeModel.login);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
uniqueNodes.push(nodeModel);
|
||||
});
|
||||
|
||||
node.addEventListener('pointermove', (event) => {
|
||||
if (!timerId) return;
|
||||
const dx = Math.abs(event.clientX - startX);
|
||||
const dy = Math.abs(event.clientY - startY);
|
||||
if (dx > 8 || dy > 8) clearTimer();
|
||||
const cards = await Promise.all(uniqueNodes.map((nodeModel) => getProfileCardCached(nodeModel.login)));
|
||||
if (requestId !== loadSeq) return;
|
||||
|
||||
const cardByKey = new Map();
|
||||
cards.forEach((card) => {
|
||||
const login = normalizeLogin(card?.login);
|
||||
if (!login) return;
|
||||
cardByKey.set(normKey(login), card);
|
||||
});
|
||||
|
||||
node.addEventListener('pointerleave', clearTimer);
|
||||
node.addEventListener('pointercancel', clearTimer);
|
||||
node.addEventListener('pointerup', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
clearTimer();
|
||||
if (longPressTriggered) return;
|
||||
openNodeMenu(node, login);
|
||||
layout.nodes.forEach((nodeModel) => {
|
||||
const node = nodeElements.get(nodeModel.id);
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const card = cardByKey.get(normKey(nodeModel.login));
|
||||
const cardGender = normalizeGender(card?.gender);
|
||||
applyNodeText(node, {
|
||||
login: nodeModel.login,
|
||||
firstName: card?.firstName || '',
|
||||
lastName: card?.lastName || '',
|
||||
role: nodeModel.relation || 'friend',
|
||||
gender: nodeModel.gender === GENDER_UNKNOWN ? cardGender : nodeModel.gender,
|
||||
mark: nodeModel.mark || null,
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => redrawEdges());
|
||||
}
|
||||
|
||||
function bindNodeInteraction(node, nodeModel) {
|
||||
node.addEventListener('click', () => {
|
||||
if (nodeModel.isCenter) {
|
||||
const routeTo = profileInfoRoute(nodeModel.login);
|
||||
if (routeTo) navigate(routeTo);
|
||||
return;
|
||||
}
|
||||
void load(nodeModel.login);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,7 +588,6 @@ export function render({ navigate }) {
|
||||
const requestId = ++loadSeq;
|
||||
const targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login);
|
||||
centerLogin = targetCenter;
|
||||
closeNodeMenu();
|
||||
note.textContent = 'Загрузка связей...';
|
||||
|
||||
try {
|
||||
@@ -513,33 +608,28 @@ export function render({ navigate }) {
|
||||
login: nodeModel.login,
|
||||
kind: nodeModel.kind,
|
||||
isCenter: nodeModel.isCenter,
|
||||
role: nodeModel.relation || 'friend',
|
||||
gender: nodeModel.gender,
|
||||
mark: nodeModel.mark,
|
||||
});
|
||||
node.style.left = `${nodeModel.x}%`;
|
||||
node.style.top = `${nodeModel.y}%`;
|
||||
board.append(node);
|
||||
nodeElements.set(nodeModel.id, node);
|
||||
if (!nodeModel.isCenter) bindNodeInteraction(node, nodeModel.login, load);
|
||||
bindNodeInteraction(node, nodeModel);
|
||||
});
|
||||
|
||||
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
|
||||
requestAnimationFrame(() => redrawEdges());
|
||||
void hydrateNodeProfiles(layout, nodeElements, requestId);
|
||||
|
||||
note.textContent = 'Тап по узлу: меню «Показать информацию» или «Показать связи». Долгое нажатие: сделать узел центром.';
|
||||
note.textContent = 'Тап по узлу: нецентральный узел станет центром. Тап по центральному узлу: открыть профиль.';
|
||||
} catch (error) {
|
||||
if (requestId !== loadSeq) return;
|
||||
note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const outsideTapHandler = (event) => {
|
||||
if (!activeMenu) return;
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (activeMenu.contains(event.target)) return;
|
||||
closeNodeMenu();
|
||||
};
|
||||
document.addEventListener('pointerdown', outsideTapHandler, true);
|
||||
|
||||
const onResize = () => redrawEdges();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
@@ -550,19 +640,10 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
screen.cleanup = () => {
|
||||
document.removeEventListener('pointerdown', outsideTapHandler, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
|
||||
board.addEventListener('pointerdown', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.node')) return;
|
||||
if (target.closest('.node-menu')) return;
|
||||
closeNodeMenu();
|
||||
});
|
||||
|
||||
load();
|
||||
screen.append(renderHeader({ title: 'Связи' }), legend, board, note);
|
||||
return screen;
|
||||
|
||||
@@ -41,6 +41,7 @@ const GENDER_OPTIONS = Object.freeze([
|
||||
const RELATIVE_RELATION_OPTIONS = Object.freeze([
|
||||
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
|
||||
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
|
||||
{ value: 'spouse', label: 'Жена / Муж (по полу)' },
|
||||
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
|
||||
{ value: 'close_friend', label: 'Близкий друг' },
|
||||
]);
|
||||
@@ -69,6 +70,11 @@ function relationAccusativeLabel(type, targetGender) {
|
||||
if (gender === PROFILE_GENDER_FEMALE) return 'сестру';
|
||||
return 'брата/сестру';
|
||||
}
|
||||
if (type === 'spouse') {
|
||||
if (gender === PROFILE_GENDER_MALE) return 'мужа';
|
||||
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
|
||||
return 'жену/мужа';
|
||||
}
|
||||
return 'близкого друга';
|
||||
}
|
||||
|
||||
@@ -134,7 +140,7 @@ export function render({ navigate }) {
|
||||
relativesCard.innerHTML = `
|
||||
<div class="profile-param-value"><b>Близкие родственники</b></div>
|
||||
<div class="meta-muted">
|
||||
Добавьте связь: родитель, ребёнок, брат/сестра или близкий друг.
|
||||
Добавьте связь: родитель, ребёнок, жена/муж, брат/сестра или близкий друг.
|
||||
Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя.
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-add-relative="true">Добавить близких родственников</button>
|
||||
|
||||
@@ -50,6 +50,7 @@ const CONNECTION_SUBTYPES = Object.freeze({
|
||||
friend: { on: 10, off: 11 },
|
||||
contact: { on: 20, off: 21 },
|
||||
follow: { on: 30, off: 31 },
|
||||
spouse: { on: 40, off: 41 },
|
||||
parent: { on: 50, off: 51 },
|
||||
child: { on: 52, off: 53 },
|
||||
sibling: { on: 54, off: 55 },
|
||||
|
||||
Reference in New Issue
Block a user