feat(map): add universal subject and station search
This commit is contained in:
@@ -135,6 +135,25 @@ export type MapGatewayHealth = {
|
|||||||
lastFailure?: string | null;
|
lastFailure?: string | null;
|
||||||
lastFailureAt?: 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;
|
ionConfigured?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,6 +258,7 @@ export type CesiumMapRendererHandle = {
|
|||||||
getCameraView: () => MapCameraView | null;
|
getCameraView: () => MapCameraView | null;
|
||||||
fitRuntimeEntities: (entityIds?: string[]) => boolean;
|
fitRuntimeEntities: (entityIds?: string[]) => boolean;
|
||||||
focusRuntimeEntity: (entityId: string) => boolean;
|
focusRuntimeEntity: (entityId: string) => boolean;
|
||||||
|
focusCoordinates: (longitude: number, latitude: number) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerrainRouteSample = {
|
type TerrainRouteSample = {
|
||||||
@@ -1380,12 +1400,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||||||
return true;
|
return true;
|
||||||
}, [runtimeEntities]);
|
}, [runtimeEntities]);
|
||||||
|
|
||||||
const focusRuntimeEntity = useCallback((entityId: string) => {
|
const focusCoordinates = useCallback((longitude: number, latitude: number) => {
|
||||||
const viewer = viewerRef.current;
|
const viewer = viewerRef.current;
|
||||||
if (!viewer || viewer.isDestroyed()) return false;
|
if (!viewer || viewer.isDestroyed()
|
||||||
const entity = runtimeEntities([entityId])[0];
|
|| !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|
||||||
const position = entity?.position?.getValue(viewer.clock.currentTime);
|
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90) return false;
|
||||||
if (!position) return false;
|
|
||||||
|
|
||||||
// Preserve the observer's current composition exactly as the proven
|
// Preserve the observer's current composition exactly as the proven
|
||||||
// legacy MMAP/AIS interaction does: move the camera/viewport frame to the
|
// 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),
|
new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||||
viewer.scene.globe.ellipsoid,
|
viewer.scene.globe.ellipsoid,
|
||||||
);
|
);
|
||||||
const cartographic = Cartographic.fromCartesian(position);
|
const groundTarget = Cartesian3.fromDegrees(
|
||||||
const groundTarget = Cartesian3.fromRadians(
|
longitude,
|
||||||
cartographic.longitude,
|
latitude,
|
||||||
cartographic.latitude,
|
|
||||||
0,
|
0,
|
||||||
viewer.scene.globe.ellipsoid,
|
viewer.scene.globe.ellipsoid,
|
||||||
);
|
);
|
||||||
@@ -1410,8 +1428,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||||||
new Cartesian3(),
|
new Cartesian3(),
|
||||||
)
|
)
|
||||||
: Cartesian3.fromRadians(
|
: Cartesian3.fromRadians(
|
||||||
cartographic.longitude,
|
CesiumMath.toRadians(longitude),
|
||||||
cartographic.latitude,
|
CesiumMath.toRadians(latitude),
|
||||||
camera.positionCartographic.height,
|
camera.positionCartographic.height,
|
||||||
viewer.scene.globe.ellipsoid,
|
viewer.scene.globe.ellipsoid,
|
||||||
);
|
);
|
||||||
@@ -1423,10 +1441,23 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||||||
pitch: camera.pitch,
|
pitch: camera.pitch,
|
||||||
roll: camera.roll,
|
roll: camera.roll,
|
||||||
},
|
},
|
||||||
duration: 2.1,
|
duration: 0.45,
|
||||||
});
|
});
|
||||||
return true;
|
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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
startSpiralAnimation,
|
startSpiralAnimation,
|
||||||
@@ -1436,8 +1467,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||||||
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
|
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
|
||||||
},
|
},
|
||||||
fitRuntimeEntities,
|
fitRuntimeEntities,
|
||||||
|
focusCoordinates,
|
||||||
focusRuntimeEntity,
|
focusRuntimeEntity,
|
||||||
}), [fitRuntimeEntities, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
|
}), [fitRuntimeEntities, focusCoordinates, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stopForPageLeave = () => stopSpiralAnimation("stopped");
|
const stopForPageLeave = () => stopSpiralAnimation("stopped");
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ type MapGatewayHealth = {
|
|||||||
fetchEnabled?: boolean;
|
fetchEnabled?: boolean;
|
||||||
cellDegrees?: number;
|
cellDegrees?: number;
|
||||||
cachedCellCount?: number;
|
cachedCellCount?: number;
|
||||||
|
upstreamRequests?: number;
|
||||||
|
upstreamFailures?: number;
|
||||||
|
searchRequests?: number;
|
||||||
|
searchFailures?: number;
|
||||||
|
upstreamState?: "idle" | "ready" | "degraded";
|
||||||
|
activeFetches?: number;
|
||||||
|
queuedFetches?: number;
|
||||||
lastRefreshAt?: string | null;
|
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="Начальный snapshot"><strong>{stations?.seedFactCount ?? 638} точек</strong></ControlRow>
|
||||||
<ControlRow label="Пространственный кэш"><strong>{stations ? `${stations.cachedCellCount ?? 0} ячеек · ${stations.cellDegrees ?? 0.5}°` : "проверяется…"}</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="Подгрузка"><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?.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>
|
</SettingsCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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 { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,7 +38,8 @@ import {
|
|||||||
isMapReferencePresentationProfile,
|
isMapReferencePresentationProfile,
|
||||||
type MapReferenceLayer,
|
type MapReferenceLayer,
|
||||||
} from "./mapReferenceStations.js";
|
} 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 })));
|
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||||
|
|
||||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||||
@@ -376,6 +377,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
|
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
|
||||||
const [layersWindowActive, setLayersWindowActive] = useState(false);
|
const [layersWindowActive, setLayersWindowActive] = useState(false);
|
||||||
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
|
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 [assistantOpen, setAssistantOpen] = useState(false);
|
||||||
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
|
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
|
||||||
...initialMapSettings,
|
...initialMapSettings,
|
||||||
@@ -434,6 +441,16 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
enabled: Boolean(applicationId && pageId),
|
enabled: Boolean(applicationId && pageId),
|
||||||
});
|
});
|
||||||
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
|
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(
|
const referencePresentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
|
||||||
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
|
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
|
||||||
), [referenceLayers]);
|
), [referenceLayers]);
|
||||||
@@ -447,6 +464,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
const primaryRuntimeBindings = useMemo(() => (
|
const primaryRuntimeBindings = useMemo(() => (
|
||||||
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
|
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
|
||||||
), [primaryBindingIds, runtimeBindings]);
|
), [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(() => (
|
const selectable = useMemo(() => (
|
||||||
primaryRuntimeBindings.flatMap((binding) => {
|
primaryRuntimeBindings.flatMap((binding) => {
|
||||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||||
@@ -873,6 +898,71 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
mapRendererRef.current?.focusRuntimeEntity(entityId);
|
mapRendererRef.current?.focusRuntimeEntity(entityId);
|
||||||
}, [handleSelect]);
|
}, [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) => {
|
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
|
||||||
gatewayHealthRef.current = health;
|
gatewayHealthRef.current = health;
|
||||||
setGatewayHealth(health);
|
setGatewayHealth(health);
|
||||||
@@ -983,8 +1073,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
const liveCacheSummary = liveCacheStatus
|
const liveCacheSummary = liveCacheStatus
|
||||||
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
|
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
|
||||||
: "индекс ещё не получен";
|
: "индекс ещё не получен";
|
||||||
const transportDiagnostic = gatewayHealth?.diagnostics?.lastFailure
|
const transportReferenceStatus = gatewayHealth?.referenceSources?.transportStations;
|
||||||
? `Последняя transport-ошибка: ${gatewayHealth.diagnostics.lastFailure}${gatewayHealth.diagnostics.lastFailureAt ? ` · ${new Date(gatewayHealth.diagnostics.lastFailureAt).toLocaleTimeString()}` : ""}`
|
const transportDiagnostic = transportReferenceStatus?.lastFailure
|
||||||
|
? `Последняя ошибка справочных станций: ${transportReferenceStatus.lastFailure}${transportReferenceStatus.lastFailureAt ? ` · ${new Date(transportReferenceStatus.lastFailureAt).toLocaleTimeString()}` : ""}`
|
||||||
: null;
|
: null;
|
||||||
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
|
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
|
||||||
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
|
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
|
||||||
@@ -1354,7 +1445,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{toolbarOpen ? (
|
{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
|
<Dropdown
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
width={320}
|
width={320}
|
||||||
@@ -1429,7 +1520,72 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||||||
)}
|
)}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -703,6 +703,7 @@ textarea {
|
|||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 10.8rem;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
border-radius: var(--nodedc-radius-circle);
|
border-radius: var(--nodedc-radius-circle);
|
||||||
background: var(--nodedc-map-glass-bg);
|
background: var(--nodedc-map-glass-bg);
|
||||||
@@ -710,6 +711,130 @@ textarea {
|
|||||||
translate: -50% 0;
|
translate: -50% 0;
|
||||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
box-shadow: var(--nodedc-glass-dropdown-shadow);
|
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 {
|
.catalog-map-fixture__objects-menu {
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ type ReferenceSnapshot = {
|
|||||||
schemaVersion: "nodedc.map-reference.snapshot/v1";
|
schemaVersion: "nodedc.map-reference.snapshot/v1";
|
||||||
profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
|
profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
|
||||||
sourceRevision: string;
|
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[];
|
facts: MapRuntimeFact[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +32,8 @@ export function useMapReferenceRuntime(
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
) {
|
) {
|
||||||
const [snapshot, setSnapshot] = useState<ReferenceSnapshot | null>(null);
|
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 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 bbox = useMemo(() => referenceBbox(camera), [camera?.height, camera?.latitude, camera?.longitude]);
|
||||||
const bboxSignature = bbox?.join(",") ?? "";
|
const bboxSignature = bbox?.join(",") ?? "";
|
||||||
@@ -29,11 +41,21 @@ export function useMapReferenceRuntime(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !layers.length) {
|
if (!enabled || !layers.length) {
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
|
setRequestState("idle");
|
||||||
return;
|
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();
|
const controller = new AbortController();
|
||||||
|
let retryTimer: number | undefined;
|
||||||
|
const scheduleRetry = () => {
|
||||||
|
retryTimer = window.setTimeout(() => setRetryEpoch((current) => current + 1), 6_000);
|
||||||
|
};
|
||||||
const debounce = window.setTimeout(() => {
|
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}`, {
|
void fetch(`/api/map-gateway/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_REFERENCE_PROFILE_ID}/current${query}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -43,16 +65,25 @@ export function useMapReferenceRuntime(
|
|||||||
const parsed = asSnapshot(await response.json());
|
const parsed = asSnapshot(await response.json());
|
||||||
if (!parsed) throw new Error("map_reference_snapshot_invalid");
|
if (!parsed) throw new Error("map_reference_snapshot_invalid");
|
||||||
setSnapshot(parsed);
|
setSnapshot(parsed);
|
||||||
|
setRequestState(parsed.complete ? "ready" : "reconnecting");
|
||||||
|
if (!parsed.complete) scheduleRetry();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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);
|
}, 450);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(debounce);
|
window.clearTimeout(debounce);
|
||||||
|
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [bboxSignature, enabled, signature]);
|
}, [bboxSignature, enabled, retryEpoch, signature]);
|
||||||
|
|
||||||
return useMemo<MapRuntimeBinding[]>(() => layers.map((layer) => ({
|
return useMemo<MapRuntimeBinding[]>(() => layers.map((layer) => ({
|
||||||
bindingId: layer.id,
|
bindingId: layer.id,
|
||||||
@@ -61,8 +92,71 @@ export function useMapReferenceRuntime(
|
|||||||
presentationProfileId: layer.presentationProfileId,
|
presentationProfileId: layer.presentationProfileId,
|
||||||
facts: snapshot?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [],
|
facts: snapshot?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [],
|
||||||
cursor: snapshot?.sourceRevision ?? null,
|
cursor: snapshot?.sourceRevision ?? null,
|
||||||
state: snapshot ? "ready" : "loading",
|
state: requestState === "idle" ? (snapshot ? "ready" : "loading") : requestState,
|
||||||
})), [layers, snapshot]);
|
})), [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) {
|
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 {
|
function asSnapshot(value: unknown): ReferenceSnapshot | null {
|
||||||
if (!isObject(value) || value.schemaVersion !== "nodedc.map-reference.snapshot/v1"
|
if (!isObject(value) || value.schemaVersion !== "nodedc.map-reference.snapshot/v1"
|
||||||
|| value.profileId !== TRANSPORT_STATION_REFERENCE_PROFILE_ID
|
|| 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));
|
const facts = value.facts.map(asFact).filter((fact): fact is MapRuntimeFact => Boolean(fact));
|
||||||
return {
|
return {
|
||||||
schemaVersion: "nodedc.map-reference.snapshot/v1",
|
schemaVersion: "nodedc.map-reference.snapshot/v1",
|
||||||
profileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID,
|
profileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID,
|
||||||
sourceRevision: value.sourceRevision,
|
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,
|
facts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ slot `reference-points`. Их три presentation profiles (`Метро`, `Во
|
|||||||
`Станции РЖД`) являются живыми каноническими Inspector sections: приложение
|
`Станции РЖД`) являются живыми каноническими Inspector sections: приложение
|
||||||
может менять высоту, размер, label и LOD, не создавая provider-specific UI.
|
может менять высоту, размер, 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`).
|
Приложение контролирует `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`.
|
Окно предназначено для вспомогательных камер, инструментов и сопоставляемых представлений внутри сцены. Modal workflow, подтверждение и viewport-level Inspector по-прежнему используют `Window`.
|
||||||
|
|||||||
+34
-4
@@ -72,7 +72,30 @@ subjects. Выбор subject в списке открывает его карт
|
|||||||
композицию камеры к точке. Адаптер повторяет проверенную механику legacy
|
композицию камеры к точке. Адаптер повторяет проверенную механику legacy
|
||||||
MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение камеры относительно
|
MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение камеры относительно
|
||||||
центра viewport; длительность перелёта для текущего канонического workflow —
|
центра 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 не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
|
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 не
|
payload, upstream endpoint, credentials и произвольные tags в Foundry не
|
||||||
попадают. Начальный snapshot Москвы получен из legacy L1 MMAP как donor и
|
попадают. Начальный snapshot Москвы получен из legacy L1 MMAP как donor и
|
||||||
разделён на три категории: `metro`, `railway_station`,
|
разделён на три категории: `metro`, `railway_station`,
|
||||||
`railway_terminal`. За пределами snapshot Map Gateway может последовательно
|
`railway_terminal`. За пределами snapshot Map Gateway загружает spatial cells
|
||||||
загрузить spatial cells из OSM и сохраняет нормализованный результат в
|
из OSM через bounded queue: одновременно исполняются не более двух upstream
|
||||||
persistent cache; повторный viewport читается локально.
|
запросов, их старты сглаживаются, одинаковые inflight cells дедуплицируются, а
|
||||||
|
нормализованный результат атомарно сохраняется в persistent cache. Частичный
|
||||||
|
viewport автоматически повторяет только отсутствующие cells с backoff;
|
||||||
|
повторный готовый viewport читается локально.
|
||||||
|
Русское имя `name:ru` имеет приоритет перед общим `name`. Временная
|
||||||
|
недоступность Gateway не стирает последний уже проверенный browser snapshot;
|
||||||
|
server status показывает только безопасный код ошибки, состояние очереди и
|
||||||
|
время, не раскрывая upstream endpoint.
|
||||||
|
|
||||||
Page Layout хранит три независимых reference bindings:
|
Page Layout хранит три независимых reference bindings:
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"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": "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",
|
"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",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.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-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-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-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-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-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",
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"id": "map",
|
"id": "map",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"title": "Map Page",
|
"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",
|
"category": "map",
|
||||||
"page": {
|
"page": {
|
||||||
"id": "map",
|
"id": "map",
|
||||||
|
|||||||
@@ -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, /heading: camera\.heading/);
|
||||||
assert.match(renderer, /pitch: camera\.pitch/);
|
assert.match(renderer, /pitch: camera\.pitch/);
|
||||||
assert.match(renderer, /roll: camera\.roll/);
|
assert.match(renderer, /roll: camera\.roll/);
|
||||||
assert.match(renderer, /duration: 2\.1/);
|
assert.match(renderer, /duration: 0\.45/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"), []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user