feat(map): add reference layers and stabilize workspace controls

This commit is contained in:
Codex
2026-07-25 13:12:24 +03:00
parent c859ae4db0
commit eaecce9f56
29 changed files with 1593 additions and 247 deletions
+1 -119
View File
@@ -118,13 +118,6 @@ interface FoundrySessionProfile {
};
}
interface CesiumIonSecretStatus {
configured: boolean;
updatedAt: string | null;
updatedBy: string | null;
verification?: "verified" | "failed" | "not-configured";
}
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: {
panelHex: "#151517",
@@ -320,12 +313,7 @@ export function CatalogApp() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSequenceRef = useRef(0);
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
const [cesiumIonToken, setCesiumIonToken] = useState("");
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
const [cesiumIonError, setCesiumIonError] = useState("");
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
@@ -527,60 +515,6 @@ export function CatalogApp() {
const isFoundryAdmin = sessionProfile?.access?.role === "admin";
const openPlatformSettings = () => {
setPlatformSettingsOpen(true);
setCesiumIonToken("");
setCesiumIonError("");
setCesiumIonSaveState("loading");
void fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("platform_settings_load_failed");
return await response.json() as CesiumIonSecretStatus;
})
.then((status) => {
setCesiumIonStatus(status);
setCesiumIonSaveState("idle");
})
.catch(() => setCesiumIonSaveState("error"));
};
const closePlatformSettings = () => {
setPlatformSettingsOpen(false);
setCesiumIonToken("");
setCesiumIonError("");
};
const saveCesiumIonToken = async () => {
const token = cesiumIonToken.trim();
if (token.length < 16) return;
setCesiumIonSaveState("saving");
setCesiumIonError("");
try {
const response = await fetch("/api/platform-settings/cesium-ion", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const result = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(result?.error || "platform_settings_save_failed");
}
setCesiumIonStatus(await response.json() as CesiumIonSecretStatus);
setCesiumIonToken("");
setCesiumIonSaveState("saved");
} catch (error) {
const code = error instanceof Error ? error.message : "platform_settings_save_failed";
setCesiumIonError(code === "cesium_ion_token_verification_failed"
? "Token не прошёл проверку Cesium Ion для terrain, imagery или 3D Buildings. Значение не сохранено."
: code === "map_gateway_admin_unauthorized"
? "Внутренний signing profile Gateway не совпадает с Foundry. Это исправляется только runner-managed deployment, не вводом значений в интерфейс."
: code === "map_gateway_admin_not_configured"
? "Runner-managed signing file Gateway недоступен."
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
setCesiumIonSaveState("error");
}
};
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
@@ -2019,8 +1953,6 @@ export function CatalogApp() {
onClose={closeGuideline}
headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : studioContext === "pages" && isFoundryAdmin ? (
<IconButton label="Настройки Platform" onClick={openPlatformSettings}><Icon name="settings" /></IconButton>
) : undefined}
contextSlot={studioContext === "applications" ? (
<Select
@@ -2270,57 +2202,7 @@ export function CatalogApp() {
onConfirm={confirmPageRemoval}
/>
<Window
open={platformSettingsOpen}
title="Настройки Platform"
subtitle="Map Gateway · provider credentials"
size="sm"
onClose={closePlatformSettings}
footer={
<>
<Button variant="ghost" onClick={closePlatformSettings}>Отмена</Button>
<WindowFooterActions>
<Button
variant="primary"
shape="pill"
icon={<Icon name="save" />}
disabled={cesiumIonSaveState === "loading" || cesiumIonSaveState === "saving" || cesiumIonToken.trim().length < 16}
onClick={() => { void saveCesiumIonToken(); }}
>{cesiumIonSaveState === "saving" ? "Применение…" : "Применить"}</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<SettingsCard
title="Cesium Ion"
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser/env или artifact."
>
<ControlRow label="Состояние">
<strong>{cesiumIonSaveState === "loading" ? "Проверяем…" : cesiumIonStatus?.configured ? "Настроен" : "Не настроен"}</strong>
</ControlRow>
{cesiumIonStatus?.configured ? <ControlRow label="Проверка provider"><strong>{cesiumIonStatus.verification === "verified" ? "Пройдена: terrain и imagery доступны" : cesiumIonStatus.verification === "failed" ? "Не пройдена: проверьте token" : "Будет выполнена при следующем применении"}</strong></ControlRow> : null}
{cesiumIonStatus?.updatedAt ? <small>Последнее изменение: {new Date(cesiumIonStatus.updatedAt).toLocaleString()}</small> : null}
</SettingsCard>
<TextField
type="password"
autoComplete="new-password"
label="Cesium Ion token"
hint="обязательно"
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
value={cesiumIonToken}
onChange={(event) => {
setCesiumIonToken(event.target.value);
if (cesiumIonSaveState === "saved" || cesiumIonSaveState === "error") setCesiumIonSaveState("idle");
setCesiumIonError("");
}}
/>
{cesiumIonSaveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
{cesiumIonSaveState === "error" ? <small className="catalog-application-draft__status" data-state="error">{cesiumIonError}</small> : null}
</div>
</Window>
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
<FoundrySettingsModal open={foundrySettingsOpen} isAdmin={isFoundryAdmin} onClose={() => setFoundrySettingsOpen(false)} />
<Window
open={createModalOpen}
+108 -18
View File
@@ -4,6 +4,7 @@ import {
Cartesian3,
BingMapsImageryProvider,
BingMapsStyle,
BillboardGraphics,
Color,
Credit,
Cesium3DTileset,
@@ -29,6 +30,7 @@ import {
ImageryLayer,
JulianDate,
LabelGraphics,
LabelStyle,
Matrix4,
Math as CesiumMath,
PerInstanceColorAppearance,
@@ -64,6 +66,41 @@ const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
const elevatedTargetImageCache = new Map<string, string>();
function elevatedTargetImage(
fillColor: Color,
outlineColor: Color,
outlineWidthPx: number,
headSizePx: number,
) {
const safeHeadSize = Math.max(1, headSizePx);
const safeOutlineWidth = Math.max(0, outlineWidthPx);
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
const key = [
fillColor.toCssColorString(),
outlineColor.toCssColorString(),
safeOutlineWidth,
safeHeadSize,
imageSize,
].join("|");
const cached = elevatedTargetImageCache.get(key);
if (cached) return { image: cached, size: imageSize };
const center = imageSize / 2;
const radius = Math.max(0.5, safeHeadSize / 2);
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
safeOutlineWidth > 0
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
: "/>",
"</svg>",
].join("");
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
elevatedTargetImageCache.set(key, image);
return { image, size: imageSize };
}
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
@@ -622,7 +659,11 @@ function syncRuntimeDataSources(
faultedHGeoZoneGeometryKeys: Set<string>,
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points" || binding.slotId === "zones")
.filter((binding) => (
binding.slotId === "points"
|| binding.slotId === "reference-points"
|| binding.slotId === "zones"
))
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
@@ -657,7 +698,7 @@ function syncRuntimeDataSources(
const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
if (fact.geometry.type === "Point" && binding.slotId === "points") {
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId;
wanted.add(entityId);
@@ -666,19 +707,23 @@ function syncRuntimeDataSources(
entity.name = label;
entity.polygon = undefined;
if (!profile) {
entity.billboard = undefined;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
@@ -714,11 +759,19 @@ function syncRuntimeDataSources(
material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.point = new PointGraphics({
pixelSize: target.headSizePx,
const targetImage = elevatedTargetImage(
color,
outlineColor: Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
outlineWidth: target.outlineWidthPx,
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
target.outlineWidthPx,
target.headSizePx,
);
entity.point = undefined;
entity.billboard = new BillboardGraphics({
image: targetImage.image,
width: targetImage.size,
height: targetImage.size,
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
@@ -726,9 +779,9 @@ function syncRuntimeDataSources(
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor),
outlineWidth: profile.label.outlineWidthPx,
style: 2,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
@@ -759,6 +812,7 @@ function syncRuntimeDataSources(
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
entity.billboard = undefined;
entity.point = undefined;
entity.polygon = undefined;
entity.polyline = undefined;
@@ -766,9 +820,9 @@ function syncRuntimeDataSources(
text: label,
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
outlineColor: profile ? Color.fromCssColorString(profile.label.outlineColor) : Color.BLACK,
outlineWidth: profile?.label.outlineWidthPx ?? 1,
style: 2,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
backgroundColor: profile
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
@@ -1330,10 +1384,46 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0];
if (!entity) return false;
void viewer.flyTo(entity, {
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000),
const position = entity?.position?.getValue(viewer.clock.currentTime);
if (!position) return false;
// Preserve the observer's current composition exactly as the proven
// legacy MMAP/AIS interaction does: move the camera/viewport frame to the
// selected point without replacing heading, pitch, roll or ground offset.
const camera = viewer.camera;
const canvas = viewer.scene.canvas;
const viewportCenter = camera.pickEllipsoid(
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,
0,
viewer.scene.globe.ellipsoid,
);
const destination = viewportCenter
? Cartesian3.add(
groundTarget,
Cartesian3.subtract(camera.position, viewportCenter, new Cartesian3()),
new Cartesian3(),
)
: Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
camera.positionCartographic.height,
viewer.scene.globe.ellipsoid,
);
camera.flyTo({
destination,
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
duration: 2.1,
});
return true;
}, [runtimeEntities]);
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { Button, ControlRow, Icon, SettingsCard, TextField } from "@nodedc/ui-react";
type CesiumIonSecretStatus = {
configured: boolean;
updatedAt: string | null;
verification?: "verified" | "failed" | "not-configured";
};
type MapGatewayHealth = {
referenceSources?: {
transportStations?: {
profileId?: string;
seedFactCount?: number;
fetchEnabled?: boolean;
cellDegrees?: number;
cachedCellCount?: number;
lastRefreshAt?: string | null;
};
};
};
export function FoundryMapProviderSettings() {
const [token, setToken] = useState("");
const [status, setStatus] = useState<CesiumIonSecretStatus | null>(null);
const [gateway, setGateway] = useState<MapGatewayHealth | null>(null);
const [saveState, setSaveState] = useState<"loading" | "idle" | "saving" | "saved" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setSaveState("loading");
void Promise.all([
fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<CesiumIonSecretStatus> : Promise.reject()),
fetch("/api/map-gateway/healthz", { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<MapGatewayHealth> : null),
]).then(([nextStatus, nextGateway]) => {
if (!active) return;
setStatus(nextStatus);
setGateway(nextGateway);
setSaveState("idle");
}).catch(() => {
if (active) setSaveState("error");
});
return () => { active = false; };
}, []);
const save = async () => {
const value = token.trim();
if (value.length < 16) return;
setSaveState("saving");
setError("");
try {
const response = await fetch("/api/platform-settings/cesium-ion", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: value }),
});
if (!response.ok) {
const result = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(result?.error || "platform_settings_save_failed");
}
setStatus(await response.json() as CesiumIonSecretStatus);
setToken("");
setSaveState("saved");
} catch (cause) {
const code = cause instanceof Error ? cause.message : "platform_settings_save_failed";
setError(code === "cesium_ion_token_verification_failed"
? "Token не прошёл проверку terrain, imagery и 3D Buildings. Старое значение сохранено."
: code === "map_gateway_admin_unauthorized"
? "Внутренний signing profile Gateway не совпадает с Foundry."
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
setSaveState("error");
}
};
const stations = gateway?.referenceSources?.transportStations;
return (
<div className="catalog-form">
<SettingsCard
eyebrow="MAP PROVIDERS"
title="Cesium Ion"
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser, env ответа или Application artifact."
>
<ControlRow label="Состояние">
<strong>{saveState === "loading" ? "Проверяем…" : status?.configured ? "Настроен" : "Не настроен"}</strong>
</ControlRow>
{status?.configured ? (
<ControlRow label="Проверка provider">
<strong>{status.verification === "verified" ? "Пройдена: terrain, imagery и 3D доступны" : "Не пройдена"}</strong>
</ControlRow>
) : null}
{status?.updatedAt ? <small>Последнее изменение: {new Date(status.updatedAt).toLocaleString()}</small> : null}
</SettingsCard>
<TextField
type="password"
autoComplete="new-password"
label="Cesium Ion token"
hint="обязательно"
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
value={token}
onChange={(event) => {
setToken(event.target.value);
if (saveState === "saved" || saveState === "error") setSaveState("idle");
setError("");
}}
/>
<Button
variant="primary"
shape="pill"
icon={<Icon name="save" />}
disabled={saveState === "loading" || saveState === "saving" || token.trim().length < 16}
onClick={() => { void save(); }}
>{saveState === "saving" ? "Применение…" : "Применить Cesium token"}</Button>
{saveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
{saveState === "error" && error ? <small className="catalog-application-draft__status" data-state="error">{error}</small> : null}
<SettingsCard
eyebrow="MAP REFERENCE SOURCE"
title="OpenStreetMap · транспортные станции"
description="Token не требуется. Map Gateway нормализует только разрешённые поля map.station/map.terminal; raw OSM payload и provider endpoint не попадают в Foundry."
>
<ControlRow label="Профиль"><strong>{stations?.profileId ?? "transport-stations.v1"}</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?.fetchEnabled === false ? "только snapshot" : "по viewport, через Gateway"}</strong></ControlRow>
{stations?.lastRefreshAt ? <small>Последнее пополнение: {new Date(stations.lastRefreshAt).toLocaleString()}</small> : null}
</SettingsCard>
</div>
);
}
+25 -5
View File
@@ -1,24 +1,44 @@
import { useEffect, useState } from "react";
import { FeatureSettingsWindow } from "@nodedc/ui-react";
import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js";
import { FoundryMapProviderSettings } from "./FoundryMapProviderSettings.js";
interface FoundrySettingsModalProps {
open: boolean;
isAdmin: boolean;
onClose: () => void;
}
export function FoundrySettingsModal({ open, onClose }: FoundrySettingsModalProps) {
type FoundrySettingsSection = "codex-agent-api" | "map-providers";
export function FoundrySettingsModal({ open, isAdmin, onClose }: FoundrySettingsModalProps) {
const [activeSection, setActiveSection] = useState<FoundrySettingsSection>("codex-agent-api");
useEffect(() => {
if (!isAdmin && activeSection === "map-providers") setActiveSection("codex-agent-api");
}, [activeSection, isAdmin]);
const sections = [
{ id: "codex-agent-api" as const, label: "Codex Agent API", group: "Features", icon: "network" as const },
...(isAdmin ? [{
id: "map-providers" as const,
label: "Картографические источники",
group: "Admin",
icon: "globe" as const,
}] : []),
];
return (
<FeatureSettingsWindow
open={open}
title="Настройки Foundry"
subtitle="NODE DC / Codex Agent API"
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]}
activeSection="codex-agent-api"
onSectionChange={() => undefined}
sections={sections}
activeSection={activeSection}
onSectionChange={setActiveSection}
onClose={onClose}
>
<FoundryCodexAgentSettings />
{activeSection === "map-providers" && isAdmin
? <FoundryMapProviderSettings />
: <FoundryCodexAgentSettings />}
</FeatureSettingsWindow>
);
}
+178 -49
View File
@@ -32,6 +32,13 @@ import {
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
import {
ensureMapReferencePresentationProfiles,
initialMapReferenceLayers,
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { useMapReferenceRuntime } from "./useMapReferenceRuntime.js";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -171,6 +178,8 @@ export type MapPageLayout = {
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string;
};
@@ -247,10 +256,12 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: [],
presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [],
subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
};
}
@@ -354,7 +365,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
initialLayout?.inspectorOpenSections ?? ["map-base"]
));
const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
@@ -388,7 +403,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Presentation profiles are application/page-owned, versioned map.style_profile
// values. A human camera/settings save must preserve profiles provisioned by MCP.
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []))
));
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
initialLayout?.subjectDetailProfiles?.length
@@ -399,6 +414,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [referenceLayers, setReferenceLayers] = useState<MapReferenceLayer[]>(() => (
initialMapReferenceLayers(initialLayout?.referenceLayers)
));
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
));
@@ -415,10 +433,23 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
const referencePresentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
), [referenceLayers]);
const rendererPresentationFilters = useMemo<MapPresentationFilters>(() => ({
...presentationFilters,
...referencePresentationFilters,
}), [presentationFilters, referencePresentationFilters]);
const primaryBindingIds = useMemo(() => new Set(
dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id),
), [dataProductBindings]);
const primaryRuntimeBindings = useMemo(() => (
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
), [primaryBindingIds, runtimeBindings]);
const selectable = useMemo(() => (
runtimeBindings.flatMap((binding) => {
primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
if (bindingConfig?.joinToBindingId) return [];
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
presentationProfiles,
@@ -440,8 +471,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
};
});
})
), [dataProductBindings, presentationProfiles, runtimeBindings]);
), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
@@ -457,7 +489,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
counts: mapPresentationFacetCounts(facts, profile),
}];
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => {
const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId);
if (!profile) return [];
const runtime = referenceRuntimeBindings.find((candidate) => candidate.bindingId === layer.id);
return [{
layer,
displayName: profile.title,
total: runtime?.facts.length ?? 0,
}];
}), [presentationProfiles, referenceLayers, referenceRuntimeBindings]);
const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length;
const filteredTargets = useMemo(() => primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
@@ -475,7 +518,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, primaryRuntimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
@@ -714,6 +757,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
presentationProfiles,
subjectDetailProfiles,
dataProductBindings,
referenceLayers,
inspectorOpenSections,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id,
visible: true,
@@ -726,7 +771,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
: state;
}),
}),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectDetailProfiles, subjectStates]);
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
@@ -811,7 +856,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? ""));
setSubjectCardTabId(profile?.defaultTabId ?? "overview");
setSubjectCardTabId((current) => (
profile?.tabs.some((tab) => tab.id === current)
? current
: (profile?.defaultTabId ?? "overview")
));
setSubjectCardOpen(true);
setSubjectCardActive(true);
setLayersWindowActive(false);
@@ -819,6 +868,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
mapRendererRef.current?.focusRuntimeEntity(entityId);
}, [handleSelect]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health;
setGatewayHealth(health);
@@ -1010,14 +1064,26 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
...presentationProfiles.flatMap((profile) => [
...presentationProfiles.flatMap((profile) => {
const referenceProfile = isMapReferencePresentationProfile(profile);
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
return [
{
id: `map-target-${profile.id}`,
label: profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
<Checker
checked={referenceLayer.visible}
label={`Показывать слой «${profile.title}»`}
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
layer.id === referenceLayer.id ? { ...layer, visible } : layer
)))}
/>
) : null}
{profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => {
@@ -1054,14 +1120,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
{profile.target.variant === "elevated-spike" && <>
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
</>}
</>,
},
...(profile.target.variant === "surface-fill" ? [] : [{
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
@@ -1078,7 +1139,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
})}
</>,
}]),
]),
];
}),
{
id: "map-grid",
label: "Сетка и LOD",
@@ -1239,9 +1301,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={runtimeBindings}
runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={presentationFilters}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
@@ -1308,7 +1370,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small>
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div>
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
@@ -1324,30 +1386,45 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={visible}
role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => toggleSubjectVisibility(summary.bindingId)}
onClick={() => {
if (hasControls) {
openSubjectWindow(summary.bindingId);
close();
return;
}
toggleSubjectVisibility(summary.bindingId);
}}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
{hasControls ? (
<IconButton
label={`Фильтры и счётчики: ${summary.displayName}`}
role="menuitem"
aria-pressed={state?.window.open || false}
data-active={state?.window.open || undefined}
onClick={() => {
openSubjectWindow(summary.bindingId);
close();
}}
><Icon name="settings" /></IconButton>
) : null}
</div>
);
})}
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
{referenceObjectSummaries.map(({ layer, displayName, total }) => (
<div
className="catalog-map-fixture__objects-menu-item"
key={layer.id}
data-visible={layer.visible || undefined}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={layer.visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => setReferenceLayers((current) => current.map((candidate) => (
candidate.id === layer.id ? { ...candidate, visible: !candidate.visible } : candidate
)))}
>
<span>{displayName}</span>
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
</button>
</div>
))}
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
@@ -1382,6 +1459,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
@@ -1390,17 +1468,63 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = state.filters[facet.field]?.includes(item.value) ?? false;
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
entity.bindingId === summary.bindingId
&& mapFactMatchesFilters(
entity.fact,
summary.profile,
{
[summary.bindingId]: {
visible: true,
facets: { [facet.field]: [item.value] },
},
},
summary.bindingId,
)
));
return (
<button
type="button"
key={`${facet.field}:${item.value}`}
aria-pressed={active}
data-active={active || undefined}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
>
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
<button
type="button"
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
>
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<button
type="button"
className="catalog-map-fixture__target-filter-expander"
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
aria-expanded={expanded}
onClick={() => setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
{expanded ? (
<div className="catalog-map-fixture__target-filter-children">
{matchingEntities.map((entity) => (
<button
type="button"
className="catalog-map-fixture__target-filter-entity"
key={entity.id}
data-selected={entity.id === selectedId || undefined}
onClick={() => handleSelectAndFocus(entity.id)}
>
<span>{entity.title}</span>
{entity.status ? <small>{entity.status}</small> : null}
</button>
))}
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
</div>
) : null}
</div>
);
})
))}
@@ -1501,7 +1625,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)}
>
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</Window>
</div>
);
+117
View File
@@ -0,0 +1,117 @@
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export const TRANSPORT_STATION_REFERENCE_PROFILE_ID = "transport-stations.v1";
export type MapReferenceStationCategory = "metro" | "railway_terminal" | "railway_station";
export type MapReferenceLayer = {
id: string;
referenceProfileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
category: MapReferenceStationCategory;
presentationProfileId: string;
visible: boolean;
};
const profileDefinitions = [
{
id: "map.reference.transport.metro.v1",
title: "Метро",
category: "metro",
semanticTypes: ["map.station"],
hideCameraHeightMeters: 10_000,
},
{
id: "map.reference.transport.terminal.v1",
title: "Вокзалы",
category: "railway_terminal",
semanticTypes: ["map.terminal"],
hideCameraHeightMeters: 35_000,
},
{
id: "map.reference.transport.railway-station.v1",
title: "Станции РЖД",
category: "railway_station",
semanticTypes: ["map.station"],
hideCameraHeightMeters: 15_000,
},
] as const;
export const defaultMapReferenceLayers: readonly MapReferenceLayer[] = profileDefinitions.map((definition) => ({
id: `reference.transport.${definition.category}`,
referenceProfileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID,
category: definition.category,
presentationProfileId: definition.id,
visible: true,
}));
export const defaultMapReferencePresentationProfiles: readonly MapPresentationProfile[] = profileDefinitions.map((definition) => ({
id: definition.id,
version: "1.2.0",
title: definition.title,
semanticTypes: [...definition.semanticTypes],
label: {
mode: "attributes",
fields: ["name", "official_name", "local_name"],
fontWeight: 600,
sizePx: 20,
color: "#cccccc",
outlineColor: "#0c0d12",
outlineWidthPx: 1,
backgroundColor: "#000000",
backgroundOpacity: 1,
paddingX: 6,
paddingY: 3,
maxLength: 80,
offsetX: 15,
offsetY: 0,
hideCameraHeightMeters: definition.hideCameraHeightMeters,
},
target: {
variant: "elevated-spike",
stemHeightMeters: 500,
headSizePx: 20,
stemWidthPx: 2,
outlineColor: "#ff00c8",
outlineOpacity: 1,
outlineWidthPx: 2,
hideCameraHeightMeters: definition.hideCameraHeightMeters,
},
facets: [{
id: "category",
field: "category",
label: "Категория",
filterable: false,
counter: false,
values: [{ value: definition.category, label: definition.title, order: 0 }],
}],
styles: [{ id: "reference", color: "#ffffff", opacity: 1 }],
classes: [{
id: definition.category,
label: definition.title,
priority: 100,
match: [{ field: "category", equals: definition.category }],
styleId: "reference",
renderable: true,
}],
defaultClassId: definition.category,
sort: [{ field: "category", order: [definition.category] }],
}));
export function ensureMapReferencePresentationProfiles(profiles: MapPresentationProfile[]) {
const ids = new Set(profiles.map((profile) => profile.id));
return [
...profiles,
...defaultMapReferencePresentationProfiles
.filter((profile) => !ids.has(profile.id))
.map((profile) => structuredClone(profile) as MapPresentationProfile),
];
}
export function initialMapReferenceLayers(value?: MapReferenceLayer[]) {
const byId = new Map((value ?? []).map((layer) => [layer.id, layer]));
return defaultMapReferenceLayers.map((layer) => structuredClone(byId.get(layer.id) ?? layer));
}
export function isMapReferencePresentationProfile(profile: MapPresentationProfile) {
return defaultMapReferencePresentationProfiles.some((candidate) => candidate.id === profile.id);
}
+93 -15
View File
@@ -746,7 +746,7 @@ textarea {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: 0.28rem;
border-radius: 0.78rem;
@@ -791,11 +791,6 @@ textarea {
white-space: nowrap;
}
.catalog-map-fixture__objects-menu-item > .nodedc-icon-button {
width: 2.25rem;
height: 2.25rem;
}
.catalog-map-fixture__objects-menu-empty {
padding: 0.72rem;
}
@@ -938,44 +933,127 @@ textarea {
gap: 0.36rem;
}
.catalog-map-fixture__target-filter-list button {
.catalog-map-fixture__target-filter-row {
display: inline-flex;
width: 100%;
min-height: 2.05rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 0;
border: 0;
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.72);
padding: 0.42rem 0.72rem;
color: rgba(8, 8, 10, 0.88);
overflow: hidden;
}
.catalog-map-fixture__target-filter-row button {
border: 0;
background: transparent;
font: inherit;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.catalog-map-fixture__target-filter-list button > span {
.catalog-map-fixture__target-filter-body {
display: inline-flex;
min-width: 0;
min-height: 2.05rem;
flex: 1;
align-items: center;
gap: 0.75rem;
padding: 0.42rem 0.28rem 0.42rem 0.72rem;
color: inherit;
text-align: left;
}
.catalog-map-fixture__target-filter-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-fixture__target-filter-count {
margin-left: auto;
color: rgba(8, 8, 10, 0.96);
font-weight: 800;
text-align: right;
}
.catalog-map-fixture__target-filter-list button:hover,
.catalog-map-fixture__target-filter-list button[data-active] {
.catalog-map-fixture__target-filter-expander {
display: inline-grid;
width: 2rem;
min-width: 2rem;
min-height: 2.05rem;
place-items: center;
border-radius: 0 var(--nodedc-radius-circle) var(--nodedc-radius-circle) 0;
color: rgba(8, 8, 10, 0.75);
}
.catalog-map-fixture__target-filter-expander[aria-expanded="true"] svg {
transform: rotate(90deg);
}
.catalog-map-fixture__target-filter-row:hover,
.catalog-map-fixture__target-filter-row[data-active] {
background: rgba(255, 255, 255, 0.96);
color: rgba(8, 8, 10, 0.96);
}
.catalog-map-fixture__target-filter-list button:disabled {
.catalog-map-fixture__target-filter-body:disabled {
cursor: default;
opacity: 0.55;
}
.catalog-map-fixture__target-filter-branch,
.catalog-map-fixture__target-filter-children {
display: grid;
gap: 0.28rem;
}
.catalog-map-fixture__target-filter-children {
padding: 0.08rem 0.18rem 0.18rem 0.72rem;
}
.catalog-map-fixture__target-filter-entity {
display: grid;
min-width: 0;
gap: 0.15rem;
border: 0;
border-left: 2px solid rgba(255, 255, 255, 0.24);
background: transparent;
padding: 0.38rem 0.48rem;
color: var(--nodedc-map-glass-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.catalog-map-fixture__target-filter-entity:hover,
.catalog-map-fixture__target-filter-entity[data-selected] {
border-left-color: var(--nodedc-map-glass-text);
background: rgba(255, 255, 255, 0.11);
}
.catalog-map-fixture__target-filter-entity > span,
.catalog-map-fixture__target-filter-entity > small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-fixture__target-filter-entity > span {
font-size: 0.7rem;
font-weight: 720;
}
.catalog-map-fixture__target-filter-entity > small,
.catalog-map-fixture__target-filter-children > small {
color: var(--nodedc-map-glass-muted);
font-size: 0.61rem;
}
.catalog-map-inspector__style {
display: grid;
gap: 0.5rem;
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from "react";
import type { MapRuntimeBinding, MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
TRANSPORT_STATION_REFERENCE_PROFILE_ID,
type MapReferenceLayer,
type MapReferenceStationCategory,
} from "./mapReferenceStations.js";
type ReferenceSnapshot = {
schemaVersion: "nodedc.map-reference.snapshot/v1";
profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
sourceRevision: string;
facts: MapRuntimeFact[];
};
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const categories = new Set<MapReferenceStationCategory>(["metro", "railway_terminal", "railway_station"]);
export function useMapReferenceRuntime(
layers: MapReferenceLayer[],
camera: { longitude: number; latitude: number; height: number } | null,
enabled = true,
) {
const [snapshot, setSnapshot] = useState<ReferenceSnapshot | null>(null);
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(",") ?? "";
useEffect(() => {
if (!enabled || !layers.length) {
setSnapshot(null);
return;
}
const controller = new AbortController();
const debounce = window.setTimeout(() => {
const query = bbox ? `?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,
})
.then(async (response) => {
if (!response.ok) throw new Error(`map_reference_http_${response.status}`);
const parsed = asSnapshot(await response.json());
if (!parsed) throw new Error("map_reference_snapshot_invalid");
setSnapshot(parsed);
})
.catch(() => {
if (!controller.signal.aborted) setSnapshot(null);
});
}, 450);
return () => {
window.clearTimeout(debounce);
controller.abort();
};
}, [bboxSignature, enabled, signature]);
return useMemo<MapRuntimeBinding[]>(() => layers.map((layer) => ({
bindingId: layer.id,
dataProductId: `platform-reference.${layer.referenceProfileId}`,
slotId: "reference-points",
presentationProfileId: layer.presentationProfileId,
facts: snapshot?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [],
cursor: snapshot?.sourceRevision ?? null,
state: snapshot ? "ready" : "loading",
})), [layers, snapshot]);
}
function referenceBbox(camera: { longitude: number; latitude: number; height: number } | null) {
if (!camera || camera.height > 150_000 || !Number.isFinite(camera.height)) return null;
const radiusKm = Math.max(10, Math.min(40, camera.height / 1_000 * 0.8));
const latitudeDelta = radiusKm / 111.32;
const longitudeScale = Math.max(0.2, Math.cos(camera.latitude * Math.PI / 180));
const longitudeDelta = radiusKm / (111.32 * longitudeScale);
return [
Math.max(-180, camera.longitude - longitudeDelta),
Math.max(-90, camera.latitude - latitudeDelta),
Math.min(180, camera.longitude + longitudeDelta),
Math.min(90, camera.latitude + latitudeDelta),
].map((value) => Number(value.toFixed(5)));
}
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;
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,
facts,
};
}
function asFact(value: unknown): MapRuntimeFact | null {
if (!isObject(value) || typeof value.sourceId !== "string" || !identifier.test(value.sourceId)
|| !["map.station", "map.terminal"].includes(String(value.semanticType))
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|| !isPoint(value.geometry)) return null;
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
return {
sourceId: value.sourceId,
semanticType: String(value.semanticType),
observedAt: value.observedAt,
receivedAt: value.receivedAt,
attributes: Object.fromEntries(Object.entries(value.attributes).filter(([key]) => allowedAttributes.has(key))),
geometry: { type: "Point", coordinates: [...value.geometry.coordinates] as [number, number] },
presentationStatus: "active",
};
}
function isPoint(value: unknown): value is { type: "Point"; coordinates: [number, number] } {
if (!isObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) return false;
const [longitude, latitude] = value.coordinates;
return typeof longitude === "number" && Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
&& typeof latitude === "number" && Number.isFinite(latitude) && latitude >= -90 && latitude <= 90;
}
function isObject(value: unknown): value is Record<string, any> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isIso(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}