feat(graph): space-theme polish — planets, sprite labels, thematic edges, sizes, loader

This commit is contained in:
Александр Зимин
2026-05-11 22:50:33 +00:00
parent 1178317878
commit 9fd63ab267
4 changed files with 558 additions and 114 deletions
@@ -11,9 +11,9 @@ import {
type SimulationNodeDatum, type SimulationNodeDatum,
type SimulationLinkDatum, type SimulationLinkDatum,
} from 'd3-force' } from 'd3-force'
import { useDictionaries, dictionaryDependentsQuery } from '@/api/queries' import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client' import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { LoadingBlock } from '@/ui' import { GlobeLoader, LoadingBlock } from '@/ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не // 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
@@ -102,9 +102,9 @@ const BUNDLE_COLORS: Record<string, string> = {
default: '#52483d', // ink-2 default: '#52483d', // ink-2
} }
function colorForBundle(bundle: string): string { // colorForBundle helper больше не используется — карточки рендерят
return BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default // planet-orbs через radial gradients (defs id=planet-{bundle}), а легенда
} // читает BUNDLE_COLORS напрямую через Object.entries.
export function DictionaryGraph() { export function DictionaryGraph() {
const dictsQuery = useDictionaries() const dictsQuery = useDictionaries()
@@ -136,7 +136,29 @@ export function DictionaryGraph() {
return m return m
}, [dicts, dependentsResults]) }, [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 allDependentsLoading = dependentsResults.some((r) => r.isLoading)
const allRecordsLoading = recordsResults.some((r) => r.isLoading)
if (dictsQuery.isLoading) { if (dictsQuery.isLoading) {
return <LoadingBlock size="lg" label="Загрузка справочников…" /> return <LoadingBlock size="lg" label="Загрузка справочников…" />
@@ -154,7 +176,8 @@ export function DictionaryGraph() {
<GraphCanvas <GraphCanvas
dicts={dicts} dicts={dicts}
dependentsByDict={dependentsByDict} dependentsByDict={dependentsByDict}
depsLoading={allDependentsLoading} recordCountByDict={recordCountByDict}
depsLoading={allDependentsLoading || allRecordsLoading}
mode={mode} mode={mode}
onModeChange={setMode} onModeChange={setMode}
/> />
@@ -164,12 +187,14 @@ export function DictionaryGraph() {
function GraphCanvas({ function GraphCanvas({
dicts, dicts,
dependentsByDict, dependentsByDict,
recordCountByDict,
depsLoading, depsLoading,
mode, mode,
onModeChange, onModeChange,
}: { }: {
dicts: DictionaryDefinition[] dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]> dependentsByDict: Map<string, SchemaDependent[]>
recordCountByDict: Map<string, number>
depsLoading: boolean depsLoading: boolean
mode: GraphMode mode: GraphMode
onModeChange: (m: GraphMode) => void onModeChange: (m: GraphMode) => void
@@ -195,7 +220,9 @@ function GraphCanvas({
const nodesRef = useRef<GraphNode[]>([]) const nodesRef = useRef<GraphNode[]>([])
const linksRef = useRef<GraphLink[]>([]) 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, links } = useMemo(() => {
const nodes: GraphNode[] = dicts.map((d) => ({ const nodes: GraphNode[] = dicts.map((d) => ({
id: d.name, id: d.name,
@@ -203,7 +230,7 @@ function GraphCanvas({
displayName: d.displayName ?? d.name, displayName: d.displayName ?? d.name,
bundle: d.bundle, bundle: d.bundle,
scope: d.scope, scope: d.scope,
recordCount: d.recordCount ?? 0, recordCount: recordCountByDict.get(d.name) ?? d.recordCount ?? 0,
})) }))
const links: GraphLink[] = [] const links: GraphLink[] = []
@@ -223,7 +250,7 @@ function GraphCanvas({
}) })
return { nodes, links } return { nodes, links }
}, [dicts, dependentsByDict]) }, [dicts, dependentsByDict, recordCountByDict])
// Responsive sizing. // Responsive sizing.
useEffect(() => { useEffect(() => {
@@ -245,17 +272,23 @@ function GraphCanvas({
nodesRef.current = nodes.map((n) => ({ ...n })) nodesRef.current = nodes.map((n) => ({ ...n }))
linksRef.current = links.map((l) => ({ ...l })) 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) const sim = forceSimulation<GraphNode>(nodesRef.current)
.force( .force(
'link', 'link',
forceLink<GraphNode, GraphLink>(linksRef.current) forceLink<GraphNode, GraphLink>(linksRef.current)
.id((d) => d.id) .id((d) => d.id)
.distance(90) .distance(180)
.strength(0.4), .strength(0.4),
) )
.force('charge', forceManyBody().strength(-280)) .force('charge', forceManyBody().strength(-540))
.force('center', forceCenter(size.width / 2, size.height / 2)) .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) .alpha(1)
.alphaDecay(0.025) .alphaDecay(0.025)
.on('tick', () => setTick((t) => (t + 1) % 1_000_000)) .on('tick', () => setTick((t) => (t + 1) % 1_000_000))
@@ -286,17 +319,60 @@ function GraphCanvas({
return out return out
}, [hoveredId]) }, [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 ( 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 && ( {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> </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"> <div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-1">
{/* 2D / 3D mode toggle */} {/* 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 <button
type="button" type="button"
onClick={() => onModeChange('2d')} onClick={() => onModeChange('2d')}
@@ -305,8 +381,8 @@ function GraphCanvas({
className={cn( className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors', 'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '2d' mode === '2d'
? 'bg-accent text-white' ? 'bg-[#ff9a64] text-[#1c1840]'
: 'text-mute hover:text-ink hover:bg-surface-2', : 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
)} )}
> >
2D 2D
@@ -319,8 +395,8 @@ function GraphCanvas({
className={cn( className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors', 'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '3d' mode === '3d'
? 'bg-accent text-white' ? 'bg-[#ff9a64] text-[#1c1840]'
: 'text-mute hover:text-ink hover:bg-surface-2', : 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
)} )}
> >
3D 3D
@@ -329,7 +405,7 @@ function GraphCanvas({
{/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */} {/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */}
{mode === '2d' && ( {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 <button
type="button" type="button"
onClick={() => onClick={() =>
@@ -337,7 +413,7 @@ function GraphCanvas({
} }
aria-label="Zoom in" aria-label="Zoom in"
title="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> </button>
@@ -348,7 +424,7 @@ function GraphCanvas({
} }
aria-label="Zoom out" aria-label="Zoom out"
title="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> </button>
@@ -357,11 +433,11 @@ function GraphCanvas({
onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })} onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
aria-label="Reset view" aria-label="Reset view"
title="Reset (1:1)" 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 1:1
</button> </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)}% {Math.round(view.zoom * 100)}%
</span> </span>
</div> </div>
@@ -381,8 +457,8 @@ function GraphCanvas({
fallback={ fallback={
<div className="absolute inset-0 flex items-center justify-center p-6"> <div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<p className="text-title-md text-ink mb-2">3D-режим недоступен</p> <p className="text-title-md text-[#f0eaff] mb-2">3D-режим недоступен</p>
<p className="text-cell text-mute"> <p className="text-cell text-[#a09bc8]">
Браузер не смог инициализировать WebGL. Возможно отключено Браузер не смог инициализировать WebGL. Возможно отключено
аппаратное ускорение (chrome://gpu) или используется среда без аппаратное ускорение (chrome://gpu) или используется среда без
поддержки 3D. Возврат к 2D-режиму поддержки 3D. Возврат к 2D-режиму
@@ -401,6 +477,7 @@ function GraphCanvas({
<DictionaryGraph3D <DictionaryGraph3D
dicts={dicts} dicts={dicts}
dependentsByDict={dependentsByDict} dependentsByDict={dependentsByDict}
recordCountByDict={recordCountByDict}
width={size.width} width={size.width}
height={size.height} height={size.height}
/> />
@@ -409,7 +486,7 @@ function GraphCanvas({
)} )}
{webglFallbackNotice && ( {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. 3D недоступен в этом браузере переключено на 2D.
</div> </div>
)} )}
@@ -454,15 +531,20 @@ function GraphCanvas({
} }
}} }}
onMouseMove={(e) => { onMouseMove={(e) => {
if (panStartRef.current) { // Захватываем start snapshot СИНХРОННО — setView updater запускается
const dx = e.clientX - panStartRef.current.x // асинхронно, и к тому моменту panStartRef.current может стать null
const dy = e.clientY - panStartRef.current.y // (mouseup/mouseleave). Раньше `panStartRef.current!.panX + dx`
setView((v) => ({ // внутри updater'а падал с "Cannot read properties of null (reading
...v, // 'panX')". Локальная const живёт в closure → стабильна.
panX: panStartRef.current!.panX + dx, const start = panStartRef.current
panY: panStartRef.current!.panY + dy, 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={() => { onMouseUp={() => {
panStartRef.current = null panStartRef.current = null
@@ -481,7 +563,7 @@ function GraphCanvas({
markerHeight="6" markerHeight="6"
orient="auto" 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>
<marker <marker
id="arrow-hi" id="arrow-hi"
@@ -492,13 +574,105 @@ function GraphCanvas({
markerHeight="7" markerHeight="7"
orient="auto" 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> </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> </defs>
{/* Starfield — рендерится первым (под всем), не пересчитывается
* при ticks simulation (useMemo seeded LCG для стабильных позиций).
* Position поверх pan/zoom group? Нет — звёзды должны оставаться
* "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ
* zoom group (двигаются вместе с graph nodes). */}
{/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */} {/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */}
<g transform={`translate(${view.panX} ${view.panY}) scale(${view.zoom})`}> <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) => { {linksRef.current.map((l, i) => {
const s = l.source as GraphNode const s = l.source as GraphNode
const t = l.target as GraphNode const t = l.target as GraphNode
@@ -506,6 +680,7 @@ function GraphCanvas({
const highlighted = const highlighted =
hoveredId && hoveredId &&
(s.id === hoveredId || t.id === hoveredId) (s.id === hoveredId || t.id === hoveredId)
const dimmed = hoveredId && !highlighted
return ( return (
<line <line
key={i} key={i}
@@ -513,61 +688,194 @@ function GraphCanvas({
y1={s.y} y1={s.y}
x2={t.x} x2={t.x}
y2={t.y} y2={t.y}
stroke={highlighted ? 'var(--color-accent)' : 'var(--color-line)'} stroke={
strokeWidth={highlighted ? 1.5 : 1} highlighted
opacity={hoveredId && !highlighted ? 0.2 : 0.65} ? '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)'} 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) => { {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 dim = hoveredId && !connectedIds.has(n.id)
const focused = hoveredId === 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 ( return (
<g <g
key={n.id} key={n.id}
transform={`translate(${n.x ?? 0},${n.y ?? 0})`} transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
className="cursor-pointer" className="cursor-pointer"
opacity={dim ? 0.3 : 1} opacity={dim ? 0.25 : 1}
onMouseEnter={() => setHoveredId(n.id)} onMouseEnter={() => setHoveredId(n.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
> >
<circle {/* Card body — dark glass с violet-ish border, focused → accent border + glow */}
r={r} <rect
fill={colorForBundle(n.bundle)} x={-halfW}
stroke={focused ? 'var(--color-accent)' : 'var(--color-surface)'} y={-halfH}
strokeWidth={focused ? 3 : 2} 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 */} {/* Planet orb с glow filter + radial gradient (lit top-left).
<text * Размер orbR пропорционален recordCount (sqrt scale, 7-18px),
y={r + 12} * specular highlight тоже шкалируется чтобы выглядеть как
textAnchor="middle" * планета а не точка. */}
fontSize={10} <g filter="url(#planet-glow)">
fontFamily="ui-sans-serif,system-ui,sans-serif" <circle
fill="var(--color-ink)" cx={orbCx}
className={cn(focused && 'font-semibold')} 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} <div
</text> 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>
) )
})} })}
</g> </g>
{/* === end zoom/pan transform === */} {/* === end zoom/pan transform === */}
</svg>} </svg>}
{/* Legend */} {/* Legend — space-themed panel с planet-orb swatches */}
<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="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-mute mb-0.5">Bundle</div> <div className="font-display uppercase tracking-wider text-[#a09bc8] mb-0.5">Bundle</div>
{Object.entries(BUNDLE_COLORS) {Object.entries(BUNDLE_COLORS)
.filter(([k]) => k !== 'default') .filter(([k]) => k !== 'default')
.map(([bundle, color]) => ( .map(([bundle, color]) => (
<div key={bundle} className="flex items-center gap-1.5"> <div key={bundle} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: color }} /> <span
<span className="text-ink-2 font-mono">{bundle}</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>
))} ))}
</div> </div>
@@ -1,25 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import ForceGraph3D, { type ForceGraphMethods } from 'react-force-graph-3d' import ForceGraph3D, { type ForceGraphMethods } from 'react-force-graph-3d'
import { CanvasTexture, Sprite, SpriteMaterial } from 'three'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client' 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 только когда * - Сферы с bundle цветами (terracotta / green / gold / brown / mute)
* пользователь явно переключает в 3D режим. * - Hovered planet → accent (warm orange) glow
* * - Label-sprite (canvas-rendered text) над каждой планетой: displayName
* Поведение: * + record count. Sprite автоматически billboard-ит к камере.
* - Те же nodes/links что в 2D-моде (общая data shape) * - Edges: subtle violet stardust → bright accent при hover, directional
* - Camera orbit controls (drag = rotate, wheel = zoom) * particles на hovered chain (comet-trail feel).
* - Hover → tooltip с displayName + record count * - Background: transparent (container's radial gradient просвечивает).
* - 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.
*/ */
type Node3D = { type Node3D = {
@@ -43,19 +40,135 @@ type Link3D = {
} }
const BUNDLE_COLORS: Record<string, string> = { const BUNDLE_COLORS: Record<string, string> = {
cuod: '#b05a2e', cuod: '#d97757',
geo: '#4f7038', geo: '#5b8044',
comms: '#a8762a', comms: '#b8902f',
delivery: '#3a2818', delivery: '#7a5644',
default: '#52483d', default: '#7a76b8',
} }
const colorForBundle = (bundle: string): string => const colorForBundle = (bundle: string): string =>
BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default 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 = { type Props = {
dicts: DictionaryDefinition[] dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]> dependentsByDict: Map<string, SchemaDependent[]>
recordCountByDict: Map<string, number>
width: number width: number
height: number height: number
} }
@@ -63,6 +176,7 @@ type Props = {
export default function DictionaryGraph3D({ export default function DictionaryGraph3D({
dicts, dicts,
dependentsByDict, dependentsByDict,
recordCountByDict,
width, width,
height, height,
}: Props) { }: Props) {
@@ -70,8 +184,7 @@ export default function DictionaryGraph3D({
const graphRef = useRef<ForceGraphMethods<Node3D, Link3D> | undefined>(undefined) const graphRef = useRef<ForceGraphMethods<Node3D, Link3D> | undefined>(undefined)
const [hoverId, setHoverId] = useState<string | null>(null) const [hoverId, setHoverId] = useState<string | null>(null)
// Build graph data — same logic as 2D mode, без simulation refs (three // Build graph data — same logic as 2D mode.
// worker handles physics).
const graphData = useMemo(() => { const graphData = useMemo(() => {
const nodes: Node3D[] = dicts.map((d) => ({ const nodes: Node3D[] = dicts.map((d) => ({
id: d.name, id: d.name,
@@ -79,7 +192,7 @@ export default function DictionaryGraph3D({
displayName: d.displayName ?? d.name, displayName: d.displayName ?? d.name,
bundle: d.bundle, bundle: d.bundle,
scope: d.scope, 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 nodeIds = new Set(nodes.map((n) => n.id))
const links: Link3D[] = [] const links: Link3D[] = []
@@ -97,21 +210,15 @@ export default function DictionaryGraph3D({
}) })
}) })
return { nodes, links } return { nodes, links }
}, [dicts, dependentsByDict]) }, [dicts, dependentsByDict, recordCountByDict])
// Tune camera + force layout once after mount. // Tune camera once after mount.
useEffect(() => { useEffect(() => {
const fg = graphRef.current const fg = graphRef.current
if (!fg) return if (!fg) return
// Подальше отвести камеру для просторного 3D-обзора. fg.cameraPosition({ x: 0, y: 0, z: 500 })
fg.cameraPosition({ x: 0, y: 0, z: 400 })
}, []) }, [])
// CSS variable resolution — three не понимает var(--color-*),
// grab raw hex values from document root для accent + ink.
const accentHex = '#b05a2e'
const lineHex = '#e6e1d4'
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<ForceGraph3D<Node3D, Link3D> <ForceGraph3D<Node3D, Link3D>
@@ -121,38 +228,51 @@ export default function DictionaryGraph3D({
height={height} height={height}
backgroundColor="rgba(0,0,0,0)" backgroundColor="rgba(0,0,0,0)"
nodeId="id" nodeId="id"
nodeLabel={(n) => // Tooltip отключаем — sprite уже показывает inline label с тем же
`<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>` // contentом, native tooltip был бы дублёром.
} nodeLabel={() => ''}
nodeColor={(n) => (hoverId === n.id ? accentHex : colorForBundle(n.bundle))} nodeColor={(n) => (hoverId === n.id ? ACCENT_HEX : colorForBundle(n.bundle))}
nodeVal={(n) => 1 + Math.log10((n.recordCount ?? 1) + 1) * 2} // 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} 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) => { linkColor={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id 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 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) => { linkWidth={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id 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 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} linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1} linkDirectionalArrowRelPos={1}
linkDirectionalArrowColor={() => accentHex} linkDirectionalArrowColor={() => ACCENT_HEX}
linkDirectionalParticles={(l) => { linkDirectionalParticles={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id 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 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} linkDirectionalParticleSpeed={0.006}
linkDirectionalParticleWidth={2} linkDirectionalParticleWidth={2.5}
linkDirectionalParticleColor={() => ACCENT_HEX}
onNodeHover={(n) => setHoverId(n?.id ?? null)} onNodeHover={(n) => setHoverId(n?.id ?? null)}
onNodeClick={(n) => navigate({ to: '/dictionaries/$name', params: { name: n.name } })} onNodeClick={(n) => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
// Slight cooldown extension чтобы layout успел осесть. cooldownTicks={150}
cooldownTicks={120}
/> />
</div> </div>
) )
+16
View File
@@ -423,3 +423,19 @@ body {
--color-regolith: var(--color-line); --color-regolith: var(--color-line);
--color-asteroid: var(--color-mute); --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;
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ export default defineConfig({
// wrappers), грузится только при переключении в 3D режим. // wrappers), грузится только при переключении в 3D режим.
if ( if (
id.match( 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' return 'vendor-graph3d'