From 519bce6b785cdf6f0c2b05cb43cffe4a9eaed73933dad82da8d530472fd58b0e Mon Sep 17 00:00:00 2001 From: Pixel Date: Wed, 10 Jun 2026 00:53:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-aquariu?= =?UTF-8?q?m,=2010.06):=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=8F=203=20(?= =?UTF-8?q?=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F)=20=E2=80=94=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B8=20+=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем). - Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана. - Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня) для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label). Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS. Бамп client.version → 1.2.150. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION.properties | 4 +-- .../features/interactive-network-graph.md | 6 ++++ shine-UI/js/pages/network/force-graph.js | 22 +++++++++++-- shine-UI/js/pages/network/lab.js | 12 +++++++ shine-UI/js/pages/network/selftest.js | 13 ++++++++ shine-UI/styles/network-graph.css | 32 +++++++++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index ccc36c5..30ea2ee 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.149 -server.version=1.2.133 +client.version=1.2.150 +server.version=1.2.134 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 9cb4e24..2a239ce 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -145,6 +145,12 @@ - **Бейдж числа связей** — `.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`). - **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`). +**Фишки (партия 3, лаборатория):** +- **Общие связи** — среди друзей человека один помечен как «общий» (он и твой друг тоже): золотой + ободок + ★ (CSS `is-common`; в лаб-генерации `addDeepLevels` подставляет узнаваемого друга Ивана). +- **Доступность** — визуально скрытый (`sr-only`) текстовый список графа `.fg-a11y` (центр + связи + 1-го уровня) для скринридеров; обновляется в `updateA11y` при перестроении. Полезно и для реального пути. + **Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`, прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index e9786db..77d0192 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -149,7 +149,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL edgesSvg.setAttribute('class', 'fg-edges'); const world = document.createElement('div'); world.className = 'fg-world'; - stage.append(edgesSvg, world); + // Доступность: визуально скрытый текстовый список графа для скринридеров (граф сам по себе им не читается). + const a11y = document.createElement('div'); + a11y.className = 'fg-a11y'; + a11y.setAttribute('role', 'region'); + a11y.setAttribute('aria-label', 'Карта связей — список'); + stage.append(edgesSvg, world, a11y); ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) // Состояние камеры (панорамирование + зум) @@ -433,6 +438,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier >= 2 ? 'is-secondary' : '', + src.common ? 'is-common' : '', // «общая связь» — этот человек и твой друг тоже (золотой ободок ★) ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); @@ -472,6 +478,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } } + // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком). + function updateA11y() { + const focus = nodes.find((n) => n.isFocus); + const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus); + const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' }; + const items = tier1.map((n) => `
  • ${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})
  • `).join(''); + a11y.innerHTML = `

    Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.

    `; + } + function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + // Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель. function updateNodeRole(node, spec) { const src = spec.src; @@ -502,7 +518,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM node.el.className = spec.dotOnly ? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') - : ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : ''].filter(Boolean).join(' '); + : ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); } // --- Рендер ---------------------------------------------------------------- @@ -1540,6 +1556,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов updateBadges(); // бейджи-счётчики связей под новый набор + updateA11y(); // текстовый список графа для скринридеров layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает) emitDiveChange(); // сбрасываем хлебные крошки (новый граф = верхний уровень) @@ -1622,6 +1639,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (ro) ro.disconnect(); edgesSvg.remove(); world.remove(); + a11y.remove(); }, }; } diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 9b53dd3..c7cc6d6 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -93,9 +93,21 @@ function addDeepLevels(model) { const extra = []; tier1.forEach((p) => { const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5 + // «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь). + const others = tier1.filter((o) => String(o.id) !== String(p.id)); + const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null; for (let i = 0; i < k2; i += 1) { const id2 = `${p.id}__d2_${i}`; const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6; + // i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★) + if (i === 0 && common) { + extra.push({ + id: id2, login: id2, name: common.name || common.login, + avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend', + strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true, + }); + continue; + } // узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`; extra.push({ diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js index 054520b..388eb49 100644 --- a/shine-UI/js/pages/network/selftest.js +++ b/shine-UI/js/pages/network/selftest.js @@ -102,6 +102,19 @@ export async function runNetworkSelfTest(graph, deepChipEl) { check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`); } + // === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) === + if (typeof document !== 'undefined') { + const commonCount = document.querySelectorAll('.fg-node.is-common').length; + check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`); + } + + // === Тест J: доступность — текстовый список графа для скринридеров === + if (typeof document !== 'undefined') { + const a11y = document.querySelector('.fg-a11y'); + const liCount = a11y ? a11y.querySelectorAll('li').length : 0; + check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`); + } + // === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает === graph.exitDive(); graph.pumpForTest(); diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index afd3869..0912e2a 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -639,3 +639,35 @@ cursor: default; } .fg-crumb-sep { color: #5f7196; font-size: 11px; align-self: center; pointer-events: none; } + +/* Доступность: визуально скрытый список графа для скринридеров (sr-only, читается ассистивными технологиями) */ +.fg-a11y { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */ +.fg-node.is-common .node-dot { + border-color: rgba(255, 214, 120, 0.95); + box-shadow: 0 0 14px rgba(255, 200, 90, 0.4); +} +.fg-node.is-common .node-dot::after { + content: '★'; + position: absolute; + bottom: -5px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + line-height: 1; + color: #ffd678; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); + pointer-events: none; +}