SHA256
UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -255,7 +255,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const a11y = document.createElement('div');
|
||||
a11y.className = 'fg-a11y';
|
||||
a11y.setAttribute('role', 'region');
|
||||
a11y.setAttribute('aria-label', 'Карта связей — список');
|
||||
a11y.setAttribute('aria-label', 'Карта связей – список');
|
||||
stage.append(edgesSvg, world, a11y);
|
||||
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
|
||||
|
||||
@@ -656,7 +656,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const focus = nodes.find((n) => n.isFocus);
|
||||
const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
|
||||
const rel = { family: 'семья', friend: 'друг', close_friend: 'близкий друг', business: 'бизнес', contact: 'контакт' };
|
||||
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
|
||||
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' – сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
|
||||
a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
|
||||
}
|
||||
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
@@ -696,7 +696,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.hidden = false;
|
||||
node.bloom = false;
|
||||
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
|
||||
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
|
||||
// обновляем классы элемента (роль/тип/свечение/уровень) – без пересоздания DOM
|
||||
node.el.className = spec.dotOnly
|
||||
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
|
||||
@@ -711,7 +711,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
|
||||
// Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает
|
||||
// обе центральные оси viewport. На крайнем положении к центральной линии прижимается край
|
||||
// крайнего аватара — полностью выбросить весь граф за экран больше нельзя.
|
||||
// крайнего аватара – полностью выбросить весь граф за экран больше нельзя.
|
||||
function clampCameraPosition(nextX, nextY) {
|
||||
const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02);
|
||||
if (!visible.length) return { x: 0, y: 0 };
|
||||
@@ -783,14 +783,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
n.el.style.transform =
|
||||
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
|
||||
n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
|
||||
// расфокус глубины (dive): фоновые узлы уходят в blur; чёткие — без фильтра (дёшево)
|
||||
// расфокус глубины (dive): фоновые узлы уходят в blur; чёткие – без фильтра (дёшево)
|
||||
n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
|
||||
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию
|
||||
// СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия
|
||||
// СКРЫТЫ (схлопнуты в родителя, opacity 0) – и «выплывают» вокруг родителя по мере его раскрытия
|
||||
// (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь.
|
||||
function layoutDeep() {
|
||||
if (!hasDeep) return;
|
||||
@@ -820,13 +820,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const pr = ORB_R * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
|
||||
const cnt = childCountByParent.get(n.parentId) || 1;
|
||||
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
|
||||
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
|
||||
const r = ringR * e; // при e=0 – в центре родителя, при 1 – на полной орбите
|
||||
// ВЕЕР «полукругом наружу»: раскрываем детей в сторону ОТ пути назад (от деда к родителю),
|
||||
// чтобы они не перекрывали нить-крошку и родителя. Равномерно по индексу среди братьев.
|
||||
const gp = nodeById.get(p.parentId);
|
||||
const ox = p.x - (gp ? gp.x : 0);
|
||||
const oy = p.y - (gp ? gp.y : 0);
|
||||
const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол)
|
||||
const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк – старый угол)
|
||||
const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0; // -0.5..0.5 по веера
|
||||
const ang = outward + t * DEEP_FAN;
|
||||
n.x = p.x + Math.cos(ang) * r;
|
||||
@@ -834,7 +834,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
|
||||
// tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42)
|
||||
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
|
||||
// вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий)
|
||||
// вложенность: 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); // лёгкий «поп» при выплывании
|
||||
@@ -861,7 +861,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
return moving;
|
||||
}
|
||||
|
||||
// Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик)
|
||||
// Во время CSS-bloom (когда renderNodes не вызывается – узлы 1-го уровня анимирует компоновщик)
|
||||
// отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями.
|
||||
function renderDeepNodes() {
|
||||
if (!hasDeep) return;
|
||||
@@ -875,14 +875,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
}
|
||||
|
||||
// LOD (level-of-detail): при сильном зуме (pinch/dive) видимые точки 3-го уровня дорисовываются как
|
||||
// маленькие аватарки (лицо+имя), при отдалении — сворачиваются обратно в светящиеся точки.
|
||||
// маленькие аватарки (лицо+имя), при отдалении – сворачиваются обратно в светящиеся точки.
|
||||
// Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая.
|
||||
function updateLod() {
|
||||
if (!hasDeep) return;
|
||||
for (const n of nodes) {
|
||||
if (n.tier < 3) continue; // LOD касается только микрозвёзд 3-го уровня
|
||||
const isFull = n.lod === 'full';
|
||||
// гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания)
|
||||
// гистерезис: апгрейд на UP, откат на DOWN – у промежуточного зума состояние «залипает» (без мигания)
|
||||
const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP;
|
||||
const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые
|
||||
if (wantFull === isFull) continue;
|
||||
@@ -986,8 +986,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
edgesSvg.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
|
||||
// Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
|
||||
// концы линий – строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
|
||||
// Z – зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
|
||||
const Z = zoom;
|
||||
const tx = (n) => centerX + camX + n.x * Z;
|
||||
const ty = (n) => centerY + camY + n.y * Z;
|
||||
@@ -1007,14 +1007,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const fx = centerX + camX + parent.x * Z;
|
||||
const fy = centerY + camY + parent.y * Z;
|
||||
// радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…),
|
||||
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) — легаси, давало разный зазор.
|
||||
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) – легаси, давало разный зазор.
|
||||
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
|
||||
const nx = tx(n);
|
||||
const ny = ty(n);
|
||||
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
|
||||
|
||||
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке
|
||||
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) – линию тянем к его ФИНАЛЬНОЙ точке
|
||||
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
|
||||
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
|
||||
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
|
||||
|
||||
Reference in New Issue
Block a user