Связи (эксперимент pixel-web): база — микро-взаимодействия + глубина
Фиксирую накопленные черновики как точку отката перед режимом «Интерактивная паутина»: - press/pan-bend (резиновые нити при свайпе, тактильное «вдавливание» узла); - глубина 2-3 уровней (прототип «Вселенная», переключатель в лаборатории); - прогрессивное раскрытие (глубина скрыта, выплывает по нажатию/наведению). Ветка pixel-web — экспериментальная (отдельно от pixel-08.06/PR), чтобы можно было откатиться. Бамп client.version → 1.2.142. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3de992d251
commit
345a21a211
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.141
|
client.version=1.2.142
|
||||||
server.version=1.2.127
|
server.version=1.2.127
|
||||||
|
|||||||
@ -42,6 +42,14 @@ const PAN_THRESHOLD = 8; // порог смещения (px), после
|
|||||||
const LONGPRESS_MS = 480; // порог долгого нажатия
|
const LONGPRESS_MS = 480; // порог долгого нажатия
|
||||||
const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки
|
const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки
|
||||||
|
|
||||||
|
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
|
||||||
|
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
|
||||||
|
const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше
|
||||||
|
const DEEP2_OPACITY = 0.4; // и полупрозрачный
|
||||||
|
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
|
||||||
|
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
|
||||||
|
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
|
||||||
|
|
||||||
const RELATION_COLORS = {
|
const RELATION_COLORS = {
|
||||||
family: 'rgba(255, 159, 94, 0.92)',
|
family: 'rgba(255, 159, 94, 0.92)',
|
||||||
friend: 'rgba(120, 179, 255, 0.9)',
|
friend: 'rgba(120, 179, 255, 0.9)',
|
||||||
@ -129,6 +137,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius }
|
// Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius }
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let focusId = '';
|
let focusId = '';
|
||||||
|
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
|
||||||
|
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
|
||||||
|
const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); };
|
||||||
|
|
||||||
// Управление циклом rAF
|
// Управление циклом rAF
|
||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
@ -155,23 +166,37 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let panVelX = 0;
|
let panVelX = 0;
|
||||||
let panVelY = 0;
|
let panVelY = 0;
|
||||||
|
|
||||||
|
// Упругий «изгиб от свайпа»: сглаженный вектор, который догоняет скорость пальца и плавно
|
||||||
|
// возвращается к нулю при отпускании (lerp). Им смещаем контрольные точки Безье — нити тянутся.
|
||||||
|
let panBendX = 0;
|
||||||
|
let panBendY = 0;
|
||||||
|
function advancePanBend() {
|
||||||
|
panBendX += (panVelX - panBendX) * 0.3;
|
||||||
|
panBendY += (panVelY - panBendY) * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Построение модели -----------------------------------------------------
|
// --- Построение модели -----------------------------------------------------
|
||||||
// Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга:
|
// Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга:
|
||||||
// фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки.
|
// фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки.
|
||||||
function computeSpecs(srcModel) {
|
function computeSpecs(srcModel) {
|
||||||
const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : [];
|
const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : [];
|
||||||
const fId = String(srcModel?.focusId || (list[0] && list[0].id) || '');
|
const fId = String(srcModel?.focusId || (list[0] && list[0].id) || '');
|
||||||
const peers = list
|
// 1-й уровень держит орбиту вокруг центра (сортируем по силе, как раньше); узлы 2/3 уровня
|
||||||
.filter((n) => String(n.id) !== fId)
|
// («друзья друзей» и микрозвёзды) орбиту не используют — их раскладывает layoutDeep вокруг родителя.
|
||||||
|
const tier1 = list
|
||||||
|
.filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) === 1)
|
||||||
.sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0));
|
.sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0));
|
||||||
const dotCount = Math.max(0, peers.length - MAX_FULL_NODES);
|
const deep = list.filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) >= 2);
|
||||||
|
const dotCount = Math.max(0, tier1.length - MAX_FULL_NODES);
|
||||||
if (dotCount > 0) {
|
if (dotCount > 0) {
|
||||||
console.info(`[force-graph] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`);
|
console.info(`[force-graph] связей ${tier1.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`);
|
||||||
}
|
}
|
||||||
const specs = [];
|
const specs = [];
|
||||||
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
||||||
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
|
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
|
||||||
peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES }));
|
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
|
||||||
|
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
|
||||||
|
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
|
||||||
return { focusId: fId, specs };
|
return { focusId: fId, specs };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,7 +214,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
||||||
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
||||||
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
|
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
|
||||||
const scale = isFocus ? FOCUS_SCALE : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE);
|
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
||||||
|
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||||
|
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||||
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
|
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
|
||||||
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
|
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
|
||||||
const tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
const tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
||||||
@ -200,6 +227,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
...src,
|
...src,
|
||||||
isFocus,
|
isFocus,
|
||||||
tier,
|
tier,
|
||||||
|
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус)
|
||||||
|
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
||||||
|
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||||
|
expandTarget: 0, // 0/1 — раскрыты ли дочерние глубокие узлы (по клику/ховеру)
|
||||||
|
expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс
|
||||||
dotOnly,
|
dotOnly,
|
||||||
strength,
|
strength,
|
||||||
targetR,
|
targetR,
|
||||||
@ -213,12 +245,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
scale,
|
scale,
|
||||||
targetScale: scale,
|
targetScale: scale,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
opacity: 1,
|
opacity: tier >= 2 ? op : 1,
|
||||||
targetOpacity: 1,
|
targetOpacity: op,
|
||||||
bloom: false,
|
bloom: false,
|
||||||
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
|
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
|
||||||
el,
|
el,
|
||||||
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
|
dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,6 +281,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (dotOnly) {
|
if (dotOnly) {
|
||||||
el.className = [
|
el.className = [
|
||||||
'fg-node', 'fg-dot',
|
'fg-node', 'fg-dot',
|
||||||
|
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся точка)
|
||||||
src.shining ? 'is-shine' : '',
|
src.shining ? 'is-shine' : '',
|
||||||
`is-${src.relationType || 'contact'}`,
|
`is-${src.relationType || 'contact'}`,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
@ -261,6 +294,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
isFocus ? 'is-focus' : '',
|
isFocus ? 'is-focus' : '',
|
||||||
src.shining ? 'is-shine' : '',
|
src.shining ? 'is-shine' : '',
|
||||||
`is-${src.relationType || 'contact'}`,
|
`is-${src.relationType || 'contact'}`,
|
||||||
|
tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный)
|
||||||
tier >= 2 ? 'is-secondary' : '',
|
tier >= 2 ? 'is-secondary' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
el.dataset.nodeId = String(src.id);
|
el.dataset.nodeId = String(src.id);
|
||||||
@ -291,6 +325,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const tier = Number(src.tier) || 1;
|
const tier = Number(src.tier) || 1;
|
||||||
node.isFocus = spec.isFocus;
|
node.isFocus = spec.isFocus;
|
||||||
node.tier = tier;
|
node.tier = tier;
|
||||||
|
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.expandTarget = 0; node.expandP = 0; // при перестроении глубина схлопывается (раскрытие — по клику)
|
||||||
node.dotOnly = spec.dotOnly;
|
node.dotOnly = spec.dotOnly;
|
||||||
node.strength = strength;
|
node.strength = strength;
|
||||||
node.relationType = src.relationType;
|
node.relationType = src.relationType;
|
||||||
@ -301,15 +339,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
|
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
|
||||||
node.tx = Math.cos(node.angle) * node.targetR;
|
node.tx = Math.cos(node.angle) * node.targetR;
|
||||||
node.ty = Math.sin(node.angle) * node.targetR;
|
node.ty = Math.sin(node.angle) * node.targetR;
|
||||||
node.targetScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||||
node.targetOpacity = 1;
|
node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||||
node.hidden = false;
|
node.hidden = false;
|
||||||
node.bloom = false;
|
node.bloom = false;
|
||||||
node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26));
|
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
|
||||||
// обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM
|
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||||
node.el.className = spec.dotOnly
|
node.el.className = spec.dotOnly
|
||||||
? ['fg-node', 'fg-dot', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
? ['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-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' : ''].filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Рендер ----------------------------------------------------------------
|
// --- Рендер ----------------------------------------------------------------
|
||||||
@ -326,6 +364,57 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию
|
||||||
|
// СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия
|
||||||
|
// (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь.
|
||||||
|
function layoutDeep() {
|
||||||
|
if (!hasDeep) return;
|
||||||
|
for (const tier of [2, 3]) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.tier !== tier) continue;
|
||||||
|
const p = nodeById.get(n.parentId);
|
||||||
|
if (!p) { n.opacity = 0; continue; }
|
||||||
|
const e = p.expandP || 0; // насколько раскрыт родитель
|
||||||
|
const r = (tier === 2 ? DEEP_R2 : DEEP_R3) * e; // при e=0 — в центре родителя, при 1 — на орбите
|
||||||
|
n.x = p.x + Math.cos(n.deepAngle) * r;
|
||||||
|
n.y = p.y + Math.sin(n.deepAngle) * r;
|
||||||
|
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
|
||||||
|
const baseSc = tier === 2 ? DEEP2_SCALE : 1;
|
||||||
|
// вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий)
|
||||||
|
const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1;
|
||||||
|
n.opacity = baseOp * e * parentVis;
|
||||||
|
n.scale = baseSc * (0.3 + 0.7 * e); // лёгкий «поп» при выплывании
|
||||||
|
n.targetOpacity = n.opacity; n.targetScale = n.scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет.
|
||||||
|
function advanceExpand() {
|
||||||
|
let moving = false;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const t = n.expandTarget || 0;
|
||||||
|
const cur = n.expandP || 0;
|
||||||
|
if (Math.abs(cur - t) > 0.001) {
|
||||||
|
let next = cur + (t - cur) * 0.18;
|
||||||
|
if (Math.abs(next - t) < 0.012) next = t; else moving = true;
|
||||||
|
n.expandP = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик)
|
||||||
|
// отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями.
|
||||||
|
function renderDeepNodes() {
|
||||||
|
if (!hasDeep) return;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderEdges() {
|
function renderEdges() {
|
||||||
const focus = nodes.find((n) => n.id === focusId);
|
const focus = nodes.find((n) => n.id === focusId);
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
@ -336,10 +425,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const tx = (n) => centerX + camX + n.x;
|
const tx = (n) => centerX + camX + n.x;
|
||||||
const ty = (n) => centerY + camY + n.y;
|
const ty = (n) => centerY + camY + n.y;
|
||||||
|
|
||||||
const fx = tx(focus);
|
|
||||||
const fy = ty(focus);
|
|
||||||
const fr = focus.dotRadius * focus.scale + 4;
|
|
||||||
|
|
||||||
const focusLogin = String(focus.login || '').toLowerCase();
|
const focusLogin = String(focus.login || '').toLowerCase();
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const defs = [];
|
const defs = [];
|
||||||
@ -350,6 +435,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1;
|
const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1;
|
||||||
if (n.hidden && nodeOpacity <= 0.02) continue;
|
if (n.hidden && nodeOpacity <= 0.02) continue;
|
||||||
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
||||||
|
// начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель
|
||||||
|
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
|
||||||
|
const fx = centerX + camX + parent.x;
|
||||||
|
const fy = centerY + camY + parent.y;
|
||||||
|
const fr = parent.dotRadius * parent.scale + 4;
|
||||||
const nx = tx(n);
|
const nx = tx(n);
|
||||||
const ny = ty(n);
|
const ny = ty(n);
|
||||||
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||||
@ -382,9 +472,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости
|
const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости
|
||||||
const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения
|
const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения
|
||||||
const invY = speed > 0.01 ? -n.vy / speed : 0;
|
const invY = speed > 0.01 ? -n.vy / speed : 0;
|
||||||
// желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости
|
// ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца,
|
||||||
const desX = mx + (-uy) * baseBow + invX * lag;
|
// сильнее у дальних узлов (инерция периферии). panBend сглажен и сам затухает → нить пружинит назад.
|
||||||
const desY = my + ux * baseBow + invY * lag;
|
const farK = Math.min(1.3, Math.max(0.35, segLen0 / 200));
|
||||||
|
let panBx = -panBendX * 0.55 * farK;
|
||||||
|
let panBy = -panBendY * 0.55 * farK;
|
||||||
|
const panBmag = Math.hypot(panBx, panBy);
|
||||||
|
if (panBmag > 40) { panBx = (panBx / panBmag) * 40; panBy = (panBy / panBmag) * 40; }
|
||||||
|
// желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости узла + пан-стретч
|
||||||
|
const desX = mx + (-uy) * baseBow + invX * lag + panBx;
|
||||||
|
const desY = my + ux * baseBow + invY * lag + panBy;
|
||||||
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
|
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
|
||||||
const cpy = 2 * desY - my;
|
const cpy = 2 * desY - my;
|
||||||
// Связь рисуем по статусу узла:
|
// Связь рисуем по статусу узла:
|
||||||
@ -403,8 +500,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
|
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
|
||||||
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
||||||
}
|
}
|
||||||
if (shine) {
|
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
|
||||||
// glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr)
|
if (n.tier >= 3) {
|
||||||
|
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
|
||||||
|
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe).toFixed(2)}" />`);
|
||||||
|
} else if (n.tier === 2) {
|
||||||
|
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
|
||||||
|
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe).toFixed(2)}" />`);
|
||||||
|
} else if (shine || n.track) {
|
||||||
|
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
|
||||||
|
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
|
||||||
const gOp = (0.4 * nodeOpacity).toFixed(2);
|
const gOp = (0.4 * nodeOpacity).toFixed(2);
|
||||||
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
|
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
|
||||||
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
|
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
|
||||||
@ -452,6 +557,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let totalV = 0;
|
let totalV = 0;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает
|
if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает
|
||||||
|
if (n.tier >= 2) { n.vx = 0; n.vy = 0; continue; } // глубокие уровни — раскладывает layoutDeep
|
||||||
let ax = 0;
|
let ax = 0;
|
||||||
let ay = 0;
|
let ay = 0;
|
||||||
|
|
||||||
@ -467,9 +573,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const fr = K_RADIAL * (n.targetR - d);
|
const fr = K_RADIAL * (n.targetR - d);
|
||||||
ax += fr * ux;
|
ax += fr * ux;
|
||||||
ay += fr * uy;
|
ay += fr * uy;
|
||||||
// отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре)
|
// отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре);
|
||||||
|
// глубокие уровни (tier≥2) в отталкивании не участвуют (их много — берегём перф)
|
||||||
for (const m of nodes) {
|
for (const m of nodes) {
|
||||||
if (m === n || m.hidden) continue;
|
if (m === n || m.hidden || m.tier >= 2) continue;
|
||||||
const dx = n.x - m.x;
|
const dx = n.x - m.x;
|
||||||
const dy = n.y - m.y;
|
const dy = n.y - m.y;
|
||||||
let dist2 = dx * dx + dy * dy;
|
let dist2 = dx * dx + dy * dy;
|
||||||
@ -667,6 +774,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
n.scale = n.targetScale;
|
n.scale = n.targetScale;
|
||||||
n.opacity = n.targetOpacity;
|
n.opacity = n.targetOpacity;
|
||||||
}
|
}
|
||||||
|
panBendX = 0; panBendY = 0; // нити в покое — ровные базовые дуги
|
||||||
|
layoutDeep(); // глубокие уровни — на орбитах родителей перед финальным кадром
|
||||||
renderAll(); // финальный кадр на целых координатах
|
renderAll(); // финальный кадр на целых координатах
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,9 +783,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function tick(ts) {
|
function tick(ts) {
|
||||||
rafId = 0;
|
rafId = 0;
|
||||||
|
|
||||||
// режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики)
|
// режим CSS-bloom: узлы 1-го уровня анимирует компоновщик — мы лишь ведём лучи за ними (без физики);
|
||||||
|
// глубокие уровни (tier≥2) не в CSS-bloom — позиционируем их из JS, чтобы следовали за родителями
|
||||||
if (cssBloom) {
|
if (cssBloom) {
|
||||||
syncPositionsFromDOM();
|
syncPositionsFromDOM();
|
||||||
|
advanceExpand();
|
||||||
|
layoutDeep();
|
||||||
|
renderDeepNodes();
|
||||||
renderEdges();
|
renderEdges();
|
||||||
updateReticle();
|
updateReticle();
|
||||||
schedule();
|
schedule();
|
||||||
@ -691,10 +804,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
panVelX *= PAN_FRICTION;
|
panVelX *= PAN_FRICTION;
|
||||||
panVelY *= PAN_FRICTION;
|
panVelY *= PAN_FRICTION;
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
|
} else if (dragging) {
|
||||||
|
// палец удерживается без движения → скорость (и изгиб нитей) мягко расслабляется,
|
||||||
|
// но onPointerMove перезапишет её свежим дельтой при следующем движении
|
||||||
|
panVelX *= 0.7;
|
||||||
|
panVelY *= 0.7;
|
||||||
} else {
|
} else {
|
||||||
panVelX = 0;
|
panVelX = 0;
|
||||||
panVelY = 0;
|
panVelY = 0;
|
||||||
}
|
}
|
||||||
|
advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю
|
||||||
|
|
||||||
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
||||||
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
||||||
@ -710,9 +829,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity
|
advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expanding = advanceExpand(); // раскрытие/схлопывание глубоких уровней (по клику/ховеру)
|
||||||
|
layoutDeep(); // глубокие уровни следуют за родителями (после шага физики 1-го уровня)
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
||||||
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || visualSettling()) {
|
const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
|
||||||
|
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
|
||||||
schedule();
|
schedule();
|
||||||
} else {
|
} else {
|
||||||
freezeGraph(); // система успокоилась — замираем
|
freezeGraph(); // система успокоилась — замираем
|
||||||
@ -737,6 +859,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let downNodeEl = null;
|
let downNodeEl = null;
|
||||||
let longTimer = 0;
|
let longTimer = 0;
|
||||||
let longFired = false;
|
let longFired = false;
|
||||||
|
let pressedNode = null; // узел, чьи глубокие дети сейчас «выплыли» (по нажатию)
|
||||||
|
let hoverNode = null; // узел под курсором (десктоп) — тоже раскрывает свои глубокие связи
|
||||||
|
|
||||||
|
// раскрыть/схлопнуть глубокие уровни вокруг узла (локальный bloom по взаимодействию)
|
||||||
|
function expandNode(n) { if (n && !n.expandTarget) { n.expandTarget = 1; wake(); } }
|
||||||
|
function collapseNode(n) { if (n && n.expandTarget) { n.expandTarget = 0; wake(); } }
|
||||||
|
|
||||||
|
// Hover (десктоп): наведение раскрывает глубокие связи узла, увод/смена — схлопывает.
|
||||||
|
function onHoverMove(ev) {
|
||||||
|
if (pointerId !== null || dragging) return; // только когда не нажато и не тащим
|
||||||
|
const n = nodeFromEvent(ev);
|
||||||
|
if (n === hoverNode) return;
|
||||||
|
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
|
||||||
|
hoverNode = n;
|
||||||
|
if (n) expandNode(n);
|
||||||
|
}
|
||||||
|
function onHoverLeave() {
|
||||||
|
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
|
||||||
|
hoverNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
function nodeFromEvent(ev) {
|
function nodeFromEvent(ev) {
|
||||||
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||||
@ -758,7 +900,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
moved = false;
|
moved = false;
|
||||||
longFired = false;
|
longFired = false;
|
||||||
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||||
|
if (downNodeEl) downNodeEl.classList.add('is-pressed'); // тактильный отклик «нажатия вглубь»
|
||||||
const downNode = nodeFromEvent(ev);
|
const downNode = nodeFromEvent(ev);
|
||||||
|
if (downNode) { pressedNode = downNode; expandNode(downNode); } // локальный bloom его глубоких связей
|
||||||
if (downNode && typeof onNodeLongPress === 'function') {
|
if (downNode && typeof onNodeLongPress === 'function') {
|
||||||
longTimer = window.setTimeout(() => {
|
longTimer = window.setTimeout(() => {
|
||||||
if (moved) return;
|
if (moved) return;
|
||||||
@ -777,6 +921,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||||
moved = true;
|
moved = true;
|
||||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
|
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
|
||||||
|
if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // свайп — схлопываем глубину
|
||||||
cancelTween(); // жест прерывает анимацию центрирования
|
cancelTween(); // жест прерывает анимацию центрирования
|
||||||
dragging = true;
|
dragging = true;
|
||||||
}
|
}
|
||||||
@ -788,6 +934,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
camX = newCamX;
|
camX = newCamX;
|
||||||
camY = newCamY;
|
camY = newCamY;
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
|
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
|
||||||
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
||||||
updateReticle();
|
updateReticle();
|
||||||
}
|
}
|
||||||
@ -796,6 +943,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function onPointerUp(ev) {
|
function onPointerUp(ev) {
|
||||||
if (ev.pointerId !== pointerId) return;
|
if (ev.pointerId !== pointerId) return;
|
||||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
|
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
|
||||||
|
if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // отпустили — глубина уходит обратно
|
||||||
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
|
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
|
||||||
const wasMoved = moved;
|
const wasMoved = moved;
|
||||||
const wasLong = longFired;
|
const wasLong = longFired;
|
||||||
@ -889,6 +1038,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo;
|
n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo;
|
||||||
n.vx = 0; n.vy = 0; n.edgeGrow = 1;
|
n.vx = 0; n.vy = 0; n.edgeGrow = 1;
|
||||||
}
|
}
|
||||||
|
layoutDeep(); // глубокие уровни ставим на орбиты родителей (их bfx — орбита центра, не годится)
|
||||||
if (cssBloomKind === 'filter') {
|
if (cssBloomKind === 'filter') {
|
||||||
// ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся
|
// ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся
|
||||||
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
||||||
@ -942,7 +1092,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
||||||
const finalX = node.isFocus ? 0 : node.tx;
|
const finalX = node.isFocus ? 0 : node.tx;
|
||||||
const finalY = node.isFocus ? 0 : node.ty;
|
const finalY = node.isFocus ? 0 : node.ty;
|
||||||
const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
|
||||||
|
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
|
||||||
|
|
||||||
// стартовая точка разлёта
|
// стартовая точка разлёта
|
||||||
let fx; let fy; let fs; let fo; let delay = 0;
|
let fx; let fy; let fs; let fo; let delay = 0;
|
||||||
@ -959,19 +1110,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей)
|
// финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей)
|
||||||
node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1;
|
node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = finalOp;
|
||||||
node.x = fx; node.y = fy;
|
node.x = fx; node.y = fy;
|
||||||
node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1;
|
node.scale = finalScale; node.opacity = finalOp; node.targetScale = finalScale; node.targetOpacity = finalOp;
|
||||||
node.hidden = false;
|
node.hidden = false;
|
||||||
// НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0);
|
// НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0);
|
||||||
// переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия).
|
// переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия).
|
||||||
node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1;
|
node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1;
|
||||||
|
|
||||||
maxDelay = Math.max(maxDelay, delay);
|
// глубокие уровни (tier≥2) НЕ участвуют в CSS-bloom — их позиционирует layoutDeep/renderDeepNodes
|
||||||
blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay });
|
if (node.tier < 2) {
|
||||||
|
maxDelay = Math.max(maxDelay, delay);
|
||||||
|
blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay });
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
pendingFocusOrigin = null;
|
pendingFocusOrigin = null;
|
||||||
|
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
|
||||||
|
layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей
|
||||||
|
renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает)
|
||||||
|
|
||||||
camX = 0; camY = 0; applyWorldTransform();
|
camX = 0; camY = 0; applyWorldTransform();
|
||||||
|
|
||||||
@ -996,6 +1153,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
|
|
||||||
stage.addEventListener('pointerdown', onPointerDown);
|
stage.addEventListener('pointerdown', onPointerDown);
|
||||||
stage.addEventListener('pointermove', onPointerMove);
|
stage.addEventListener('pointermove', onPointerMove);
|
||||||
|
stage.addEventListener('pointermove', onHoverMove); // hover-раскрытие глубины (десктоп)
|
||||||
|
stage.addEventListener('pointerleave', onHoverLeave);
|
||||||
stage.addEventListener('pointerup', onPointerUp);
|
stage.addEventListener('pointerup', onPointerUp);
|
||||||
stage.addEventListener('pointercancel', onPointerUp);
|
stage.addEventListener('pointercancel', onPointerUp);
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
@ -1019,6 +1178,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (longTimer) window.clearTimeout(longTimer);
|
if (longTimer) window.clearTimeout(longTimer);
|
||||||
stage.removeEventListener('pointerdown', onPointerDown);
|
stage.removeEventListener('pointerdown', onPointerDown);
|
||||||
stage.removeEventListener('pointermove', onPointerMove);
|
stage.removeEventListener('pointermove', onPointerMove);
|
||||||
|
stage.removeEventListener('pointermove', onHoverMove);
|
||||||
|
stage.removeEventListener('pointerleave', onHoverLeave);
|
||||||
stage.removeEventListener('pointerup', onPointerUp);
|
stage.removeEventListener('pointerup', onPointerUp);
|
||||||
stage.removeEventListener('pointercancel', onPointerUp);
|
stage.removeEventListener('pointercancel', onPointerUp);
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
|
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
|
||||||
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
|
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
|
||||||
// карту на сеть этого человека (как реальный путь, но локально).
|
// карту на сеть этого человека (как реальный путь, но локально).
|
||||||
|
//
|
||||||
|
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
|
||||||
|
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
|
||||||
|
// Это чисто визуальный лабораторный эксперимент на мок-данных.
|
||||||
|
|
||||||
import { renderHeader } from '../../components/header.js';
|
import { renderHeader } from '../../components/header.js';
|
||||||
import { networkGraphUsers } from '../../mock-data.js';
|
import { networkGraphUsers } from '../../mock-data.js';
|
||||||
@ -22,6 +26,18 @@ const FILTERS = {
|
|||||||
};
|
};
|
||||||
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
||||||
|
|
||||||
|
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
|
||||||
|
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
|
||||||
|
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
|
||||||
|
|
||||||
|
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
|
||||||
|
function seed01(str) {
|
||||||
|
let h = 2166136261;
|
||||||
|
const s = String(str || '');
|
||||||
|
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
|
return ((h >>> 0) % 100000) / 100000;
|
||||||
|
}
|
||||||
|
|
||||||
function helpText() {
|
function helpText() {
|
||||||
return [
|
return [
|
||||||
'Лаборатория карты связей (мок-данные, без сервера).',
|
'Лаборатория карты связей (мок-данные, без сервера).',
|
||||||
@ -30,6 +46,9 @@ function helpText() {
|
|||||||
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
||||||
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
||||||
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
||||||
|
'• Чип «Вселенная» — прототип глубины 2-3 уровней (фейковые связи, для наглядности).',
|
||||||
|
' По умолчанию дальние связи скрыты. Зажми/наведи узел — его микро-связи выплывают',
|
||||||
|
' вокруг, отпусти — втягиваются обратно. Перейдёшь на узел — путь до него горит треком.',
|
||||||
'',
|
'',
|
||||||
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@ -42,6 +61,87 @@ function graphForLogin(login) {
|
|||||||
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
|
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
|
||||||
|
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
|
||||||
|
function synthTier1(focusId) {
|
||||||
|
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < k; i += 1) {
|
||||||
|
const id = `${focusId}__t1_${i}`;
|
||||||
|
const s = seed01(id);
|
||||||
|
out.push({
|
||||||
|
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
|
||||||
|
avatar: null, photo: null,
|
||||||
|
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
|
||||||
|
connectionStrength: 0.5 + s * 0.4,
|
||||||
|
status: s > 0.78 ? 'shining' : '',
|
||||||
|
hasOwnConnections: true, tier: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
|
||||||
|
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
|
||||||
|
function addDeepLevels(model) {
|
||||||
|
const focusId = model.focusId;
|
||||||
|
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
|
||||||
|
const extra = [];
|
||||||
|
tier1.forEach((p) => {
|
||||||
|
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
|
||||||
|
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;
|
||||||
|
extra.push({
|
||||||
|
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
|
||||||
|
avatar: null, photo: null, 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
|
||||||
|
for (let j = 0; j < k3; j += 1) {
|
||||||
|
const id3 = `${id2}_d3_${j}`;
|
||||||
|
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
|
||||||
|
extra.push({
|
||||||
|
id: id3, login: id3, name: '', avatar: null, photo: null, relationType: 'contact',
|
||||||
|
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { focusId, nodes: [...model.nodes, ...extra] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
|
||||||
|
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
|
||||||
|
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
|
||||||
|
function buildLabModel(login, deep, fromLogin) {
|
||||||
|
const tz = graphForLogin(login);
|
||||||
|
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
|
||||||
|
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
|
||||||
|
else tz.connections = [];
|
||||||
|
}
|
||||||
|
const base = buildModelFromTz(tz);
|
||||||
|
|
||||||
|
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
|
||||||
|
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
|
||||||
|
const fid = String(fromLogin);
|
||||||
|
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|
||||||
|
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
|
||||||
|
if (found) {
|
||||||
|
found.track = true; // уже среди связей — просто подсветим трек
|
||||||
|
} else {
|
||||||
|
const f = graphForLogin(fromLogin).focusUser || {};
|
||||||
|
base.nodes.push({
|
||||||
|
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
|
||||||
|
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
|
||||||
|
photo: f.photo || null, relationType: 'friend', strength: 0.97,
|
||||||
|
shining: false, tier: 1, track: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deep ? addDeepLevels(base) : base;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderNetworkLab({ navigate }) {
|
export function renderNetworkLab({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'network-screen';
|
screen.className = 'network-screen';
|
||||||
@ -54,17 +154,13 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи · лаборатория',
|
title: 'Связи · лаборатория',
|
||||||
leftAction: {
|
leftAction: { label: '←', onClick: () => navigate('network-view') },
|
||||||
label: '←',
|
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
|
||||||
onClick: () => navigate('network-view'),
|
|
||||||
},
|
|
||||||
rightActions: [
|
|
||||||
{ label: '?', onClick: () => window.alert(helpText()) },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
header.classList.add('network-header-overlay');
|
header.classList.add('network-header-overlay');
|
||||||
|
|
||||||
const model = buildModelFromTz(graphForLogin(START_LOGIN));
|
let centerLogin = START_LOGIN;
|
||||||
|
let deepMode = false;
|
||||||
|
|
||||||
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
||||||
let activeFilter = 'all';
|
let activeFilter = 'all';
|
||||||
@ -82,15 +178,19 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
stage.append(header);
|
stage.append(header);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
|
|
||||||
|
const model = buildLabModel(centerLogin, deepMode);
|
||||||
|
|
||||||
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
||||||
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
||||||
const graph = createForceGraph({
|
const graph = createForceGraph({
|
||||||
stage,
|
stage,
|
||||||
model,
|
model,
|
||||||
// тап по узлу — переключаем карту на сеть выбранного человека
|
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
|
||||||
|
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
||||||
onNodeTap: (node) => {
|
onNodeTap: (node) => {
|
||||||
graph.setModel(buildModelFromTz(graphForLogin(node.login)));
|
const from = centerLogin; // предыдущий фокус → трек прохождения
|
||||||
// сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
|
centerLogin = node.login || node.id;
|
||||||
|
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
},
|
},
|
||||||
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
|
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
|
||||||
@ -108,8 +208,7 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar';
|
filterBar.className = 'fg-filter-bar';
|
||||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
|
||||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
FILTER_ORDER.forEach((key) => {
|
FILTER_ORDER.forEach((key) => {
|
||||||
const chip = document.createElement('button');
|
const chip = document.createElement('button');
|
||||||
@ -120,6 +219,18 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
filterChips[key] = chip;
|
filterChips[key] = chip;
|
||||||
filterBar.append(chip);
|
filterBar.append(chip);
|
||||||
});
|
});
|
||||||
|
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
|
||||||
|
const deepChip = document.createElement('button');
|
||||||
|
deepChip.type = 'button';
|
||||||
|
deepChip.className = 'fg-filter-chip fg-deep-chip';
|
||||||
|
deepChip.textContent = '🌌 Вселенная';
|
||||||
|
deepChip.addEventListener('click', () => {
|
||||||
|
deepMode = !deepMode;
|
||||||
|
deepChip.classList.toggle('is-active', deepMode);
|
||||||
|
graph.setModel(buildLabModel(centerLogin, deepMode));
|
||||||
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
});
|
||||||
|
filterBar.append(deepChip);
|
||||||
stage.append(filterBar);
|
stage.append(filterBar);
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
|||||||
@ -102,7 +102,7 @@
|
|||||||
height: 52px;
|
height: 52px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
transition: box-shadow 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
@ -132,10 +132,18 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45);
|
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Тактильный отклик «нажатия вглубь»: аватарка слегка вдавливается (scale 0.92), а неоновое кольцо
|
||||||
|
вспыхивает заметно ярче (~1.5×). Срабатывает при наведении, фокусе и зажатии (.is-pressed). */
|
||||||
.fg-node:focus-visible .node-dot,
|
.fg-node:focus-visible .node-dot,
|
||||||
.fg-node:hover .node-dot {
|
.fg-node:hover .node-dot,
|
||||||
border-color: rgba(166, 218, 255, 0.95);
|
.fg-node.is-pressed .node-dot {
|
||||||
box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35);
|
transform: scale(0.92);
|
||||||
|
border-color: rgba(160, 240, 255, 0.95);
|
||||||
|
box-shadow: 0 0 0 2px rgba(150, 238, 255, 0.6), 0 0 22px rgba(120, 230, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fg-node.is-pressed .node-dot { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
||||||
@ -265,6 +273,40 @@
|
|||||||
.fg-dot.is-contact { background: #36435c; }
|
.fg-dot.is-contact { background: #36435c; }
|
||||||
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
|
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
|
||||||
|
|
||||||
|
/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */
|
||||||
|
/* 2-й уровень — «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени
|
||||||
|
и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */
|
||||||
|
.fg-node.is-tier2 .node-dot {
|
||||||
|
border-color: rgba(150, 180, 220, 0.4);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.fg-node.is-tier2 .fg-node-label {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.55;
|
||||||
|
top: calc(100% + 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3-й уровень — микрозвезда: светящаяся точка без картинки (эффект далёкого созвездия). */
|
||||||
|
.fg-dot.is-tier3 {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 0;
|
||||||
|
background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%);
|
||||||
|
box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5);
|
||||||
|
}
|
||||||
|
.fg-dot.is-tier3.is-shine {
|
||||||
|
background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%);
|
||||||
|
box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
|
||||||
|
.fg-deep-chip.is-active {
|
||||||
|
background: rgba(150, 130, 255, 0.18);
|
||||||
|
border-color: rgba(190, 170, 255, 0.6);
|
||||||
|
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3);
|
||||||
|
color: #efeaff;
|
||||||
|
}
|
||||||
|
|
||||||
/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
|
/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
|
||||||
.fg-reticle {
|
.fg-reticle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user