feat(map): add universal subject and station search
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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