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:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user