diff --git a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx index 7fe411b..13584cb 100644 --- a/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx +++ b/ordinis-admin-ui/src/components/graph/DictionaryGraph.tsx @@ -1,6 +1,7 @@ import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useNavigate } from '@tanstack/react-router' import { useQueries } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' import { forceCenter, forceCollide, @@ -203,10 +204,17 @@ function GraphCanvas({ // в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено). const [webglFallbackNotice, setWebglFallbackNotice] = useState(false) const navigate = useNavigate() + const { t } = useTranslation() const wrapperRef = useRef(null) const svgRef = useRef(null) const [size, setSize] = useState({ width: 800, height: 600 }) const [hoveredId, setHoveredId] = useState(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(null) // Zoom/pan state — управляется wheel events + mouse drag на empty space. // Transform применяется к wrapper'у, эффективно scaling всех children. const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) @@ -319,6 +327,23 @@ function GraphCanvas({ return out }, [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() + 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 // navy в центре к чёрному по краям + starfield 120 точек поверх. Stars // мемоизированы через useMemo чтобы случайные позиции были stable между @@ -367,6 +392,73 @@ function GraphCanvas({ )} + {/* 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 меняется. */} +
+
+ + + + + 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 && ( + + )} +
+ {matchedCount !== null && ( +
+ {matchedCount === 0 + ? t('graph.search.empty', { defaultValue: 'ничего не найдено' }) + : t('graph.search.count', { + count: matchedCount, + total: dicts.length, + defaultValue: '{{count}} из {{total}}', + })} +
+ )} +
+ {/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D). * Космос-стиль: dark glass panels с violet-tint border, accent (warm * orange) для active state. */} @@ -677,10 +769,14 @@ function GraphCanvas({ const s = l.source as GraphNode const t = l.target as GraphNode 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 = - hoveredId && - (s.id === hoveredId || t.id === hoveredId) - const dimmed = hoveredId && !highlighted + (hoveredId && (s.id === hoveredId || t.id === hoveredId)) || bothMatched + const dimmed = searchDimmed || (hoveredId && !highlighted) return ( { - const dim = hoveredId && !connectedIds.has(n.id) - const focused = hoveredId === n.id + // Dim приоритет: search > hover. Search active → matched=full opacity, + // прочие 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 для самой // большой планеты (orbR_max=18) не съедала text content. const W = 200 diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index b5c0ccf..44a1d0a 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -42,6 +42,9 @@ i18n 'graph.description': 'Сетевая диаграмма всех справочников и их FK-связей. Кликните по узлу — откроется детальный вид.', 'graph.comingSoon.title': 'Скоро', '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.messageGeneric': 'Доступна новая версия НСИ. Обновите страницу, чтобы получить последние исправления.', 'updateBanner.reload': 'Обновить', @@ -919,6 +922,9 @@ i18n 'graph.description': 'Network diagram of all dictionaries and their FK links. Click a node for detail.', 'graph.comingSoon.title': 'Coming soon', '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.messageGeneric': 'A new NSI version is available. Reload to get the latest fixes.', 'updateBanner.reload': 'Reload',