Merge branch 'feat/graph-space-polish-v2' into 'main'
feat(graph): space-theme polish — planets, sprite labels, thematic edges, sizes, loader See merge request 2-6/2-6-4/terravault/ordinis!112
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user