feat(map): add universal subject and station search

This commit is contained in:
Codex
2026-07-25 15:37:53 +03:00
parent eaecce9f56
commit a8c8bfd89a
13 changed files with 819 additions and 33 deletions
+162 -6
View File
@@ -1,4 +1,4 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
@@ -38,7 +38,8 @@ import {
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { useMapReferenceRuntime } from "./useMapReferenceRuntime.js";
import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js";
import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -376,6 +377,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [layersWindowActive, setLayersWindowActive] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [remoteSearchQuery, setRemoteSearchQuery] = useState("");
const [remoteSearchEpoch, setRemoteSearchEpoch] = useState(0);
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const searchInputRef = useRef<HTMLInputElement>(null);
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
...initialMapSettings,
@@ -434,6 +441,16 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
enabled: Boolean(applicationId && pageId),
});
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
const {
bindings: referenceSearchBindings,
state: referenceSearchState,
} = useMapReferenceSearch(referenceLayers, remoteSearchQuery, remoteSearchEpoch, searchOpen);
const mapRuntimeBindings = useMemo(() => (
[...runtimeBindings, ...referenceRuntimeBindings]
), [referenceRuntimeBindings, runtimeBindings]);
const mapSearchRuntimeBindings = useMemo(() => (
[...mapRuntimeBindings, ...referenceSearchBindings]
), [mapRuntimeBindings, referenceSearchBindings]);
const referencePresentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
), [referenceLayers]);
@@ -447,6 +464,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const primaryRuntimeBindings = useMemo(() => (
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
), [primaryBindingIds, runtimeBindings]);
const mapSearchIndex = useMemo(() => buildMapSearchIndex({
runtimeBindings: mapSearchRuntimeBindings,
bindingConfigs: dataProductBindings,
presentationProfiles,
}), [dataProductBindings, mapSearchRuntimeBindings, presentationProfiles]);
const mapSearchResults = useMemo(() => (
searchMapSubjects(mapSearchIndex, searchQuery, 8)
), [mapSearchIndex, searchQuery]);
const selectable = useMemo(() => (
primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
@@ -873,6 +898,71 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
mapRendererRef.current?.focusRuntimeEntity(entityId);
}, [handleSelect]);
const handleSearchResult = useCallback((result: (typeof mapSearchResults)[number]) => {
if (result.selectable) {
setSubjectStates((current) => {
const state = current[result.bindingId];
return state ? { ...current, [result.bindingId]: { ...state, visible: true } } : current;
});
handleSelect(result.entityId);
} else {
setReferenceLayers((current) => current.map((layer) => (
layer.id === result.bindingId ? { ...layer, visible: true } : layer
)));
}
if (!mapRendererRef.current?.focusRuntimeEntity(result.entityId)) {
mapRendererRef.current?.focusCoordinates(result.coordinates[0], result.coordinates[1]);
}
setSearchOpen(false);
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
}, [handleSelect, mapSearchResults]);
const handleSearchKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault();
setSearchOpen(false);
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
return;
}
if (!mapSearchResults.length) {
if (event.key === "Enter" && searchQuery.trim().length >= 2) {
event.preventDefault();
setRemoteSearchQuery(searchQuery.trim());
setRemoteSearchEpoch((current) => current + 1);
}
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSearchActiveIndex((current) => (current + 1) % mapSearchResults.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSearchActiveIndex((current) => (current - 1 + mapSearchResults.length) % mapSearchResults.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const result = mapSearchResults[Math.min(searchActiveIndex, mapSearchResults.length - 1)];
if (result) handleSearchResult(result);
}
}, [handleSearchResult, mapSearchResults, searchActiveIndex, searchQuery]);
useEffect(() => {
if (!searchOpen) return;
const frame = window.requestAnimationFrame(() => searchInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [searchOpen]);
useEffect(() => {
setSearchActiveIndex(0);
}, [searchQuery]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health;
setGatewayHealth(health);
@@ -983,8 +1073,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const liveCacheSummary = liveCacheStatus
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
: "индекс ещё не получен";
const transportDiagnostic = gatewayHealth?.diagnostics?.lastFailure
? `Последняя transport-ошибка: ${gatewayHealth.diagnostics.lastFailure}${gatewayHealth.diagnostics.lastFailureAt ? ` · ${new Date(gatewayHealth.diagnostics.lastFailureAt).toLocaleTimeString()}` : ""}`
const transportReferenceStatus = gatewayHealth?.referenceSources?.transportStations;
const transportDiagnostic = transportReferenceStatus?.lastFailure
? `Последняя ошибка справочных станций: ${transportReferenceStatus.lastFailure}${transportReferenceStatus.lastFailureAt ? ` · ${new Date(transportReferenceStatus.lastFailureAt).toLocaleTimeString()}` : ""}`
: null;
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
@@ -1354,7 +1445,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
) : null}
{toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
<Dropdown
placement="top-start"
width={320}
@@ -1429,7 +1520,72 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
)}
</Dropdown>
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
<IconButton label="Поиск"><Icon name="search" /></IconButton>
<IconButton
label={searchOpen ? "Закрыть поиск" : "Поиск"}
aria-expanded={searchOpen}
aria-controls="map-subject-search"
data-active={searchOpen || undefined}
onClick={() => {
setSearchOpen((current) => !current);
if (searchOpen) {
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
}
}}
><Icon name="search" /></IconButton>
<div className="catalog-map-search" data-open={searchOpen || undefined}>
<label className="catalog-map-search__field" htmlFor="map-subject-search">
<Icon name="search" />
<input
ref={searchInputRef}
id="map-subject-search"
type="search"
value={searchQuery}
autoComplete="off"
spellCheck={false}
placeholder="Название, ID объекта или трекера"
aria-label="Поиск объектов карты"
aria-controls="map-subject-search-results"
aria-activedescendant={mapSearchResults.length ? `map-subject-search-result-${searchActiveIndex}` : undefined}
onChange={(event) => {
setSearchQuery(event.target.value);
setRemoteSearchQuery("");
}}
onKeyDown={handleSearchKeyDown}
/>
</label>
{searchQuery.trim() ? (
<div id="map-subject-search-results" className="catalog-map-search__results nodedc-map-glass" role="listbox" aria-label="Результаты поиска">
{mapSearchResults.map((result, index) => (
<button
key={`${result.bindingId}:${result.sourceId}`}
id={`map-subject-search-result-${index}`}
type="button"
role="option"
aria-selected={index === searchActiveIndex}
data-active={index === searchActiveIndex || undefined}
onPointerEnter={() => setSearchActiveIndex(index)}
onClick={() => handleSearchResult(result)}
>
<span>{result.title}</span>
<small>{result.groupTitle}</small>
</button>
))}
{!mapSearchResults.length ? (
<small className="catalog-map-search__empty">
{remoteSearchQuery === searchQuery.trim()
? (referenceSearchState === "loading"
? "Ищем станцию в OSM…"
: referenceSearchState === "error"
? "Поиск OSM временно недоступен; локальные данные сохранены."
: "Станции с таким точным названием не найдены.")
: "Совпадений нет. Enter — найти станцию по точному названию в OSM."}
</small>
) : null}
</div>
) : null}
</div>
</div>
) : null}