From 9fd63ab2679432aef95f205c03389e66a1a2efef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 11 May 2026 22:50:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(graph):=20space-theme=20polish=20=E2=80=94?= =?UTF-8?q?=20planets,=20sprite=20labels,=20thematic=20edges,=20sizes,=20l?= =?UTF-8?q?oader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/graph/DictionaryGraph.tsx | 442 +++++++++++++++--- .../components/graph/DictionaryGraph3D.tsx | 212 +++++++-- ordinis-admin-ui/src/styles.css | 16 + ordinis-admin-ui/vite.config.ts | 2 +- 4 files changed, 558 insertions(+), 114 deletions(-) diff --git a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx index 2c369fe..497e13f 100644 --- a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx +++ b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx @@ -11,9 +11,9 @@ import { type SimulationNodeDatum, type SimulationLinkDatum, } from 'd3-force' -import { useDictionaries, dictionaryDependentsQuery } from '@/api/queries' +import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries' import type { DictionaryDefinition, SchemaDependent } from '@/api/client' -import { LoadingBlock } from '@/ui' +import { GlobeLoader, LoadingBlock } from '@/ui' import { cn } from '@/lib/utils' // 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не @@ -102,9 +102,9 @@ const BUNDLE_COLORS: Record = { default: '#52483d', // ink-2 } -function colorForBundle(bundle: string): string { - return BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default -} +// colorForBundle helper больше не используется — карточки рендерят +// planet-orbs через radial gradients (defs id=planet-{bundle}), а легенда +// читает BUNDLE_COLORS напрямую через Object.entries. export function DictionaryGraph() { const dictsQuery = useDictionaries() @@ -136,7 +136,29 @@ export function DictionaryGraph() { return m }, [dicts, dependentsResults]) + // Records counts per dict — backend /dictionaries listing НЕ возвращает + // recordCount field (см. catalog/dictionaries.index.tsx, тот же подход). + // Fetch records per dict через useQueries; TanStack Query кэширует между + // catalog ↔ graph переходами, так что если user уже видел catalog — + // counts уже в cache, мгновенно. Иначе 37 параллельных queries на mount. + const recordsResults = useQueries({ + queries: dicts.map((d) => ({ + ...recordsQuery(d.name, 'PUBLIC,INTERNAL,RESTRICTED'), + enabled: Boolean(d.name), + })), + }) + + const recordCountByDict = useMemo(() => { + const m = new Map() + dicts.forEach((d, i) => { + const records = recordsResults[i]?.data + if (Array.isArray(records)) m.set(d.name, records.length) + }) + return m + }, [dicts, recordsResults]) + const allDependentsLoading = dependentsResults.some((r) => r.isLoading) + const allRecordsLoading = recordsResults.some((r) => r.isLoading) if (dictsQuery.isLoading) { return @@ -154,7 +176,8 @@ export function DictionaryGraph() { @@ -164,12 +187,14 @@ export function DictionaryGraph() { function GraphCanvas({ dicts, dependentsByDict, + recordCountByDict, depsLoading, mode, onModeChange, }: { dicts: DictionaryDefinition[] dependentsByDict: Map + recordCountByDict: Map depsLoading: boolean mode: GraphMode onModeChange: (m: GraphMode) => void @@ -195,7 +220,9 @@ function GraphCanvas({ const nodesRef = useRef([]) const linksRef = useRef([]) - // Build initial graph data. + // Build initial graph data. recordCount приходит из per-dict useRecords + // (см. parent component DictionaryGraph) — backend /dictionaries listing + // не отдаёт count, поэтому fetch'аем records и берём .length. const { nodes, links } = useMemo(() => { const nodes: GraphNode[] = dicts.map((d) => ({ id: d.name, @@ -203,7 +230,7 @@ function GraphCanvas({ displayName: d.displayName ?? d.name, bundle: d.bundle, scope: d.scope, - recordCount: d.recordCount ?? 0, + recordCount: recordCountByDict.get(d.name) ?? d.recordCount ?? 0, })) const links: GraphLink[] = [] @@ -223,7 +250,7 @@ function GraphCanvas({ }) return { nodes, links } - }, [dicts, dependentsByDict]) + }, [dicts, dependentsByDict, recordCountByDict]) // Responsive sizing. useEffect(() => { @@ -245,17 +272,23 @@ function GraphCanvas({ nodesRef.current = nodes.map((n) => ({ ...n })) linksRef.current = links.map((l) => ({ ...l })) + // Force tuning под card geometry (W=200, H=56). + // - link distance 180 — расстояние центров двух соединённых cards; + // с шириной 200 это лёгкое касание краёв (визуально связные). + // - charge -540 — умеренное repulsion для 200px nodes; orphan nodes + // рассыпаются вокруг центра, не убегают за viewport. + // - collide radius 110 — halfW 100 + 10px padding. const sim = forceSimulation(nodesRef.current) .force( 'link', forceLink(linksRef.current) .id((d) => d.id) - .distance(90) + .distance(180) .strength(0.4), ) - .force('charge', forceManyBody().strength(-280)) + .force('charge', forceManyBody().strength(-540)) .force('center', forceCenter(size.width / 2, size.height / 2)) - .force('collide', forceCollide().radius(28).strength(0.85)) + .force('collide', forceCollide().radius(110).strength(0.9)) .alpha(1) .alphaDecay(0.025) .on('tick', () => setTick((t) => (t + 1) % 1_000_000)) @@ -286,17 +319,60 @@ function GraphCanvas({ return out }, [hoveredId]) + // Космос — фон graph container'а: глубокий радиальный градиент от deep + // navy в центре к чёрному по краям + starfield 120 точек поверх. Stars + // мемоизированы через useMemo чтобы случайные позиции были stable между + // re-renders'ами (иначе звёзды бы мерцали при каждом tick simulation). + // Звёзды distribuируются на 2000×2000 виртуальном poле — больше viewport, + // чтобы pan'инг показывал новые звёзды а не пустоту по краям. + const starfield = useMemo(() => { + const pts: Array<{ x: number; y: number; r: number; o: number }> = [] + const seed = 137 + let s = seed + const rand = () => { + // Простой LCG — детерминированный без библиотеки seedrandom. + s = (s * 1103515245 + 12345) & 0x7fffffff + return s / 0x7fffffff + } + for (let i = 0; i < 140; i++) { + const isLarge = rand() > 0.85 + pts.push({ + x: rand() * 2400 - 200, + y: rand() * 1800 - 200, + r: isLarge ? 1.2 + rand() * 0.8 : 0.4 + rand() * 0.6, + o: 0.3 + rand() * 0.6, + }) + } + return pts + }, []) + return ( -
+
{depsLoading && ( -
- Загрузка связей… +
+
+ + + Считаем планеты и связи… + +
)} - {/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D). */} + {/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D). + * Космос-стиль: dark glass panels с violet-tint border, accent (warm + * orange) для active state. */}
{/* 2D / 3D mode toggle */} -
+
@@ -348,7 +424,7 @@ function GraphCanvas({ } aria-label="Zoom out" title="Zoom out (−)" - className="size-7 inline-flex items-center justify-center text-ink-2 hover:text-ink hover:bg-surface-2 rounded-sm transition-colors" + className="size-7 inline-flex items-center justify-center text-[#c5bff0] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors" > − @@ -357,11 +433,11 @@ function GraphCanvas({ onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })} aria-label="Reset view" title="Reset (1:1)" - className="h-7 px-2 inline-flex items-center justify-center text-cap text-mute hover:text-ink hover:bg-surface-2 rounded-sm transition-colors" + className="h-7 px-2 inline-flex items-center justify-center text-cap text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors" > 1:1 - + {Math.round(view.zoom * 100)}%
@@ -381,8 +457,8 @@ function GraphCanvas({ fallback={
-

3D-режим недоступен

-

+

3D-режим недоступен

+

Браузер не смог инициализировать WebGL. Возможно отключено аппаратное ускорение (chrome://gpu) или используется среда без поддержки 3D. Возврат к 2D-режиму… @@ -401,6 +477,7 @@ function GraphCanvas({ @@ -409,7 +486,7 @@ function GraphCanvas({ )} {webglFallbackNotice && ( -

+
3D недоступен в этом браузере — переключено на 2D.
)} @@ -454,15 +531,20 @@ function GraphCanvas({ } }} onMouseMove={(e) => { - if (panStartRef.current) { - const dx = e.clientX - panStartRef.current.x - const dy = e.clientY - panStartRef.current.y - setView((v) => ({ - ...v, - panX: panStartRef.current!.panX + dx, - panY: panStartRef.current!.panY + dy, - })) - } + // Захватываем start snapshot СИНХРОННО — setView updater запускается + // асинхронно, и к тому моменту panStartRef.current может стать null + // (mouseup/mouseleave). Раньше `panStartRef.current!.panX + dx` + // внутри updater'а падал с "Cannot read properties of null (reading + // 'panX')". Локальная const живёт в closure → стабильна. + const start = panStartRef.current + if (!start) return + const dx = e.clientX - start.x + const dy = e.clientY - start.y + setView((v) => ({ + ...v, + panX: start.panX + dx, + panY: start.panY + dy, + })) }} onMouseUp={() => { panStartRef.current = null @@ -481,7 +563,7 @@ function GraphCanvas({ markerHeight="6" orient="auto" > - + - + + + {/* Planet glow filter — soft blur halo вокруг планет-орбов. + * stdDeviation = размер размытия в SVG-юнитах. Layered: + * blur copy сначала, потом source поверх. */} + + + + + + + + + {/* Per-bundle radial gradient — даёт 3D-сферу feel (lit на + * top-left, shadow на bottom-right). Цвета: lit edge = brighter + * mix of bundle + white, base = bundle, shadow = darker mix. + * Gradient'ы определены статически для всех бандлов. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Edge glow — мягкий blur halo вокруг линий связей для космо-вайба. + * stdDeviation=2 — лёгкое размытие; merge с source чтобы исходная + * линия оставалась видимой поверх halo. */} + + + + + + + + + {/* Edge gradient — обычная связь идёт от cool violet к warm + * stardust, hovered связь — accent throughout. Это даёт + * direction-сigг'нал даже без arrow markers. */} + + + + + + + + + {/* Starfield — рендерится первым (под всем), не пересчитывается + * при ticks simulation (useMemo seeded LCG для стабильных позиций). + * Position поверх pan/zoom group? Нет — звёзды должны оставаться + * "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ + * zoom group (двигаются вместе с graph nodes). */} + {/* Zoom/pan transform — все nodes/edges рендерятся внутри . */} - {/* Edges (под nodes) */} + {/* Starfield — звёзды в space layer, под edges/nodes */} + + {starfield.map((st, i) => ( + + ))} + + + {/* Edges (под nodes) — космические "потоки" между планетами. + * Стиль: + * - Default: cool violet → mute violet gradient stroke с лёгким + * glow filter + dashed pattern (orbital track feel). + * - Highlighted: accent (warm orange) с brighter glow, particles + * ставим на 2D через stroke-dashoffset CSS animation (см. + * @keyframes edge-flow в styles.css). + * - Dimmed (не connected): opacity 0.12, no glow (фокус на + * выделенной цепи). */} {linksRef.current.map((l, i) => { const s = l.source as GraphNode const t = l.target as GraphNode @@ -506,6 +680,7 @@ function GraphCanvas({ const highlighted = hoveredId && (s.id === hoveredId || t.id === hoveredId) + const dimmed = hoveredId && !highlighted return ( ) })} - {/* Nodes */} + {/* Nodes — планет-карточки: glowing orb (radial gradient sphere) + + * dark glass card с title/count/subtitle (HTML inside foreignObject + * → нативный text-overflow:ellipsis, никаких manual char-caps). + * Layout: row1 = title (full width with ellipsis), row2 = subtitle + * слева | record count справа. Раньше title и count были в одном + * row → длинные имена дрезались за count и за edge карточки. */} {nodesRef.current.map((n) => { - const r = 10 + Math.min(12, Math.log10((n.recordCount ?? 1) + 1) * 4) const dim = hoveredId && !connectedIds.has(n.id) const focused = hoveredId === n.id + // Card 200×56 — расширил с 180 чтобы reserve area для самой + // большой планеты (orbR_max=18) не съедала text content. + const W = 200 + const H = 56 + const halfW = W / 2 + const halfH = H / 2 + // Planet radius шкалируется по record count: sqrt чтобы 5→14 + // записей давали заметную разницу (не log который сжал бы их в + // 2-3px diff). Min 7 (0 записей — планета всё ещё видна), max + // 18 (cap чтобы не разорвать card layout). + // records=0 → 7px + // records=5 → 10.1px + // records=14 → 12.2px + // records=50 → 16.9px + // records≥100 → 18px (cap) + const orbR = Math.min(18, Math.max(7, 7 + Math.sqrt(n.recordCount) * 1.4)) + // Orb center остаётся в фиксированном x для consistent left margin — + // меняется только радиус (планета "растёт" наружу). + const orbCx = -halfW + 26 + const bundleKey = n.bundle in BUNDLE_COLORS ? n.bundle : 'default' + const planetGradId = `planet-${bundleKey}` + // Content area стартует за reserved orb area (фиксированно для + // max orb size), чтобы title не дёргался когда счётчики + // обновляются из useRecords. + const contentX = orbCx + 18 + 8 // max orbR (18) + padding 8 + const contentW = halfW - contentX - 8 + const recordText = n.recordCount > 0 ? `${n.recordCount} зап` : '—' return ( setHoveredId(n.id)} onMouseLeave={() => setHoveredId(null)} onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} > - - {/* Label под node */} - + + + + {/* foreignObject — HTML inside SVG для нативного truncate. + * pointerEvents=none чтобы родительский ловил mouse events. */} + - {n.displayName.length > 18 ? n.displayName.slice(0, 17) + '…' : n.displayName} - +
+ {/* Title — full width, ellipsis на overflow */} +
+ {n.displayName} +
+ {/* Subtitle row: name · bundle (left, ellipsis) | count (right, no shrink) */} +
+ + {n.name} · {n.bundle} + + 0 ? '#ff9a64' : '#7a76b8', + fontWeight: n.recordCount > 0 ? 600 : 400, + whiteSpace: 'nowrap', + flexShrink: 0, + }} + > + {recordText} + +
+
+
) })}
{/* === end zoom/pan transform === */} } - {/* Legend */} -
-
Bundle
+ {/* Legend — space-themed panel с planet-orb swatches */} +
+
Bundle
{Object.entries(BUNDLE_COLORS) .filter(([k]) => k !== 'default') .map(([bundle, color]) => (
- - {bundle} + + {bundle}
))}
diff --git a/ordinis-admin-ui/src/components/graph/DictionaryGraph3D.tsx b/ordinis-admin-ui/src/components/graph/DictionaryGraph3D.tsx index 8c2ff89..e4d4d72 100644 --- a/ordinis-admin-ui/src/components/graph/DictionaryGraph3D.tsx +++ b/ordinis-admin-ui/src/components/graph/DictionaryGraph3D.tsx @@ -1,25 +1,22 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import ForceGraph3D, { type ForceGraphMethods } from 'react-force-graph-3d' +import { CanvasTexture, Sprite, SpriteMaterial } from 'three' import type { DictionaryDefinition, SchemaDependent } from '@/api/client' /** - * 3D-режим графа справочников через react-force-graph-3d (three.js + d3-force-3d). + * 3D-режим графа справочников через react-force-graph-3d (three.js + + * d3-force-3d). Lazy-импортируется из DictionaryGraph чтобы three.js + * (~650kb) не попадал в default chunk. * - * Этот компонент динамически импортируется из DictionaryGraph, чтобы three.js - * (~650kb minified) не попадал в default chunk. Lazy-loaded только когда - * пользователь явно переключает в 3D режим. - * - * Поведение: - * - Те же nodes/links что в 2D-моде (общая data shape) - * - Camera orbit controls (drag = rotate, wheel = zoom) - * - Hover → tooltip с displayName + record count - * - Click → navigate /dictionaries/$name - * - Цвета и размеры узлов идентичны 2D (single source of truth) - * - Directional particles на edges — лёгкая визуализация направления FK - * - * Performance: 3D-граф handles 200+ nodes легко (GPU rendering), но default - * configuration уже good enough для current ~37 dicts. + * Космос-стиль: + * - Сферы с bundle цветами (terracotta / green / gold / brown / mute) + * - Hovered planet → accent (warm orange) glow + * - Label-sprite (canvas-rendered text) над каждой планетой: displayName + * + record count. Sprite автоматически billboard-ит к камере. + * - Edges: subtle violet stardust → bright accent при hover, directional + * particles на hovered chain (comet-trail feel). + * - Background: transparent (container's radial gradient просвечивает). */ type Node3D = { @@ -43,19 +40,135 @@ type Link3D = { } const BUNDLE_COLORS: Record = { - cuod: '#b05a2e', - geo: '#4f7038', - comms: '#a8762a', - delivery: '#3a2818', - default: '#52483d', + cuod: '#d97757', + geo: '#5b8044', + comms: '#b8902f', + delivery: '#7a5644', + default: '#7a76b8', } const colorForBundle = (bundle: string): string => BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default +// Space-palette accents reused across canvases / edges. +const ACCENT_HEX = '#ff9a64' +const EDGE_DIM_HEX = '#4a4470' +const LABEL_FG = '#f0eaff' +const LABEL_SUB = '#a09bc8' +const LABEL_BG = 'rgba(28, 24, 64, 0.85)' +const LABEL_BORDER = 'rgba(61, 56, 99, 1)' + +/** + * Рисует label-sprite для одной планеты. Canvas 256×96, текст в 2 строки: + * 1) displayName (12px, белый) + * 2) record count + bundle (10px, mute violet, accent для count > 0) + * + * Прячем за rounded-pill background для читаемости поверх любого фона. + * Sprite scale (16 units high) подобран так, чтобы text был ~ размер node + * sphere'ы при default camera position. + */ +function makeLabelSprite(node: Node3D): Sprite { + const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio ?? 1, 2) : 1 + const cssW = 256 + const cssH = 96 + const canvas = document.createElement('canvas') + canvas.width = cssW * dpr + canvas.height = cssH * dpr + const ctx = canvas.getContext('2d') + if (!ctx) { + // Fallback: пустой sprite если ctx недоступен (very rare). + const mat = new SpriteMaterial({}) + return new Sprite(mat) + } + ctx.scale(dpr, dpr) + ctx.textBaseline = 'middle' + + // Background pill — rounded rect для контраста с фоном (космос-bg + // переменный, нужен якорь). + const padX = 14 + const padY = 12 + ctx.fillStyle = LABEL_BG + ctx.strokeStyle = LABEL_BORDER + ctx.lineWidth = 1 + const r = 10 + const x = padX + const y = padY + const w = cssW - padX * 2 + const h = cssH - padY * 2 + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + r) + ctx.lineTo(x + w, y + h - r) + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) + ctx.lineTo(x + r, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - r) + ctx.lineTo(x, y + r) + ctx.quadraticCurveTo(x, y, x + r, y) + ctx.closePath() + ctx.fill() + ctx.stroke() + + // Title — ellipsis в canvas руками, canvas нет text-overflow. + ctx.fillStyle = LABEL_FG + ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif' + const maxTitleW = w - 24 + let title = node.displayName + if (ctx.measureText(title).width > maxTitleW) { + while (title.length > 1 && ctx.measureText(title + '…').width > maxTitleW) { + title = title.slice(0, -1) + } + title = title + '…' + } + ctx.fillText(title, x + 12, y + 22) + + // Sub-row: bundle code · scope (left), record count (right, accent). + ctx.font = '500 14px ui-monospace, SFMono-Regular, Menlo, monospace' + const subRaw = `${node.name} · ${node.bundle}` + let subText = subRaw + // Reserve ~70px справа на count + const subAvail = w - 24 - 70 + if (ctx.measureText(subText).width > subAvail) { + while (subText.length > 1 && ctx.measureText(subText + '…').width > subAvail) { + subText = subText.slice(0, -1) + } + subText = subText + '…' + } + ctx.fillStyle = LABEL_SUB + ctx.fillText(subText, x + 12, y + 50) + + // Count, right-aligned. + const countText = node.recordCount > 0 ? `${node.recordCount} зап` : '—' + ctx.font = '600 14px ui-monospace, SFMono-Regular, Menlo, monospace' + ctx.fillStyle = node.recordCount > 0 ? ACCENT_HEX : LABEL_SUB + const cw = ctx.measureText(countText).width + ctx.fillText(countText, x + w - 12 - cw, y + 50) + + const texture = new CanvasTexture(canvas) + // Filtering improves readability на subpixel positions. + texture.needsUpdate = true + // Без mipmaps — sharper text при отдалении. + texture.minFilter = 1006 // LinearFilter + texture.magFilter = 1006 // LinearFilter + + const material = new SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, // не блокируем edges за sprite'ом + }) + const sprite = new Sprite(material) + // Scale: ширина sprite 32 units в world space → ~ диаметр большой планеты. + // Aspect ratio: cssW/cssH = 256/96 ≈ 2.67. + sprite.scale.set(28, 10.5, 1) + // Поднимаем sprite над сферой (sphere radius максимум ~5). + sprite.position.set(0, 10, 0) + return sprite +} + type Props = { dicts: DictionaryDefinition[] dependentsByDict: Map + recordCountByDict: Map width: number height: number } @@ -63,6 +176,7 @@ type Props = { export default function DictionaryGraph3D({ dicts, dependentsByDict, + recordCountByDict, width, height, }: Props) { @@ -70,8 +184,7 @@ export default function DictionaryGraph3D({ const graphRef = useRef | undefined>(undefined) const [hoverId, setHoverId] = useState(null) - // Build graph data — same logic as 2D mode, без simulation refs (three - // worker handles physics). + // Build graph data — same logic as 2D mode. const graphData = useMemo(() => { const nodes: Node3D[] = dicts.map((d) => ({ id: d.name, @@ -79,7 +192,7 @@ export default function DictionaryGraph3D({ displayName: d.displayName ?? d.name, bundle: d.bundle, scope: d.scope, - recordCount: d.recordCount ?? 0, + recordCount: recordCountByDict.get(d.name) ?? d.recordCount ?? 0, })) const nodeIds = new Set(nodes.map((n) => n.id)) const links: Link3D[] = [] @@ -97,21 +210,15 @@ export default function DictionaryGraph3D({ }) }) return { nodes, links } - }, [dicts, dependentsByDict]) + }, [dicts, dependentsByDict, recordCountByDict]) - // Tune camera + force layout once after mount. + // Tune camera once after mount. useEffect(() => { const fg = graphRef.current if (!fg) return - // Подальше отвести камеру для просторного 3D-обзора. - fg.cameraPosition({ x: 0, y: 0, z: 400 }) + fg.cameraPosition({ x: 0, y: 0, z: 500 }) }, []) - // CSS variable resolution — three не понимает var(--color-*), - // grab raw hex values from document root для accent + ink. - const accentHex = '#b05a2e' - const lineHex = '#e6e1d4' - return (
@@ -121,38 +228,51 @@ export default function DictionaryGraph3D({ height={height} backgroundColor="rgba(0,0,0,0)" nodeId="id" - nodeLabel={(n) => - `
${n.displayName}
${n.bundle} · ${n.recordCount} записей
` - } - nodeColor={(n) => (hoverId === n.id ? accentHex : colorForBundle(n.bundle))} - nodeVal={(n) => 1 + Math.log10((n.recordCount ?? 1) + 1) * 2} + // Tooltip отключаем — sprite уже показывает inline label с тем же + // contentом, native tooltip был бы дублёром. + nodeLabel={() => ''} + nodeColor={(n) => (hoverId === n.id ? ACCENT_HEX : colorForBundle(n.bundle))} + // nodeVal — "volume" по three convention, sphere radius ≈ cbrt(nodeVal) + // * nodeRelSize/4. Sqrt-шкала с большим коэффициентом чтобы планеты + // визуально отличались по counts (раньше log10 давал почти одинаковые + // sphere). + // records=0 → nodeVal=1 (sphere radius ~1.0) + // records=5 → nodeVal=4.4 (~1.6) + // records=14 → nodeVal=6.6 (~1.9) + // records=100 → nodeVal=16 (~2.5) + nodeVal={(n) => 1 + Math.sqrt(n.recordCount ?? 0) * 1.5} nodeOpacity={hoverId ? 0.85 : 0.95} - nodeResolution={16} + nodeResolution={20} + // Custom three.js object для каждого node — Sprite с текстом + // displayName + count. nodeThreeObjectExtend=true → sprite + // дополняет default sphere (sphere остаётся, sprite добавляется). + nodeThreeObject={makeLabelSprite} + nodeThreeObjectExtend={true} linkColor={(l) => { const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id - return hoverId && (sid === hoverId || tid === hoverId) ? accentHex : lineHex + return hoverId && (sid === hoverId || tid === hoverId) ? ACCENT_HEX : EDGE_DIM_HEX }} - linkOpacity={hoverId ? 0.4 : 0.5} + linkOpacity={hoverId ? 0.55 : 0.45} linkWidth={(l) => { const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id - return hoverId && (sid === hoverId || tid === hoverId) ? 1.5 : 0.5 + return hoverId && (sid === hoverId || tid === hoverId) ? 2 : 0.6 }} linkDirectionalArrowLength={3} linkDirectionalArrowRelPos={1} - linkDirectionalArrowColor={() => accentHex} + linkDirectionalArrowColor={() => ACCENT_HEX} linkDirectionalParticles={(l) => { const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id - return hoverId && (sid === hoverId || tid === hoverId) ? 2 : 0 + return hoverId && (sid === hoverId || tid === hoverId) ? 3 : 0 }} linkDirectionalParticleSpeed={0.006} - linkDirectionalParticleWidth={2} + linkDirectionalParticleWidth={2.5} + linkDirectionalParticleColor={() => ACCENT_HEX} onNodeHover={(n) => setHoverId(n?.id ?? null)} onNodeClick={(n) => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} - // Slight cooldown extension чтобы layout успел осесть. - cooldownTicks={120} + cooldownTicks={150} />
) diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css index 4f7d280..8234a4e 100644 --- a/ordinis-admin-ui/src/styles.css +++ b/ordinis-admin-ui/src/styles.css @@ -423,3 +423,19 @@ body { --color-regolith: var(--color-line); --color-asteroid: var(--color-mute); } + +/* === Graph edge flow — космо-анимация для активных связей. + * stroke-dashoffset animation создаёт ощущение потока частиц от source + * к target. Только на highlighted edges (hover state), default edges + * статичные dashed pattern. Respects prefers-reduced-motion. === */ +@keyframes graph-edge-flow { + to { stroke-dashoffset: -28; } +} +.graph-edge-flow { + animation: graph-edge-flow 1.4s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .graph-edge-flow { + animation: none; + } +} diff --git a/ordinis-admin-ui/vite.config.ts b/ordinis-admin-ui/vite.config.ts index dbc0cbf..a90e75a 100644 --- a/ordinis-admin-ui/vite.config.ts +++ b/ordinis-admin-ui/vite.config.ts @@ -137,7 +137,7 @@ export default defineConfig({ // wrappers), грузится только при переключении в 3D режим. if ( id.match( - /node_modules\/(three|3d-force-graph|d3-force-3d|kapsule|react-kapsule|three-forcegraph|three-render-objects|react-force-graph-3d|@tweenjs)/, + /node_modules\/(three|3d-force-graph|d3-force-3d|d3-binarytree|d3-octree|kapsule|react-kapsule|three-forcegraph|three-render-objects|react-force-graph-3d|@tweenjs|accessor-fn|index-array-by)/, ) ) { return 'vendor-graph3d'