From d7de1eb7404f3d51c61a01724a37a32a3e108203 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sun, 17 May 2026 15:36:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(graph):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=20=D0=B2=20Graph=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-left dark-glass input на странице /graph. Substring match case-insensitive по name + displayName (admin помнит либо technical id `antenna`, либо русское название `Антенны`). UX: - Matched nodes остаются в full opacity, остальные dim'ятся (0.25) — как при hover-focus. Force-layout не трогаем (positions стабильны). - При единственном match → matched node получает focused border (orange accent + glow) — легко найти результат. - Edges светятся только когда оба конца — match. Иначе dim. - Counter chip `N из M` под input'ом + `ничего не найдено` для empty. - Esc clears query и снимает focus. ✕-кнопка тоже clears (focus остаётся на input). - Search dominates над hover dim — non-matched nodes остаются dim даже если hover их затрагивает. i18n: graph.search.placeholder / .empty / .count для ru-RU и en-US. Force-simulation не пересчитываем при изменении query — это намеренно. Иначе nodes прыгали бы по экрану на каждый символ, ломая spatial recognition (юзер уже привык где какой node лежит). --- .../src/components/graph/DictionaryGraph.tsx | 111 +++++++++++++++++- ordinis-admin-ui/src/i18n.ts | 6 + 2 files changed, 112 insertions(+), 5 deletions(-) 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',