feat(map): add universal subject and station search
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user