diff --git a/VERSION.properties b/VERSION.properties index 337496b..e927a5c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.11 -server.version=1.2.11 +client.version=1.2.12 +server.version=1.2.12 diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index bfee6af..f598645 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -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 = ` + + + + `; 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 }) { Односторонняя связь `; - 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 = ` -
- `; - - 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; diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 32c6d2b..cf7e1fc 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -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 = `