feat(graph): space-theme polish — planets, sprite labels, thematic edges, sizes, loader
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, number>()
|
||||
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 <LoadingBlock size="lg" label="Загрузка справочников…" />
|
||||
@@ -154,7 +176,8 @@ export function DictionaryGraph() {
|
||||
<GraphCanvas
|
||||
dicts={dicts}
|
||||
dependentsByDict={dependentsByDict}
|
||||
depsLoading={allDependentsLoading}
|
||||
recordCountByDict={recordCountByDict}
|
||||
depsLoading={allDependentsLoading || allRecordsLoading}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
@@ -164,12 +187,14 @@ export function DictionaryGraph() {
|
||||
function GraphCanvas({
|
||||
dicts,
|
||||
dependentsByDict,
|
||||
recordCountByDict,
|
||||
depsLoading,
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
dicts: DictionaryDefinition[]
|
||||
dependentsByDict: Map<string, SchemaDependent[]>
|
||||
recordCountByDict: Map<string, number>
|
||||
depsLoading: boolean
|
||||
mode: GraphMode
|
||||
onModeChange: (m: GraphMode) => void
|
||||
@@ -195,7 +220,9 @@ function GraphCanvas({
|
||||
const nodesRef = useRef<GraphNode[]>([])
|
||||
const linksRef = useRef<GraphLink[]>([])
|
||||
|
||||
// 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<GraphNode>(nodesRef.current)
|
||||
.force(
|
||||
'link',
|
||||
forceLink<GraphNode, GraphLink>(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<GraphNode>().radius(28).strength(0.85))
|
||||
.force('collide', forceCollide<GraphNode>().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 (
|
||||
<div ref={wrapperRef} className="relative w-full h-[calc(100vh-220px)] min-h-[500px] rounded-lg border border-line bg-surface overflow-hidden">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative w-full h-[calc(100vh-220px)] min-h-[500px] rounded-lg border border-line overflow-hidden"
|
||||
style={{
|
||||
// Космос — radial gradient deep navy → near-black, плюс subtle
|
||||
// accent halo в центре (намёк на star/sun без отвлекающего диска).
|
||||
background:
|
||||
'radial-gradient(ellipse at 40% 35%, #1a1742 0%, #0c0a23 45%, #060512 90%)',
|
||||
}}
|
||||
>
|
||||
{depsLoading && (
|
||||
<div className="text-mono absolute top-3 right-3 z-10 uppercase tracking-wider text-mute bg-surface/80 backdrop-blur px-2 py-1 rounded">
|
||||
Загрузка связей…
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-3 bg-[#1c1840]/85 backdrop-blur border border-[#3d3863] rounded-lg px-6 py-5 shadow-2xl">
|
||||
<GlobeLoader size={56} />
|
||||
<span className="text-mono text-cap uppercase tracking-wider text-[#a09bc8]">
|
||||
Считаем планеты и связи…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-1">
|
||||
{/* 2D / 3D mode toggle */}
|
||||
<div className="inline-flex items-center rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
|
||||
<div className="inline-flex items-center rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onModeChange('2d')}
|
||||
@@ -305,8 +381,8 @@ function GraphCanvas({
|
||||
className={cn(
|
||||
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
|
||||
mode === '2d'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-mute hover:text-ink hover:bg-surface-2',
|
||||
? 'bg-[#ff9a64] text-[#1c1840]'
|
||||
: 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
|
||||
)}
|
||||
>
|
||||
2D
|
||||
@@ -319,8 +395,8 @@ function GraphCanvas({
|
||||
className={cn(
|
||||
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
|
||||
mode === '3d'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-mute hover:text-ink hover:bg-surface-2',
|
||||
? 'bg-[#ff9a64] text-[#1c1840]'
|
||||
: 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
|
||||
)}
|
||||
>
|
||||
3D
|
||||
@@ -329,7 +405,7 @@ function GraphCanvas({
|
||||
|
||||
{/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */}
|
||||
{mode === '2d' && (
|
||||
<div className="inline-flex items-center gap-1 rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
|
||||
<div className="inline-flex items-center gap-1 rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -337,7 +413,7 @@ function GraphCanvas({
|
||||
}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in (+)"
|
||||
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"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -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"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
<span className="px-1 text-mono text-cap text-mute tabular-nums">
|
||||
<span className="px-1 text-mono text-cap text-[#a09bc8] tabular-nums">
|
||||
{Math.round(view.zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -381,8 +457,8 @@ function GraphCanvas({
|
||||
fallback={
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div className="text-center max-w-md">
|
||||
<p className="text-title-md text-ink mb-2">3D-режим недоступен</p>
|
||||
<p className="text-cell text-mute">
|
||||
<p className="text-title-md text-[#f0eaff] mb-2">3D-режим недоступен</p>
|
||||
<p className="text-cell text-[#a09bc8]">
|
||||
Браузер не смог инициализировать WebGL. Возможно отключено
|
||||
аппаратное ускорение (chrome://gpu) или используется среда без
|
||||
поддержки 3D. Возврат к 2D-режиму…
|
||||
@@ -401,6 +477,7 @@ function GraphCanvas({
|
||||
<DictionaryGraph3D
|
||||
dicts={dicts}
|
||||
dependentsByDict={dependentsByDict}
|
||||
recordCountByDict={recordCountByDict}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
/>
|
||||
@@ -409,7 +486,7 @@ function GraphCanvas({
|
||||
)}
|
||||
|
||||
{webglFallbackNotice && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 max-w-md bg-warn/10 border border-warn text-ink-2 text-cell rounded-md px-3 py-2 shadow-sm backdrop-blur">
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 max-w-md bg-[#3d2614]/85 border border-[#ff9a64] text-[#ffd5b0] text-cell rounded-md px-3 py-2 shadow-lg backdrop-blur">
|
||||
3D недоступен в этом браузере — переключено на 2D.
|
||||
</div>
|
||||
)}
|
||||
@@ -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"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-mute)" opacity="0.5" />
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#7a76b8" opacity="0.6" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrow-hi"
|
||||
@@ -492,13 +574,105 @@ function GraphCanvas({
|
||||
markerHeight="7"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-accent)" />
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ff9a64" />
|
||||
</marker>
|
||||
|
||||
{/* Planet glow filter — soft blur halo вокруг планет-орбов.
|
||||
* stdDeviation = размер размытия в SVG-юнитах. Layered:
|
||||
* blur copy сначала, потом source поверх. */}
|
||||
<filter id="planet-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* 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'ы определены статически для всех бандлов. */}
|
||||
<radialGradient id="planet-cuod" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor="#ffc8a3" />
|
||||
<stop offset="40%" stopColor="#d97757" />
|
||||
<stop offset="100%" stopColor="#5c2810" />
|
||||
</radialGradient>
|
||||
<radialGradient id="planet-geo" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor="#a8c47a" />
|
||||
<stop offset="40%" stopColor="#5b8044" />
|
||||
<stop offset="100%" stopColor="#22341a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="planet-comms" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor="#e6c97a" />
|
||||
<stop offset="40%" stopColor="#b8902f" />
|
||||
<stop offset="100%" stopColor="#4a3a10" />
|
||||
</radialGradient>
|
||||
<radialGradient id="planet-delivery" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor="#8a6e5a" />
|
||||
<stop offset="40%" stopColor="#4a3324" />
|
||||
<stop offset="100%" stopColor="#1a0e08" />
|
||||
</radialGradient>
|
||||
<radialGradient id="planet-default" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stopColor="#9d93a6" />
|
||||
<stop offset="40%" stopColor="#5e5560" />
|
||||
<stop offset="100%" stopColor="#2a242a" />
|
||||
</radialGradient>
|
||||
|
||||
{/* Edge glow — мягкий blur halo вокруг линий связей для космо-вайба.
|
||||
* stdDeviation=2 — лёгкое размытие; merge с source чтобы исходная
|
||||
* линия оставалась видимой поверх halo. */}
|
||||
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.6" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Edge gradient — обычная связь идёт от cool violet к warm
|
||||
* stardust, hovered связь — accent throughout. Это даёт
|
||||
* direction-сigг'нал даже без arrow markers. */}
|
||||
<linearGradient id="edge-stroke" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#5b569b" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="#a09bc8" stopOpacity="0.55" />
|
||||
</linearGradient>
|
||||
<linearGradient id="edge-stroke-hi" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#ff9a64" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#ffd5b0" stopOpacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Starfield — рендерится первым (под всем), не пересчитывается
|
||||
* при ticks simulation (useMemo seeded LCG для стабильных позиций).
|
||||
* Position поверх pan/zoom group? Нет — звёзды должны оставаться
|
||||
* "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ
|
||||
* zoom group (двигаются вместе с graph nodes). */}
|
||||
|
||||
{/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */}
|
||||
<g transform={`translate(${view.panX} ${view.panY}) scale(${view.zoom})`}>
|
||||
{/* Edges (под nodes) */}
|
||||
{/* Starfield — звёзды в space layer, под edges/nodes */}
|
||||
<g pointerEvents="none">
|
||||
{starfield.map((st, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={st.x}
|
||||
cy={st.y}
|
||||
r={st.r}
|
||||
fill="#e6e6ff"
|
||||
opacity={st.o}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* 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 (
|
||||
<line
|
||||
key={i}
|
||||
@@ -513,61 +688,194 @@ function GraphCanvas({
|
||||
y1={s.y}
|
||||
x2={t.x}
|
||||
y2={t.y}
|
||||
stroke={highlighted ? 'var(--color-accent)' : 'var(--color-line)'}
|
||||
strokeWidth={highlighted ? 1.5 : 1}
|
||||
opacity={hoveredId && !highlighted ? 0.2 : 0.65}
|
||||
stroke={
|
||||
highlighted
|
||||
? 'url(#edge-stroke-hi)'
|
||||
: dimmed
|
||||
? '#3d3863'
|
||||
: 'url(#edge-stroke)'
|
||||
}
|
||||
strokeWidth={highlighted ? 1.8 : 1}
|
||||
strokeDasharray={highlighted ? '4 3' : '2 4'}
|
||||
opacity={dimmed ? 0.15 : highlighted ? 0.95 : 0.55}
|
||||
filter={dimmed ? undefined : 'url(#edge-glow)'}
|
||||
markerEnd={highlighted ? 'url(#arrow-hi)' : 'url(#arrow)'}
|
||||
className={highlighted ? 'graph-edge-flow' : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
|
||||
className="cursor-pointer"
|
||||
opacity={dim ? 0.3 : 1}
|
||||
opacity={dim ? 0.25 : 1}
|
||||
onMouseEnter={() => setHoveredId(n.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
|
||||
>
|
||||
<circle
|
||||
r={r}
|
||||
fill={colorForBundle(n.bundle)}
|
||||
stroke={focused ? 'var(--color-accent)' : 'var(--color-surface)'}
|
||||
strokeWidth={focused ? 3 : 2}
|
||||
{/* Card body — dark glass с violet-ish border, focused → accent border + glow */}
|
||||
<rect
|
||||
x={-halfW}
|
||||
y={-halfH}
|
||||
width={W}
|
||||
height={H}
|
||||
rx={8}
|
||||
fill="#1c1840"
|
||||
fillOpacity={0.85}
|
||||
stroke={focused ? '#ff9a64' : '#3d3863'}
|
||||
strokeWidth={focused ? 1.5 : 1}
|
||||
className={focused ? 'drop-shadow-[0_0_12px_rgba(255,154,100,0.5)]' : ''}
|
||||
/>
|
||||
{/* Label под node */}
|
||||
<text
|
||||
y={r + 12}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fontFamily="ui-sans-serif,system-ui,sans-serif"
|
||||
fill="var(--color-ink)"
|
||||
className={cn(focused && 'font-semibold')}
|
||||
{/* Planet orb с glow filter + radial gradient (lit top-left).
|
||||
* Размер orbR пропорционален recordCount (sqrt scale, 7-18px),
|
||||
* specular highlight тоже шкалируется чтобы выглядеть как
|
||||
* планета а не точка. */}
|
||||
<g filter="url(#planet-glow)">
|
||||
<circle
|
||||
cx={orbCx}
|
||||
cy={0}
|
||||
r={orbR}
|
||||
fill={`url(#${planetGradId})`}
|
||||
/>
|
||||
<ellipse
|
||||
cx={orbCx - orbR * 0.3}
|
||||
cy={-orbR * 0.3}
|
||||
rx={orbR * 0.22}
|
||||
ry={orbR * 0.14}
|
||||
fill="#ffffff"
|
||||
opacity={0.4}
|
||||
/>
|
||||
</g>
|
||||
{/* foreignObject — HTML inside SVG для нативного truncate.
|
||||
* pointerEvents=none чтобы родительский <g> ловил mouse events. */}
|
||||
<foreignObject
|
||||
x={contentX}
|
||||
y={-halfH + 5}
|
||||
width={contentW}
|
||||
height={H - 10}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{n.displayName.length > 18 ? n.displayName.slice(0, 17) + '…' : n.displayName}
|
||||
</text>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Title — full width, ellipsis на overflow */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#f0eaff',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
title={n.displayName}
|
||||
>
|
||||
{n.displayName}
|
||||
</div>
|
||||
{/* Subtitle row: name · bundle (left, ellipsis) | count (right, no shrink) */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: '6px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#a09bc8',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
title={`${n.name} · ${n.bundle}`}
|
||||
>
|
||||
{n.name} · {n.bundle}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: n.recordCount > 0 ? '#ff9a64' : '#7a76b8',
|
||||
fontWeight: n.recordCount > 0 ? 600 : 400,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{recordText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
{/* === end zoom/pan transform === */}
|
||||
</svg>}
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-surface/90 backdrop-blur px-3 py-2 rounded-md border border-line text-cell">
|
||||
<div className="font-display uppercase tracking-wider text-mute mb-0.5">Bundle</div>
|
||||
{/* Legend — space-themed panel с planet-orb swatches */}
|
||||
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-[#1c1840]/85 backdrop-blur px-3 py-2 rounded-md border border-[#3d3863] text-cell shadow-lg">
|
||||
<div className="font-display uppercase tracking-wider text-[#a09bc8] mb-0.5">Bundle</div>
|
||||
{Object.entries(BUNDLE_COLORS)
|
||||
.filter(([k]) => k !== 'default')
|
||||
.map(([bundle, color]) => (
|
||||
<div key={bundle} className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full" style={{ background: color }} />
|
||||
<span className="text-ink-2 font-mono">{bundle}</span>
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
boxShadow: `0 0 4px ${color}aa`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[#c5bff0] font-mono">{bundle}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, SchemaDependent[]>
|
||||
recordCountByDict: Map<string, number>
|
||||
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<ForceGraphMethods<Node3D, Link3D> | undefined>(undefined)
|
||||
const [hoverId, setHoverId] = useState<string | null>(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 (
|
||||
<div className="relative w-full h-full">
|
||||
<ForceGraph3D<Node3D, Link3D>
|
||||
@@ -121,38 +228,51 @@ export default function DictionaryGraph3D({
|
||||
height={height}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeId="id"
|
||||
nodeLabel={(n) =>
|
||||
`<div style="background:#fff;color:#1a1714;padding:6px 10px;border-radius:4px;border:1px solid ${lineHex};font:13px ui-sans-serif"><strong>${n.displayName}</strong><br/><span style="color:#666;font-size:11px">${n.bundle} · ${n.recordCount} записей</span></div>`
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user