diff --git a/VERSION.properties b/VERSION.properties
index be04c84..5b9a6ee 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.144
-server.version=1.2.128
+client.version=1.2.145
+server.version=1.2.129
diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md
index 419ca65..5ef348d 100644
--- a/shine-UI/Dev_Docs/features/interactive-network-graph.md
+++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md
@@ -61,7 +61,7 @@
(`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными.
- **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`,
`backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым.
-- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация
+- **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
@@ -94,9 +94,14 @@
втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover` →
`graph.setHover(node|null)`; узел получает флаг `hovered`.
- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка
- остаётся раскрытой и после ухода курсора. Повторный тап снимает фиксацию.
+ остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его
+ (надёжный toggle: `isOpen = pinned || expandP>0.5` → сброс `pinned`+`hovered`).
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
-- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned` и `hovered` со всех.
+- **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25`
+ (узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp).
+- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62`
+ (≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей.
+- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость.
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js
index eca2f91..a66f74d 100644
--- a/shine-UI/js/pages/network/force-graph.js
+++ b/shine-UI/js/pages/network/force-graph.js
@@ -44,8 +44,8 @@ const MAX_FULL_NODES = 90; // хард-лимит полных DOM-ават
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
-const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше
-const DEEP2_OPACITY = 0.4; // и полупрозрачный
+const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем
+const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка»
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
@@ -58,6 +58,7 @@ const ZOOM_WHEEL = 0.0016; // чувствительность колеса
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
+const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
@@ -131,11 +132,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.setAttribute('class', 'fg-edges');
const world = document.createElement('div');
world.className = 'fg-world';
- // «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется,
- // когда под него попадает узел (визуальная зона фокуса при свободном панорамировании).
- const reticle = document.createElement('div');
- reticle.className = 'fg-reticle';
- stage.append(edgesSvg, world, reticle);
+ stage.append(edgesSvg, world);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование + зум)
@@ -154,8 +151,24 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let focusId = '';
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
+ let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют)
const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); };
+ // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
+ // взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка
+ // 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности.
+ function rootTier1(n) {
+ let r = n; let guard = 0;
+ while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null;
+ return r;
+ }
+ function spotTargetOf(n) {
+ if (!spotActive || n.isFocus) return 1;
+ const r = rootTier1(n);
+ if (!r) return SPOTLIGHT_DIM;
+ return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM;
+ }
+
// Управление циклом rAF
let rafId = 0;
let dragging = false;
@@ -264,6 +277,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
hidden: false,
opacity: tier >= 2 ? op : 1,
targetOpacity: op,
+ spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет)
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
@@ -347,7 +361,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.parentId = String(src.parentId || '');
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
node.track = Boolean(src.track);
- node.pinned = false; node.hovered = false; node.expandP = 0; // при перестроении глубина схлопывается
+ node.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается
node.dotOnly = spec.dotOnly;
node.strength = strength;
node.relationType = src.relationType;
@@ -406,7 +420,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
applyWorldTransform();
renderEdges();
- updateReticle();
}
function onWheel(ev) {
ev.preventDefault();
@@ -419,7 +432,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) {
n.el.style.transform =
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`;
- n.el.style.opacity = String(n.opacity);
+ n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
}
}
@@ -474,7 +487,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) {
if (n.tier < 2) continue;
n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`;
- n.el.style.opacity = String(n.opacity);
+ n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
}
}
@@ -554,6 +567,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
const shine = Boolean(n.shining) && !n.hidden;
+ const sp = (n.spotCur ?? 1); // spotlight: линия тускнеет вместе со своим узлом (затемнённые ветки)
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 по мере разлёта узла
// (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
@@ -568,15 +582,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) {
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
- if (pe > 0.02) parts.push(``);
+ if (pe > 0.02) parts.push(``);
} else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
- if (pe > 0.02) parts.push(``);
+ if (pe > 0.02) parts.push(``);
} else if (shine || n.track) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
- const gOp = (0.4 * nodeOpacity).toFixed(2);
- const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
+ const cOpVal = nodeOpacity * sp;
+ const gOp = (0.4 * cOpVal).toFixed(2);
+ const cOpAttr = cOpVal < 0.995 ? ` opacity="${cOpVal.toFixed(2)}"` : '';
parts.push(``);
parts.push(``);
} else {
@@ -590,28 +605,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
+ ``
);
const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.0–1.2px
- const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
+ const opVal = nodeOpacity * sp;
+ const op = opVal < 0.995 ? ` opacity="${opVal.toFixed(2)}"` : '';
parts.push(``);
}
}
edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`;
}
- function updateReticle() {
- // ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0)
- let best = Infinity;
- for (const n of nodes) {
- if (n.hidden) continue;
- const d = Math.hypot(camX + n.x * zoom, camY + n.y * zoom);
- if (d < best) best = d;
- }
- reticle.classList.toggle('is-locked', best < 46);
- }
-
function renderAll() {
renderNodes();
renderEdges();
- updateReticle();
}
// --- Физика (пружины + отталкивание) ---------------------------------------
@@ -665,11 +669,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return totalV;
}
- // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»).
+ // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание») + spotlight.
function advanceVisual() {
+ spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют
for (const n of nodes) {
n.scale += (n.targetScale - n.scale) * 0.2;
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
+ n.spotCur += (spotTargetOf(n) - n.spotCur) * 0.2; // плавное затемнение/прояснение веток
// линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
}
@@ -678,7 +684,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки).
function visualSettling() {
for (const n of nodes) {
- if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true;
+ if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01
+ || Math.abs(n.spotCur - spotTargetOf(n)) > 0.01 || n.edgeGrow < 1) return true;
}
return false;
}
@@ -859,7 +866,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
layoutDeep();
renderDeepNodes();
renderEdges();
- updateReticle();
schedule();
return;
}
@@ -956,8 +962,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function toggleExpand(node) {
const n = node && (nodeById.get(String(node.id)) || node);
if (!n) return;
- n.pinned = !n.pinned;
- if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры
+ // надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером).
+ const isOpen = n.pinned || (n.expandP || 0) > 0.5;
+ if (isOpen) {
+ n.pinned = false; n.hovered = false; // свернуть полностью
+ haptic(8);
+ } else {
+ n.pinned = true; haptic([10, 25, 6, 35, 3]); glideCameraTo(n); // раскрыть + дотяжка камеры
+ }
wake();
}
// Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
@@ -1078,7 +1090,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
applyWorldTransform();
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
- updateReticle();
}
}
@@ -1152,14 +1163,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс
}
- // Импульс центрального кольца — подтверждение «захвата» нового фокуса.
- function pulseReticle() {
- reticle.classList.remove('is-pulse');
- void reticle.offsetWidth;
- reticle.classList.add('is-pulse');
- window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
- }
-
// Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM,
// чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками.
function syncPositionsFromDOM() {
@@ -1296,7 +1299,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами
cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost)
renderEdges();
- pulseReticle();
cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано
wake();
}
@@ -1341,7 +1343,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (ro) ro.disconnect();
edgesSvg.remove();
world.remove();
- reticle.remove();
},
};
}
diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js
index 2f7b0ba..57f18d8 100644
--- a/shine-UI/js/pages/network/lab.js
+++ b/shine-UI/js/pages/network/lab.js
@@ -93,9 +93,11 @@ function addDeepLevels(model) {
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;
+ // узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
+ const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
- avatar: null, photo: null, relationType: p.relationType || 'contact',
+ avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css
index 82fff1c..5ce08d6 100644
--- a/shine-UI/styles/network-graph.css
+++ b/shine-UI/styles/network-graph.css
@@ -292,15 +292,15 @@
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */
-/* 2-й уровень — «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени
- и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */
+/* 2-й уровень — «друзья друзей»: полноценная аватарка (лицо + имя), просто мельче основных.
+ Масштаб/прозрачность задаёт движок; здесь — читаемый ободок и подпись (не «дырка»). */
.fg-node.is-tier2 .node-dot {
- border-color: rgba(150, 180, 220, 0.4);
- box-shadow: none;
+ border-color: rgba(170, 200, 240, 0.65);
+ box-shadow: 0 2px 8px rgba(4, 8, 15, 0.4);
}
.fg-node.is-tier2 .fg-node-label {
font-size: 9px;
- opacity: 0.55;
+ opacity: 0.9;
top: calc(100% + 1px);
}
@@ -337,30 +337,6 @@
color: #efeaff;
}
-/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
-.fg-reticle {
- position: absolute;
- left: 50%;
- top: 50%;
- width: 64px;
- height: 64px;
- margin: -32px 0 0 -32px;
- border-radius: 50%;
- border: 2px dashed rgba(150, 190, 255, 0.3);
- pointer-events: none;
- z-index: 0;
- opacity: 0.45;
- transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease;
-}
-
-.fg-reticle.is-locked {
- width: 94px;
- height: 94px;
- margin: -47px 0 0 -47px;
- border-color: rgba(130, 235, 255, 0.65);
- opacity: 0.85;
-}
-
/* «Призрак» старой карты при Z-переходе (эффект погружения) */
.fg-ghost-layer {
position: absolute;
@@ -373,17 +349,6 @@
transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease;
}
-/* Импульс центрального кольца при захвате нового фокуса */
-.fg-reticle.is-pulse {
- animation: fg-reticle-pulse 0.6s ease;
-}
-
-@keyframes fg-reticle-pulse {
- 0% { transform: scale(1); }
- 40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); }
- 100% { transform: scale(1); }
-}
-
/* Панель фильтров слоёв (оверлей под шапкой) */
.fg-filter-bar {
position: absolute;