diff --git a/VERSION.properties b/VERSION.properties index 58bff52..08a46c7 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.150 -server.version=1.2.142 +client.version=1.2.151 +server.version=1.2.143 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 972a472..6acb118 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -45,10 +45,12 @@ - **Обычные линии:** SVG ` Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.0–1.2`), градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются в фоне, не спорят с сияющими). Изгиб реагирует на скорость. -- **Сияющие связи (двухслойный «световод», Neon Layering):** два пути на одну связь — широкий размытый - GLOW (`stroke-width 4`, неон, `filter: blur(2px)`, `opacity 0.4`) + тонкий чёткий CORE (`1.5px`, - `#e0f7fc`). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий - dashoffset). Никаких бегущих импульсов. +- **Сияющие связи (плазменный композитинг):** один центральный S-путь (cubic Bézier) и три наложенных + слоя с одинаковым `d`: `.fg-plasma-flare` (16px, `#00bfff`, blur6), `.fg-plasma-tube` (6px, `#00e5ff`, + blur2) и `.fg-plasma-core` (2px, `#dffaff`). Поле и трубка идут в `mix-blend-mode: screen`, поэтому + свечение складывается аддитивно с тёмным фоном и ярче проявляется в пересечениях у центра. +- **Обычные связи:** теперь это мягкое цветное свечение по типу связи (семья/друзья/бизнес/контакт) — + широкая полупрозрачная подложка плюс тонкое ядро, без SVG-blur. - **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 7d3dc05..cbd9bda 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -71,8 +71,11 @@ function ensureShineFilter() { svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.position = 'absolute'; - svg.innerHTML = ''; + svg.innerHTML = '' + + '' + + '' + + '' + + ''; document.body.appendChild(svg); } @@ -388,9 +391,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired const cpy = 2 * desY - my; // Связь рисуем по статусу узла: - // • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность; - // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий - // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). + // • обычная — мягкое цветное свечение по типу связи; + // • сияющая — плазма из трёх слоёв на одном S-пути (flare/tube/core). const shine = Boolean(n.shining) && !n.hidden; const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла @@ -404,24 +406,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; } if (shine) { - // glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr) - const gOp = (0.4 * nodeOpacity).toFixed(2); - const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; - parts.push(``); - parts.push(``); + const pnx = -uy; + const pny = ux; + const amp = Math.min(13, 5 + segLen0 * 0.05); + const bowX = desX - mx; + const bowY = desY - my; + const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; + const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; + const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; + const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; + const dS = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + parts.push(``); + parts.push(``); + parts.push(``); } else { - // обычная: тонкая дуга, градиент 0.42 (центр) → 0.07 (аватарка) — глубокий уход в прозрачность, - // чтобы матовые связи не спорили с сияющими. - const gid = `fg-grad-${gi}`; - gi += 1; - defs.push( - `` - + `` - + `` - ); - const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.0–1.2px - const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; - parts.push(``); + const col = relationColor(n.relationType); + const haloOp = (0.22 * nodeOpacity).toFixed(2); + const coreOp = (0.7 * nodeOpacity).toFixed(2); + const sw = (2.6 + n.strength * 1.4).toFixed(2); + parts.push(``); + parts.push(``); } } edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`; diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js new file mode 100644 index 0000000..4c0dd22 --- /dev/null +++ b/shine-UI/js/pages/network/selftest.js @@ -0,0 +1,150 @@ +// Автопроверки интерактивного графа связей (dev-only). +// +// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные +// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда +// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя). +// +// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }. + +const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас) + +export async function runNetworkSelfTest(graph, deepChipEl) { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const results = []; + const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); }; + const st = () => graph.debugState(); + + // 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout). + if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click(); + await wait(1700); + + let s = st(); + const focusId = s.focusId; + const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId); + const parent = tier1.find((n) => n.id === 'nina') || tier1[0]; + if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); } + + // === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) === + graph.diveTo({ id: parent.id }); + const framesA = graph.pumpForTest(); + s = st(); + const p = s.nodes.find((n) => n.id === parent.id); + const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_')); + + check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`); + check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`); + check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36, + `offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`); + check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`); + + // расталкивание: дети не слипаются + let minD = Infinity; + for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) { + minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y)); + } + check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`); + + // полукруг наружу: все дети в секторе вокруг направления от центра к родителю + const outward = Math.atan2(p.y, p.x); + const maxDev = kids.reduce((mx, k) => { + let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward); + if (d > Math.PI) d = 2 * Math.PI - d; + return Math.max(mx, d * 180 / Math.PI); + }, 0); + check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`); + + // === Тест B: Spotlight открыт — путь горит, остальное тускнеет === + const offPath = tier1.filter((n) => n.id !== parent.id); + const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; }); + const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9); + check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9'); + check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4'); + + // === Тест C: переключение веток сбрасывает прежнюю (нет накопления) === + if (offPath.length) { + graph.diveTo({ id: offPath[0].id }); + graph.pumpForTest(); + s = st(); + const prev = s.nodes.find((n) => n.id === parent.id); + check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`); + check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`); + } + + // === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме === + const t2withKids = st().nodes.find((n) => n.tier === 2); + if (t2withKids) { + graph.diveTo({ id: t2withKids.id }); + graph.pumpForTest(); + s = st(); + const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_')); + const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true; + check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`); + } + + // === Тест F: поиск по имени находит узел (для строки поиска + телепорта) === + const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId); + if (named && typeof graph.findNode === 'function') { + const byId = graph.findNode(named.id); + check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`); + } + + // === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) === + if (typeof graph.getDivePath === 'function' && t2withKids) { + const path = graph.getDivePath(); + const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id; + check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`); + } + + // === Тест H: бейдж числа связей виден и числовой (DOM) === + if (typeof document !== 'undefined') { + const fb = document.querySelector('.fg-node.is-focus .fg-node-badge'); + const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0; + 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(); + s = st(); + const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95); + check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95'); + check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`); + check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`); + + // === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) === + if (typeof document !== 'undefined') { + const flare = document.querySelectorAll('.fg-plasma-flare'); + const tube = document.querySelectorAll('.fg-plasma-tube'); + const core = document.querySelectorAll('.fg-plasma-core'); + const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length; + const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d') + && tube[0].getAttribute('d') === core[0].getAttribute('d'); + check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`); + } + + return finish(results); +} + +function finish(results) { + const pass = results.filter((r) => r.pass).length; + const out = { pass, total: results.length, results }; + if (typeof window !== 'undefined') window.__fgTestResults = out; + const tag = pass === results.length ? '✅ PASS' : '❌ FAIL'; + // eslint-disable-next-line no-console + console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`); + results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`)); + return out; +} diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index eaa552f..e9133c4 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -60,20 +60,38 @@ transition: opacity 420ms ease; /* плавное появление линий при перестройке */ } -/* Сияющая связь = двухслойный неоновый «световод» (Neon Layering): изящно, но объёмно (как OLED). - GLOW — широкий размытый ореол неонового оттенка под линией; CORE — тонкий чёткий светлый контур. */ -.fg-edge-glow { +/* Сияющая связь = плазменный композитинг (3 слоя на одном S-пути, см. renderEdges). */ +.fg-plasma-flare { fill: none; - stroke: rgba(110, 225, 255, 1); - stroke-width: 4; + stroke: #00bfff; + stroke-width: 16; stroke-linecap: round; - filter: blur(2px); /* мягкое объёмное свечение вокруг нити */ + filter: url(#fg-plasma-blur6); + mix-blend-mode: screen; + animation: fg-plasma-breath 3.6s ease-in-out infinite; } -.fg-edge-core { +.fg-plasma-tube { fill: none; - stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */ - stroke-width: 1.5; + stroke: #00e5ff; + stroke-width: 6; stroke-linecap: round; + filter: url(#fg-plasma-blur2); + mix-blend-mode: screen; +} +.fg-plasma-core { + fill: none; + stroke: #dffaff; + stroke-width: 2; + stroke-linecap: round; +} + +@keyframes fg-plasma-breath { + 0%, 100% { stroke-width: 14; } + 50% { stroke-width: 19; } +} + +@media (prefers-reduced-motion: reduce) { + .fg-plasma-flare { animation: none; } } .fg-node {