Merge branch 'feat/graph-search' into 'main'

feat(graph): поиск по справочникам в Graph view

See merge request 2-6/2-6-4/terravault/ordinis!240
This commit is contained in:
Александр Зимин
2026-05-17 12:37:06 +00:00
2 changed files with 112 additions and 5 deletions
@@ -1,6 +1,7 @@
import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { import {
forceCenter, forceCenter,
forceCollide, forceCollide,
@@ -203,10 +204,17 @@ function GraphCanvas({
// в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено). // в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено).
const [webglFallbackNotice, setWebglFallbackNotice] = useState(false) const [webglFallbackNotice, setWebglFallbackNotice] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation()
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const [size, setSize] = useState({ width: 800, height: 600 }) const [size, setSize] = useState({ width: 800, height: 600 })
const [hoveredId, setHoveredId] = useState<string | null>(null) const [hoveredId, setHoveredId] = useState<string | null>(null)
// Поиск по графу — filter по name + displayName. Не убирает non-matching
// nodes (рвало бы force-layout), а dim'ит их как при hover-фокусе.
// Matched nodes остаются in full opacity, остальные → 0.15 (как dimmed).
// Edges: видимы только если оба endpoint в matched. Empty query → no-op.
const [query, setQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// Zoom/pan state — управляется wheel events + mouse drag на empty space. // Zoom/pan state — управляется wheel events + mouse drag на empty space.
// Transform применяется к <g> wrapper'у, эффективно scaling всех children. // Transform применяется к <g> wrapper'у, эффективно scaling всех children.
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
@@ -319,6 +327,23 @@ function GraphCanvas({
return out return out
}, [hoveredId]) }, [hoveredId])
// Search filter — case-insensitive substring match по name + displayName.
// Если query пустая → matchedIds = null (=no filter active). Иначе Set
// совпадающих node id'шек. Используется ниже в dim/highlight логике node
// и edge рендеринга. Match по обоим полям — admin может помнить либо
// technical id (antenna), либо русское название (Антенны).
const queryLower = query.trim().toLowerCase()
const matchedIds = useMemo(() => {
if (!queryLower) return null
const out = new Set<string>()
dicts.forEach((d) => {
const hay = `${d.name} ${d.displayName ?? ''}`.toLowerCase()
if (hay.includes(queryLower)) out.add(d.name)
})
return out
}, [queryLower, dicts])
const matchedCount = matchedIds?.size ?? null
// Космос — фон graph container'а: глубокий радиальный градиент от deep // Космос — фон graph container'а: глубокий радиальный градиент от deep
// navy в центре к чёрному по краям + starfield 120 точек поверх. Stars // navy в центре к чёрному по краям + starfield 120 точек поверх. Stars
// мемоизированы через useMemo чтобы случайные позиции были stable между // мемоизированы через useMemo чтобы случайные позиции были stable между
@@ -367,6 +392,73 @@ function GraphCanvas({
</div> </div>
</div> </div>
)} )}
{/* Search overlay — top-left. Dark glass input + match counter.
* Esc clears, ⌘K/Ctrl+K focuses. При активном поиске non-matching
* nodes/edges dim'ятся (как при hover-фокусе), matched — full opacity.
* Force-layout остаётся untouched чтобы dictionary positions не
* прыгали при набиралении текста — только opacity меняется. */}
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1 w-[260px] max-w-[calc(100%-12rem)]">
<div className="w-full flex items-center gap-1.5 rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur px-2 py-1 shadow-lg">
<svg
aria-hidden
viewBox="0 0 16 16"
className="size-3.5 text-[#a09bc8] shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="7" cy="7" r="5" />
<path d="m13.5 13.5-3-3" />
</svg>
<input
ref={searchInputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
setQuery('')
searchInputRef.current?.blur()
}
}}
placeholder={t('graph.search.placeholder', {
defaultValue: 'Найти справочник…',
})}
aria-label={t('graph.search.placeholder', {
defaultValue: 'Найти справочник…',
})}
className="flex-1 min-w-0 bg-transparent text-cell text-[#f0eaff] placeholder:text-[#7a76b8] focus:outline-none"
/>
{query && (
<button
type="button"
onClick={() => {
setQuery('')
searchInputRef.current?.focus()
}}
aria-label={t('common.clear', { defaultValue: 'Очистить' })}
className="size-5 inline-flex items-center justify-center text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/60 rounded-sm transition-colors shrink-0"
>
</button>
)}
</div>
{matchedCount !== null && (
<div className="px-2 py-0.5 rounded-sm bg-[#1c1840]/70 backdrop-blur border border-[#3d3863]/60 text-cap font-mono uppercase tracking-wider text-[#a09bc8] shadow-sm">
{matchedCount === 0
? t('graph.search.empty', { defaultValue: 'ничего не найдено' })
: t('graph.search.count', {
count: matchedCount,
total: dicts.length,
defaultValue: '{{count}} из {{total}}',
})}
</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 * Космос-стиль: dark glass panels с violet-tint border, accent (warm
* orange) для active state. */} * orange) для active state. */}
@@ -677,10 +769,14 @@ function GraphCanvas({
const s = l.source as GraphNode const s = l.source as GraphNode
const t = l.target as GraphNode const t = l.target as GraphNode
if (!s.x || !s.y || !t.x || !t.y) return null if (!s.x || !s.y || !t.x || !t.y) return null
// При активном поиске edges светятся только если оба конца — match.
// Иначе dim даже если hover показал бы их (search dominates).
const bothMatched =
matchedIds !== null && matchedIds.has(s.id) && matchedIds.has(t.id)
const searchDimmed = matchedIds !== null && !bothMatched
const highlighted = const highlighted =
hoveredId && (hoveredId && (s.id === hoveredId || t.id === hoveredId)) || bothMatched
(s.id === hoveredId || t.id === hoveredId) const dimmed = searchDimmed || (hoveredId && !highlighted)
const dimmed = hoveredId && !highlighted
return ( return (
<line <line
key={i} key={i}
@@ -712,8 +808,13 @@ function GraphCanvas({
* слева | record count справа. Раньше title и count были в одном * слева | record count справа. Раньше title и count были в одном
* row → длинные имена дрезались за count и за edge карточки. */} * row → длинные имена дрезались за count и за edge карточки. */}
{nodesRef.current.map((n) => { {nodesRef.current.map((n) => {
const dim = hoveredId && !connectedIds.has(n.id) // Dim приоритет: search > hover. Search active → matched=full opacity,
const focused = hoveredId === n.id // прочие dim. Hover при search active работает только в пределах
// matched set'а (можно hover'нуть matched node для подсветки связей).
const searchDimmed = matchedIds !== null && !matchedIds.has(n.id)
const hoverDimmed = !searchDimmed && hoveredId !== null && !connectedIds.has(n.id)
const dim = searchDimmed || hoverDimmed
const focused = hoveredId === n.id || (matchedIds !== null && matchedIds.has(n.id) && matchedIds.size === 1)
// Card 200×56 — расширил с 180 чтобы reserve area для самой // Card 200×56 — расширил с 180 чтобы reserve area для самой
// большой планеты (orbR_max=18) не съедала text content. // большой планеты (orbR_max=18) не съедала text content.
const W = 200 const W = 200
+6
View File
@@ -42,6 +42,9 @@ i18n
'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.', 'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.',
'graph.comingSoon.title': 'Скоро', 'graph.comingSoon.title': 'Скоро',
'graph.comingSoon.description': 'Visualization через d3-force — 40+ справочников с linked edges. В разработке (Stage 3.3 redesign roadmap).', 'graph.comingSoon.description': 'Visualization через d3-force — 40+ справочников с linked edges. В разработке (Stage 3.3 redesign roadmap).',
'graph.search.placeholder': 'Найти справочник…',
'graph.search.empty': 'ничего не найдено',
'graph.search.count': '{{count}} из {{total}}',
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.', 'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
'updateBanner.messageGeneric': 'Доступна новая версия НСИ. Обновите страницу, чтобы получить последние исправления.', 'updateBanner.messageGeneric': 'Доступна новая версия НСИ. Обновите страницу, чтобы получить последние исправления.',
'updateBanner.reload': 'Обновить', 'updateBanner.reload': 'Обновить',
@@ -919,6 +922,9 @@ i18n
'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.', 'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.',
'graph.comingSoon.title': 'Coming soon', 'graph.comingSoon.title': 'Coming soon',
'graph.comingSoon.description': 'd3-force visualization of 40+ dictionaries with linked edges. WIP (Stage 3.3 redesign roadmap).', 'graph.comingSoon.description': 'd3-force visualization of 40+ dictionaries with linked edges. WIP (Stage 3.3 redesign roadmap).',
'graph.search.placeholder': 'Find dictionary…',
'graph.search.empty': 'no matches',
'graph.search.count': '{{count}} of {{total}}',
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.', 'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
'updateBanner.messageGeneric': 'A new NSI version is available. Reload to get the latest fixes.', 'updateBanner.messageGeneric': 'A new NSI version is available. Reload to get the latest fixes.',
'updateBanner.reload': 'Reload', 'updateBanner.reload': 'Reload',