From a8c8bfd89aa8bfe56726a2871099df4ec4d4a2a4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 25 Jul 2026 15:37:53 +0300 Subject: [PATCH] feat(map): add universal subject and station search --- apps/catalog/src/CesiumMapRenderer.tsx | 60 ++++-- .../src/FoundryMapProviderSettings.tsx | 14 ++ apps/catalog/src/MapFixturePreview.tsx | 168 ++++++++++++++++- apps/catalog/src/mapSearch.d.mts | 27 +++ apps/catalog/src/mapSearch.mjs | 174 ++++++++++++++++++ apps/catalog/src/styles.css | 125 +++++++++++++ apps/catalog/src/useMapReferenceRuntime.ts | 125 ++++++++++++- docs/COMPONENTS.md | 11 ++ docs/MAP_TEMPLATE.md | 38 +++- package.json | 3 +- registry/pages.json | 2 +- scripts/map-object-layers.test.mjs | 2 +- scripts/map-search.test.mjs | 103 +++++++++++ 13 files changed, 819 insertions(+), 33 deletions(-) create mode 100644 apps/catalog/src/mapSearch.d.mts create mode 100644 apps/catalog/src/mapSearch.mjs create mode 100644 scripts/map-search.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 87dbe9d..f1ef15c 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -135,6 +135,25 @@ export type MapGatewayHealth = { lastFailure?: string | null; lastFailureAt?: string | null; }; + referenceSources?: { + transportStations?: { + profileId?: string; + seedFactCount?: number; + fetchEnabled?: boolean; + cellDegrees?: number; + cachedCellCount?: number; + upstreamRequests?: number; + upstreamFailures?: number; + searchRequests?: number; + searchFailures?: number; + upstreamState?: "idle" | "ready" | "degraded"; + activeFetches?: number; + queuedFetches?: number; + lastRefreshAt?: string | null; + lastFailure?: string | null; + lastFailureAt?: string | null; + }; + }; ionConfigured?: boolean; }; @@ -239,6 +258,7 @@ export type CesiumMapRendererHandle = { getCameraView: () => MapCameraView | null; fitRuntimeEntities: (entityIds?: string[]) => boolean; focusRuntimeEntity: (entityId: string) => boolean; + focusCoordinates: (longitude: number, latitude: number) => boolean; }; type TerrainRouteSample = { @@ -1380,12 +1400,11 @@ export const CesiumMapRenderer = forwardRef { + const focusCoordinates = useCallback((longitude: number, latitude: number) => { const viewer = viewerRef.current; - if (!viewer || viewer.isDestroyed()) return false; - const entity = runtimeEntities([entityId])[0]; - const position = entity?.position?.getValue(viewer.clock.currentTime); - if (!position) return false; + if (!viewer || viewer.isDestroyed() + || !Number.isFinite(longitude) || longitude < -180 || longitude > 180 + || !Number.isFinite(latitude) || latitude < -90 || latitude > 90) return false; // Preserve the observer's current composition exactly as the proven // legacy MMAP/AIS interaction does: move the camera/viewport frame to the @@ -1396,10 +1415,9 @@ export const CesiumMapRenderer = forwardRef { + const viewer = viewerRef.current; + if (!viewer || viewer.isDestroyed()) return false; + const entity = runtimeEntities([entityId])[0]; + const position = entity?.position?.getValue(viewer.clock.currentTime); + if (!position) return false; + const cartographic = Cartographic.fromCartesian(position); + return focusCoordinates( + CesiumMath.toDegrees(cartographic.longitude), + CesiumMath.toDegrees(cartographic.latitude), + ); + }, [focusCoordinates, runtimeEntities]); useImperativeHandle(ref, () => ({ startSpiralAnimation, @@ -1436,8 +1467,9 @@ export const CesiumMapRenderer = forwardRef { const stopForPageLeave = () => stopSpiralAnimation("stopped"); diff --git a/apps/catalog/src/FoundryMapProviderSettings.tsx b/apps/catalog/src/FoundryMapProviderSettings.tsx index 429f268..bf47136 100644 --- a/apps/catalog/src/FoundryMapProviderSettings.tsx +++ b/apps/catalog/src/FoundryMapProviderSettings.tsx @@ -15,7 +15,16 @@ type MapGatewayHealth = { fetchEnabled?: boolean; cellDegrees?: number; cachedCellCount?: number; + upstreamRequests?: number; + upstreamFailures?: number; + searchRequests?: number; + searchFailures?: number; + upstreamState?: "idle" | "ready" | "degraded"; + activeFetches?: number; + queuedFetches?: number; lastRefreshAt?: string | null; + lastFailure?: string | null; + lastFailureAt?: string | null; }; }; }; @@ -125,7 +134,12 @@ export function FoundryMapProviderSettings() { {stations?.seedFactCount ?? 638} точек {stations ? `${stations.cachedCellCount ?? 0} ячеек · ${stations.cellDegrees ?? 0.5}°` : "проверяется…"} {stations?.fetchEnabled === false ? "только snapshot" : "по viewport, через Gateway"} + {stations?.upstreamState === "degraded" ? "ошибка upstream · используется кэш" : stations?.upstreamState === "ready" ? "готов" : "ожидает viewport"} + {stations ? `${stations.activeFetches ?? 0} активно · ${stations.queuedFetches ?? 0} ожидает` : "проверяется…"} + {stations ? `${stations.upstreamRequests ?? 0} · ошибок ${stations.upstreamFailures ?? 0}` : "проверяется…"} + {stations ? `${stations.searchRequests ?? 0} · ошибок ${stations.searchFailures ?? 0}` : "проверяется…"} {stations?.lastRefreshAt ? Последнее пополнение: {new Date(stations.lastRefreshAt).toLocaleString()} : null} + {stations?.lastFailure ? Последняя ошибка: {stations.lastFailure}{stations.lastFailureAt ? ` · ${new Date(stations.lastFailureAt).toLocaleString()}` : ""} : null} ); diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 5ec1573..e239705 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -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(null); const [assistantOpen, setAssistantOpen] = useState(false); const [mapSettings, setMapSettings] = useState(() => ({ ...initialMapSettings, @@ -434,6 +441,16 @@ export const MapFixturePreview = forwardRef ( + [...runtimeBindings, ...referenceRuntimeBindings] + ), [referenceRuntimeBindings, runtimeBindings]); + const mapSearchRuntimeBindings = useMemo(() => ( + [...mapRuntimeBindings, ...referenceSearchBindings] + ), [mapRuntimeBindings, referenceSearchBindings]); const referencePresentationFilters = useMemo(() => Object.fromEntries( referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]), ), [referenceLayers]); @@ -447,6 +464,14 @@ export const MapFixturePreview = forwardRef ( 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 { + 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) => { + 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 +
mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}> - + { + setSearchOpen((current) => !current); + if (searchOpen) { + setSearchQuery(""); + setRemoteSearchQuery(""); + setSearchActiveIndex(0); + } + }} + > +
+ + {searchQuery.trim() ? ( +
+ {mapSearchResults.map((result, index) => ( + + ))} + {!mapSearchResults.length ? ( + + {remoteSearchQuery === searchQuery.trim() + ? (referenceSearchState === "loading" + ? "Ищем станцию в OSM…" + : referenceSearchState === "error" + ? "Поиск OSM временно недоступен; локальные данные сохранены." + : "Станции с таким точным названием не найдены.") + : "Совпадений нет. Enter — найти станцию по точному названию в OSM."} + + ) : null} +
+ ) : null} +
) : null} diff --git a/apps/catalog/src/mapSearch.d.mts b/apps/catalog/src/mapSearch.d.mts new file mode 100644 index 0000000..869739c --- /dev/null +++ b/apps/catalog/src/mapSearch.d.mts @@ -0,0 +1,27 @@ +import type { MapDataProductBinding } from "./MapFixturePreview.js"; +import type { MapPresentationProfile } from "./mapPresentationProfile.js"; +import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js"; + +export type MapSearchDocument = { + entityId: string; + bindingId: string; + sourceId: string; + semanticType: string; + title: string; + groupTitle: string; + coordinates: [number, number]; + selectable: boolean; + searchValues: string[]; +}; + +export function buildMapSearchIndex(input?: { + runtimeBindings?: MapRuntimeBinding[]; + bindingConfigs?: MapDataProductBinding[]; + presentationProfiles?: MapPresentationProfile[]; +}): MapSearchDocument[]; + +export function searchMapSubjects( + index: MapSearchDocument[], + query: string, + limit?: number, +): MapSearchDocument[]; diff --git a/apps/catalog/src/mapSearch.mjs b/apps/catalog/src/mapSearch.mjs new file mode 100644 index 0000000..6528fb7 --- /dev/null +++ b/apps/catalog/src/mapSearch.mjs @@ -0,0 +1,174 @@ +const MAX_SEARCH_VALUES_PER_SUBJECT = 96; + +/** + * Build one provider-neutral search document per renderable map subject. + * + * Search authority is deliberately narrow: + * - stable fact identity is always searchable; + * - presentation label fields are searchable; + * - a Data Product contributes only fields already declared in its + * `fieldProjection`; + * - joined restricted aspects may augment their primary subject, but never + * become a second map entity or expose the matched value in a result row. + */ +export function buildMapSearchIndex({ + runtimeBindings = [], + bindingConfigs = [], + presentationProfiles = [], +} = {}) { + const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding])); + const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding])); + const documents = new Map(); + + for (const runtime of runtimeBindings) { + const config = configById.get(runtime.bindingId); + if (config?.joinToBindingId) continue; + for (const fact of runtime.facts ?? []) { + if (!pointCoordinates(fact.geometry)) continue; + const profile = presentationProfileFor( + presentationProfiles, + config?.presentationProfileId ?? runtime.presentationProfileId, + fact.semanticType, + ); + const label = displayLabel(fact, profile); + const fields = new Set([ + ...(profile?.label?.fields ?? []), + ...(config?.fieldProjection ?? []), + ]); + const searchValues = uniqueSearchValues([ + fact.sourceId, + label, + ...[...fields].flatMap((field) => scalarValues(fact.attributes?.[field])), + ]); + const key = subjectKey(runtime.bindingId, fact.sourceId); + documents.set(key, { + entityId: runtimeEntityId(runtime.bindingId, fact), + bindingId: runtime.bindingId, + sourceId: fact.sourceId, + semanticType: fact.semanticType, + title: label, + groupTitle: profile?.title || fact.semanticType, + coordinates: pointCoordinates(fact.geometry), + selectable: Boolean(config), + searchValues, + }); + } + } + + // Restricted/secondary aspects can add only explicitly projected scalar + // aliases to the already existing primary subject. + for (const config of bindingConfigs) { + if (!config.joinToBindingId) continue; + const runtime = runtimeById.get(config.id); + if (!runtime) continue; + for (const fact of runtime.facts ?? []) { + const document = documents.get(subjectKey(config.joinToBindingId, fact.sourceId)); + if (!document) continue; + document.searchValues = uniqueSearchValues([ + ...document.searchValues, + ...(config.fieldProjection ?? []).flatMap((field) => scalarValues(fact.attributes?.[field])), + ]); + } + } + + return [...documents.values()].sort((left, right) => ( + left.title.localeCompare(right.title, "ru") + || left.sourceId.localeCompare(right.sourceId) + )); +} + +export function searchMapSubjects(index, query, limit = 8) { + const normalizedQuery = normalizeSearchValue(query); + if (!normalizedQuery) return []; + const safeLimit = Math.max(1, Math.min(32, Number(limit) || 8)); + return index + .flatMap((document) => { + const rank = bestRank(document.searchValues, normalizedQuery); + return rank === null ? [] : [{ document, rank }]; + }) + .sort((left, right) => ( + left.rank - right.rank + || left.document.title.localeCompare(right.document.title, "ru") + || left.document.sourceId.localeCompare(right.document.sourceId) + )) + .slice(0, safeLimit) + .map(({ document }) => document); +} + +function bestRank(values, query) { + let best = null; + for (const value of values) { + const normalized = normalizeSearchValue(value); + if (!normalized) continue; + let rank = null; + if (normalized === query) rank = 0; + else if (normalized.startsWith(query)) rank = 1; + else if (normalized.split(/\s+/u).some((token) => token.startsWith(query))) rank = 2; + else if (normalized.includes(query)) rank = 3; + if (rank !== null && (best === null || rank < best)) best = rank; + } + return best; +} + +function presentationProfileFor(profiles, profileId, semanticType) { + const exact = profileId ? profiles.find((profile) => profile.id === profileId) : undefined; + if (exact?.semanticTypes?.includes(semanticType)) return exact; + return profiles.find((profile) => profile.semanticTypes?.includes(semanticType)); +} + +function displayLabel(fact, profile) { + if (profile?.label?.mode === "subject_id") return fact.sourceId; + const fields = profile?.label?.fields ?? ["display_name", "label", "name", "title"]; + for (const field of fields) { + const value = scalarValues(fact.attributes?.[field])[0]; + if (value) return value; + } + return fact.sourceId; +} + +function scalarValues(value) { + if (typeof value === "string") { + const normalized = value.trim(); + return normalized ? [normalized.slice(0, 256)] : []; + } + if (typeof value === "number" && Number.isFinite(value)) return [String(value)]; + if (typeof value === "boolean") return [value ? "true" : "false"]; + if (Array.isArray(value)) return value.slice(0, 16).flatMap(scalarValues); + return []; +} + +function uniqueSearchValues(values) { + const unique = new Map(); + for (const value of values) { + const normalized = normalizeSearchValue(value); + if (!normalized || unique.has(normalized)) continue; + unique.set(normalized, String(value).trim().slice(0, 256)); + if (unique.size >= MAX_SEARCH_VALUES_PER_SUBJECT) break; + } + return [...unique.values()]; +} + +function normalizeSearchValue(value) { + return String(value ?? "") + .normalize("NFKC") + .trim() + .toLocaleLowerCase("ru") + .replace(/\s+/gu, " "); +} + +function pointCoordinates(geometry) { + if (geometry?.type !== "Point" || !Array.isArray(geometry.coordinates) || geometry.coordinates.length !== 2) return null; + const [longitude, latitude] = geometry.coordinates.map(Number); + return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180 + && Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 + ? [longitude, latitude] + : null; +} + +function subjectKey(bindingId, sourceId) { + return `${bindingId}\u0000${sourceId}`; +} + +function runtimeEntityId(bindingId, fact) { + return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`; +} diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index addd3b3..084f3f8 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -703,6 +703,7 @@ textarea { bottom: 1rem; left: 50%; display: flex; + width: 10.8rem; gap: 0.35rem; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-map-glass-bg); @@ -710,6 +711,130 @@ textarea { translate: -50% 0; backdrop-filter: blur(var(--nodedc-blur-control)); box-shadow: var(--nodedc-glass-dropdown-shadow); + transition: width 180ms ease; +} + +.catalog-map-fixture__toolbar[data-search-open] { + width: min(42rem, calc(100% - 2rem)); +} + +.catalog-map-search { + position: relative; + min-width: 0; + flex: 0 1 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: flex-basis 180ms ease, opacity 120ms ease; +} + +.catalog-map-search[data-open] { + flex-basis: 28rem; + overflow: visible; + opacity: 1; + pointer-events: auto; +} + +.catalog-map-search__field { + height: 100%; + min-height: var(--nodedc-control-height); + display: flex; + align-items: center; + gap: 0.55rem; + border-radius: var(--nodedc-radius-circle); + background: rgb(255 255 255 / 0.84); + padding: 0 0.82rem; + color: var(--nodedc-map-glass-text); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.34); +} + +.catalog-map-search__field .nodedc-icon { + flex: 0 0 auto; +} + +.catalog-map-search__field input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--nodedc-font-size-sm); +} + +.catalog-map-search__field input::placeholder { + color: var(--nodedc-map-glass-text-muted); +} + +.catalog-map-search__field input::-webkit-search-cancel-button { + cursor: pointer; +} + +.catalog-map-search__results { + position: absolute; + right: 0; + bottom: calc(100% + 0.65rem); + width: 100%; + max-height: min(44vh, 24rem); + display: grid; + gap: 0.2rem; + overflow: auto; + border-radius: 1rem; + padding: 0.42rem; + box-shadow: var(--nodedc-glass-dropdown-shadow); +} + +.catalog-map-search__results > button { + min-width: 0; + display: grid; + gap: 0.12rem; + border: 0; + border-radius: 0.72rem; + background: transparent; + padding: 0.62rem 0.7rem; + color: var(--nodedc-map-glass-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.catalog-map-search__results > button:hover, +.catalog-map-search__results > button[data-active] { + background: rgb(255 255 255 / 0.2); +} + +.catalog-map-search__results > button:focus-visible { + outline: 2px solid var(--nodedc-map-glass-text); + outline-offset: -2px; +} + +.catalog-map-search__results > button span { + overflow: hidden; + font-size: var(--nodedc-font-size-sm); + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.catalog-map-search__results > button small, +.catalog-map-search__empty { + color: var(--nodedc-map-glass-text-muted); + font-size: var(--nodedc-font-size-xs); +} + +.catalog-map-search__empty { + padding: 0.72rem; +} + +@media (max-width: 640px) { + .catalog-map-fixture__toolbar[data-search-open] { + width: calc(100% - 1rem); + } + + .catalog-map-search[data-open] { + flex-basis: 12rem; + } } .catalog-map-fixture__objects-menu { diff --git a/apps/catalog/src/useMapReferenceRuntime.ts b/apps/catalog/src/useMapReferenceRuntime.ts index 9ad884b..94bc122 100644 --- a/apps/catalog/src/useMapReferenceRuntime.ts +++ b/apps/catalog/src/useMapReferenceRuntime.ts @@ -10,6 +10,16 @@ type ReferenceSnapshot = { schemaVersion: "nodedc.map-reference.snapshot/v1"; profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID; sourceRevision: string; + complete: boolean; + facts: MapRuntimeFact[]; +}; + +type ReferenceSearch = { + schemaVersion: "nodedc.map-reference.search/v1"; + profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID; + sourceRevision: string; + complete: boolean; + query: string; facts: MapRuntimeFact[]; }; @@ -22,6 +32,8 @@ export function useMapReferenceRuntime( enabled = true, ) { const [snapshot, setSnapshot] = useState(null); + const [requestState, setRequestState] = useState("idle"); + const [retryEpoch, setRetryEpoch] = useState(0); const signature = useMemo(() => layers.map((layer) => `${layer.id}:${layer.category}:${layer.presentationProfileId}`).join("|"), [layers]); const bbox = useMemo(() => referenceBbox(camera), [camera?.height, camera?.latitude, camera?.longitude]); const bboxSignature = bbox?.join(",") ?? ""; @@ -29,11 +41,21 @@ export function useMapReferenceRuntime( useEffect(() => { if (!enabled || !layers.length) { setSnapshot(null); + setRequestState("idle"); return; } + // Above the reference-layer LOD there is nothing useful to fetch. Keep + // the last verified snapshot so a temporary zoom-out cannot blank a + // viewport that was already materialised. + if (!bbox) return; const controller = new AbortController(); + let retryTimer: number | undefined; + const scheduleRetry = () => { + retryTimer = window.setTimeout(() => setRetryEpoch((current) => current + 1), 6_000); + }; const debounce = window.setTimeout(() => { - const query = bbox ? `?bbox=${encodeURIComponent(bbox.join(","))}` : ""; + setRequestState(snapshot ? "reconnecting" : "loading"); + const query = `?bbox=${encodeURIComponent(bbox.join(","))}`; void fetch(`/api/map-gateway/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_REFERENCE_PROFILE_ID}/current${query}`, { cache: "no-store", signal: controller.signal, @@ -43,16 +65,25 @@ export function useMapReferenceRuntime( const parsed = asSnapshot(await response.json()); if (!parsed) throw new Error("map_reference_snapshot_invalid"); setSnapshot(parsed); + setRequestState(parsed.complete ? "ready" : "reconnecting"); + if (!parsed.complete) scheduleRetry(); }) .catch(() => { - if (!controller.signal.aborted) setSnapshot(null); + // Network/Gateway interruptions retain the last good materialised + // snapshot. No stale provider payload is introduced because the + // snapshot has already passed the fail-closed normalizer below. + if (!controller.signal.aborted) { + setRequestState(snapshot ? "reconnecting" : "error"); + scheduleRetry(); + } }); }, 450); return () => { window.clearTimeout(debounce); + if (retryTimer !== undefined) window.clearTimeout(retryTimer); controller.abort(); }; - }, [bboxSignature, enabled, signature]); + }, [bboxSignature, enabled, retryEpoch, signature]); return useMemo(() => layers.map((layer) => ({ bindingId: layer.id, @@ -61,8 +92,71 @@ export function useMapReferenceRuntime( presentationProfileId: layer.presentationProfileId, facts: snapshot?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [], cursor: snapshot?.sourceRevision ?? null, - state: snapshot ? "ready" : "loading", - })), [layers, snapshot]); + state: requestState === "idle" ? (snapshot ? "ready" : "loading") : requestState, + })), [layers, requestState, snapshot]); +} + +export function useMapReferenceSearch( + layers: MapReferenceLayer[], + query: string, + requestEpoch: number, + enabled = true, +) { + const [search, setSearch] = useState(null); + const [requestState, setRequestState] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const normalizedQuery = useMemo(() => query.normalize("NFKC").trim().replace(/\s+/gu, " "), [query]); + const signature = useMemo(() => layers.map((layer) => `${layer.id}:${layer.category}:${layer.presentationProfileId}`).join("|"), [layers]); + + useEffect(() => { + if (!enabled || !layers.length || normalizedQuery.length < 2) { + setSearch(null); + setRequestState("idle"); + return; + } + const controller = new AbortController(); + setSearch(null); + setRequestState("loading"); + const debounce = window.setTimeout(() => { + const params = new URLSearchParams({ q: normalizedQuery, limit: "12" }); + void fetch(`/api/map-gateway/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_REFERENCE_PROFILE_ID}/search?${params}`, { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`map_reference_search_http_${response.status}`); + const parsed = asSearch(await response.json()); + if (!parsed || parsed.query.normalize("NFKC").trim().toLocaleLowerCase("ru") + !== normalizedQuery.toLocaleLowerCase("ru")) { + throw new Error("map_reference_search_invalid"); + } + setSearch(parsed); + setRequestState(parsed.complete ? "ready" : "error"); + }) + .catch(() => { + if (!controller.signal.aborted) { + setSearch(null); + setRequestState("error"); + } + }); + }, 300); + return () => { + window.clearTimeout(debounce); + controller.abort(); + }; + }, [enabled, normalizedQuery, requestEpoch, signature]); + + return useMemo(() => ({ + bindings: layers.map((layer) => ({ + bindingId: layer.id, + dataProductId: `platform-reference.${layer.referenceProfileId}`, + slotId: "reference-points", + presentationProfileId: layer.presentationProfileId, + facts: search?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [], + cursor: search?.sourceRevision ?? null, + state: search ? "ready" : "idle", + })), + state: requestState, + }), [layers, requestState, search]); } function referenceBbox(camera: { longitude: number; latitude: number; height: number } | null) { @@ -82,12 +176,31 @@ function referenceBbox(camera: { longitude: number; latitude: number; height: nu function asSnapshot(value: unknown): ReferenceSnapshot | null { if (!isObject(value) || value.schemaVersion !== "nodedc.map-reference.snapshot/v1" || value.profileId !== TRANSPORT_STATION_REFERENCE_PROFILE_ID - || typeof value.sourceRevision !== "string" || !Array.isArray(value.facts)) return null; + || typeof value.sourceRevision !== "string" || typeof value.complete !== "boolean" + || !Array.isArray(value.facts)) return null; const facts = value.facts.map(asFact).filter((fact): fact is MapRuntimeFact => Boolean(fact)); return { schemaVersion: "nodedc.map-reference.snapshot/v1", profileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID, sourceRevision: value.sourceRevision, + complete: value.complete, + facts, + }; +} + +function asSearch(value: unknown): ReferenceSearch | null { + if (!isObject(value) || value.schemaVersion !== "nodedc.map-reference.search/v1" + || value.profileId !== TRANSPORT_STATION_REFERENCE_PROFILE_ID + || typeof value.sourceRevision !== "string" || typeof value.complete !== "boolean" + || typeof value.query !== "string" || value.query.length > 96 + || !Array.isArray(value.facts)) return null; + const facts = value.facts.map(asFact).filter((fact): fact is MapRuntimeFact => Boolean(fact)); + return { + schemaVersion: "nodedc.map-reference.search/v1", + profileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID, + sourceRevision: value.sourceRevision, + complete: value.complete, + query: value.query, facts, }; } diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 5f4b4e3..9a52872 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -104,6 +104,17 @@ slot `reference-points`. Их три presentation profiles (`Метро`, `Во `Станции РЖД`) являются живыми каноническими Inspector sections: приложение может менять высоту, размер, label и LOD, не создавая provider-specific UI. +Центральный Map Toolbar содержит template-owned универсальный поиск. В +закрытом состоянии это три круглых действия; при открытии общий pill +симметрично расширяется, действия остаются слева, а справа появляется +search-field и список результатов. Индекс строится по domain subjects, +presentation label fields и разрешённым Data Product projections. Компонент не +принимает provider payload и использует renderer entity id только как +внутренний адрес camera adapter. Локальные совпадения появляются сразу; явный +Enter при отсутствии совпадения разрешает один точный reference-name lookup +через Map Gateway, после которого камера сохраняет композицию и переносится к +контрактным координатам результата. + Приложение контролирует `rect`, `maximized`, видимость, active-state и `zIndex`. Компонент владеет pointer/keyboard-механикой перемещения и изменения размера, кнопками maximize/restore и close, а также повторно ограничивает геометрию при изменении размеров workspace через `ResizeObserver`. Опциональный `autoHeight` подгоняет высоту под живое содержимое и сжимает её обратно, не выходя за нижнюю границу workspace. Родительский bounds-контейнер должен быть позиционированным и обрезать содержимое (`position: relative; overflow: hidden`). Окно предназначено для вспомогательных камер, инструментов и сопоставляемых представлений внутри сцены. Modal workflow, подтверждение и viewport-level Inspector по-прежнему используют `Window`. diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index 2a2250a..87d999d 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -72,7 +72,30 @@ subjects. Выбор subject в списке открывает его карт композицию камеры к точке. Адаптер повторяет проверенную механику legacy MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение камеры относительно центра viewport; длительность перелёта для текущего канонического workflow — -`2.1 s`. +`0.45 s`. + +Кнопка поиска разворачивает центральный Toolbar симметрично, оставляя три +системных действия слева и открывая единое поле справа. Поиск не знает Gelios, +OSM или Cesium: он строит локальный индекс по всем подключённым map subjects. +Для primary binding индексируются стабильный `sourceId`, label fields +presentation profile и только scalar-значения из явного `fieldProjection`. +Joined aspect может добавить aliases к существующему primary subject только +из своей собственной разрешённой projection; отдельной map entity он не +создаёт. Поэтому IMEI или другой hardware identifier начинает находить объект +только после появления в авторизованной restricted projection и никогда не +показывается отдельным значением в строке результата. Raw payload, +непроецированные поля, credentials и provider endpoint индексироваться не +могут. Выбор результата переводит `map.selection` к стабильному domain subject, +а transient renderer entity id используется только внутри adapter для +анимации камеры. + +Индекс текущих Application bindings и уже материализованных reference facts +обновляется интерактивно. Если локального совпадения нет, Enter выполняет через +Map Gateway точный поиск названия станции в OSM. Это не provider-specific ветка +Foundry и не autocomplete: Gateway возвращает тот же нормализованный +`map.station|map.terminal` contract. Для удалённого результата adapter делает +перелёт по контрактным координатам; новый viewport затем штатно материализует +spatial cell. Частичные строки не создают сетевых запросов на каждый символ. Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием. @@ -93,9 +116,16 @@ OSM/approved seed → Map Gateway spatial cache → map.station | map.terminal payload, upstream endpoint, credentials и произвольные tags в Foundry не попадают. Начальный snapshot Москвы получен из legacy L1 MMAP как donor и разделён на три категории: `metro`, `railway_station`, -`railway_terminal`. За пределами snapshot Map Gateway может последовательно -загрузить spatial cells из OSM и сохраняет нормализованный результат в -persistent cache; повторный viewport читается локально. +`railway_terminal`. За пределами snapshot Map Gateway загружает spatial cells +из OSM через bounded queue: одновременно исполняются не более двух upstream +запросов, их старты сглаживаются, одинаковые inflight cells дедуплицируются, а +нормализованный результат атомарно сохраняется в persistent cache. Частичный +viewport автоматически повторяет только отсутствующие cells с backoff; +повторный готовый viewport читается локально. +Русское имя `name:ru` имеет приоритет перед общим `name`. Временная +недоступность Gateway не стирает последний уже проверенный browser snapshot; +server status показывает только безопасный код ошибки, состояние очереди и +время, не раскрывая upstream endpoint. Page Layout хранит три независимых reference bindings: diff --git a/package.json b/package.json index 360c848..215f45b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-subject-card && npm run test:map-subject-detail-profile", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -26,6 +26,7 @@ "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-inspector-overlay-state": "node --test scripts/map-inspector-overlay-state.test.mjs", "test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs", + "test:map-search": "node --test scripts/map-search.test.mjs", "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", diff --git a/registry/pages.json b/registry/pages.json index 088f0b9..12bc10a 100644 --- a/registry/pages.json +++ b/registry/pages.json @@ -6,7 +6,7 @@ "id": "map", "version": "0.1.0", "title": "Map Page", - "description": "Full-map NODE.DC page with fixed system actions, Inspector, Toolbar and typed spatial data slots.", + "description": "Full-map NODE.DC page with fixed system actions, universal domain-subject search, Inspector, Toolbar and typed spatial data slots.", "category": "map", "page": { "id": "map", diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index 15edb0b..6bddaf7 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -75,5 +75,5 @@ test("facet tree selection focuses the subject without resetting a compatible de assert.match(renderer, /heading: camera\.heading/); assert.match(renderer, /pitch: camera\.pitch/); assert.match(renderer, /roll: camera\.roll/); - assert.match(renderer, /duration: 2\.1/); + assert.match(renderer, /duration: 0\.45/); }); diff --git a/scripts/map-search.test.mjs b/scripts/map-search.test.mjs new file mode 100644 index 0000000..348f585 --- /dev/null +++ b/scripts/map-search.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildMapSearchIndex, searchMapSubjects } from "../apps/catalog/src/mapSearch.mjs"; + +const now = "2026-07-25T08:00:00.000Z"; +const point = (sourceId, semanticType, attributes) => ({ + sourceId, + semanticType, + observedAt: now, + receivedAt: now, + attributes, + geometry: { type: "Point", coordinates: [37.62, 55.75] }, + presentationStatus: "active", +}); +const profile = { + id: "map.subject.v1", + title: "Транспорт", + semanticTypes: ["map.moving_object"], + label: { mode: "attributes", fields: ["display_name"] }, +}; + +test("search index uses stable identity, label and explicit field projection only", () => { + const index = buildMapSearchIndex({ + runtimeBindings: [{ + bindingId: "vehicles", + presentationProfileId: profile.id, + facts: [point("unit-42", "map.moving_object", { + display_name: "Трайк 42", + tracker_id: "tracker-77", + raw_secret: "must-not-be-indexed", + })], + }], + bindingConfigs: [{ + id: "vehicles", + fieldProjection: ["display_name", "tracker_id"], + presentationProfileId: profile.id, + }], + presentationProfiles: [profile], + }); + + assert.equal(searchMapSubjects(index, "Трайк").at(0)?.sourceId, "unit-42"); + assert.equal(searchMapSubjects(index, "tracker-77").at(0)?.sourceId, "unit-42"); + assert.equal(searchMapSubjects(index, "unit-42").at(0)?.sourceId, "unit-42"); + assert.deepEqual(searchMapSubjects(index, "must-not-be-indexed"), []); +}); + +test("authorized joined aspect augments primary subject without creating a second entity", () => { + const index = buildMapSearchIndex({ + runtimeBindings: [ + { + bindingId: "vehicles", + presentationProfileId: profile.id, + facts: [point("unit-42", "map.moving_object", { display_name: "Трайк 42" })], + }, + { + bindingId: "identity", + facts: [point("unit-42", "map.moving_object", { + imei: "867236078012345", + provider_payload: "forbidden", + })], + }, + ], + bindingConfigs: [ + { id: "vehicles", fieldProjection: ["display_name"], presentationProfileId: profile.id }, + { + id: "identity", + joinToBindingId: "vehicles", + fieldProjection: ["imei"], + dataClass: "restricted", + }, + ], + presentationProfiles: [profile], + }); + + assert.equal(index.length, 1); + assert.equal(searchMapSubjects(index, "867236078012345").at(0)?.entityId, "nodedc-runtime:vehicles:map.moving_object:unit-42"); + assert.deepEqual(searchMapSubjects(index, "forbidden"), []); +}); + +test("reference subjects remain searchable by profile labels without a provider branch", () => { + const stationProfile = { + id: "map.reference.station.v1", + title: "Метро", + semanticTypes: ["map.station"], + label: { mode: "attributes", fields: ["name", "official_name"] }, + }; + const index = buildMapSearchIndex({ + runtimeBindings: [{ + bindingId: "reference.metro", + presentationProfileId: stationProfile.id, + facts: [point("osm.node.1", "map.station", { + name: "Петроградская", + provider_note: "not searchable", + })], + }], + presentationProfiles: [stationProfile], + }); + + assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская"); + assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро"); + assert.deepEqual(searchMapSubjects(index, "provider_note"), []); +});