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
+46 -14
View File
@@ -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<CesiumMapRendererHandle, {
return true;
}, [runtimeEntities]);
const focusRuntimeEntity = useCallback((entityId: string) => {
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<CesiumMapRendererHandle, {
new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
viewer.scene.globe.ellipsoid,
);
const cartographic = Cartographic.fromCartesian(position);
const groundTarget = Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
const groundTarget = Cartesian3.fromDegrees(
longitude,
latitude,
0,
viewer.scene.globe.ellipsoid,
);
@@ -1410,8 +1428,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
new Cartesian3(),
)
: Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
CesiumMath.toRadians(longitude),
CesiumMath.toRadians(latitude),
camera.positionCartographic.height,
viewer.scene.globe.ellipsoid,
);
@@ -1423,10 +1441,23 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
pitch: camera.pitch,
roll: camera.roll,
},
duration: 2.1,
duration: 0.45,
});
return true;
}, [runtimeEntities]);
}, []);
const focusRuntimeEntity = useCallback((entityId: string) => {
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<CesiumMapRendererHandle, {
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
},
fitRuntimeEntities,
focusCoordinates,
focusRuntimeEntity,
}), [fitRuntimeEntities, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
}), [fitRuntimeEntities, focusCoordinates, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
useEffect(() => {
const stopForPageLeave = () => stopSpiralAnimation("stopped");
@@ -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() {
<ControlRow label="Начальный snapshot"><strong>{stations?.seedFactCount ?? 638} точек</strong></ControlRow>
<ControlRow label="Пространственный кэш"><strong>{stations ? `${stations.cachedCellCount ?? 0} ячеек · ${stations.cellDegrees ?? 0.5}°` : "проверяется…"}</strong></ControlRow>
<ControlRow label="Подгрузка"><strong>{stations?.fetchEnabled === false ? "только snapshot" : "по viewport, через Gateway"}</strong></ControlRow>
<ControlRow label="Runtime"><strong>{stations?.upstreamState === "degraded" ? "ошибка upstream · используется кэш" : stations?.upstreamState === "ready" ? "готов" : "ожидает viewport"}</strong></ControlRow>
<ControlRow label="Очередь"><strong>{stations ? `${stations.activeFetches ?? 0} активно · ${stations.queuedFetches ?? 0} ожидает` : "проверяется…"}</strong></ControlRow>
<ControlRow label="Запросы"><strong>{stations ? `${stations.upstreamRequests ?? 0} · ошибок ${stations.upstreamFailures ?? 0}` : "проверяется…"}</strong></ControlRow>
<ControlRow label="Поиск названий"><strong>{stations ? `${stations.searchRequests ?? 0} · ошибок ${stations.searchFailures ?? 0}` : "проверяется…"}</strong></ControlRow>
{stations?.lastRefreshAt ? <small>Последнее пополнение: {new Date(stations.lastRefreshAt).toLocaleString()}</small> : null}
{stations?.lastFailure ? <small className="catalog-application-draft__status" data-state="error">Последняя ошибка: {stations.lastFailure}{stations.lastFailureAt ? ` · ${new Date(stations.lastFailureAt).toLocaleString()}` : ""}</small> : null}
</SettingsCard>
</div>
);
+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}
+27
View File
@@ -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[];
+174
View File
@@ -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}`;
}
+125
View File
@@ -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 {
+119 -6
View File
@@ -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<ReferenceSnapshot | null>(null);
const [requestState, setRequestState] = useState<MapRuntimeBinding["state"]>("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<MapRuntimeBinding[]>(() => 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<ReferenceSearch | null>(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<MapRuntimeBinding>((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,
};
}
+11
View File
@@ -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`.
+34 -4
View File
@@ -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:
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/);
});
+103
View File
@@ -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"), []);
});