feat(module-studio): add configurable map page

This commit is contained in:
DCCONSTRUCTIONS
2026-07-13 17:14:34 +03:00
parent 1f2dffc031
commit 1a2ca8c82c
23 changed files with 2700 additions and 31 deletions
+77 -24
View File
@@ -58,6 +58,7 @@ import {
type DesignProfileSummary,
type DesignProfileStatus,
} from "./applicationManifest.js";
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
type StudioContext = "visual" | "pages" | "applications";
@@ -306,6 +307,10 @@ export function CatalogApp() {
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState("");
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
const applicationMapPreviewRef = useRef<MapFixturePreviewHandle>(null);
const [createModuleOpen, setCreateModuleOpen] = useState(false);
const [createModuleName, setCreateModuleName] = useState("");
const [createModuleSlug, setCreateModuleSlug] = useState("");
@@ -496,6 +501,24 @@ export function CatalogApp() {
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
fetch("/api/page-layouts/map", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("map_page_layout_load_failed");
return await response.json() as MapPageLayout | null;
})
.then((layout) => {
if (!active) return;
setMapTemplateLayout(layout);
setMapTemplateSaveState(layout ? "saved" : "idle");
})
.catch(() => {
if (active) setMapTemplateSaveState("error");
});
return () => { active = false; };
}, []);
useEffect(() => {
let active = true;
fetch("/api/design-profiles", { cache: "no-store" })
@@ -712,13 +735,21 @@ export function CatalogApp() {
const saveApplicationDraft = async () => {
if (!applicationDraft) return;
const activeMapLayout = activeApplicationPageId ? applicationMapPreviewRef.current?.getLayout() : null;
const draftToSave = activeMapLayout && activeApplicationPageId ? {
...applicationDraft,
pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? {
...page,
layout: { ...page.layout, map: activeMapLayout },
} : page),
} : applicationDraft;
setApplicationSaveState("saving");
setApplicationError("");
try {
const response = await fetch(`/api/applications/${applicationDraft.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(applicationDraft),
body: JSON.stringify(draftToSave),
});
if (!response.ok) throw new Error("application_save_failed");
const saved = await response.json() as ApplicationManifestV01;
@@ -743,6 +774,27 @@ export function CatalogApp() {
}
};
const saveMapPageTemplate = async () => {
const layout = mapTemplatePreviewRef.current?.getLayout();
if (!layout) {
setMapTemplateSaveState("error");
return;
}
setMapTemplateSaveState("saving");
try {
const response = await fetch("/api/page-layouts/map", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(layout),
});
if (!response.ok) throw new Error("map_page_layout_save_failed");
setMapTemplateLayout(await response.json() as MapPageLayout);
setMapTemplateSaveState("saved");
} catch {
setMapTemplateSaveState("error");
}
};
const changeStudioContext = (next: StudioContext) => {
setStudioContext(next);
workspace.openNavigation();
@@ -1504,27 +1556,28 @@ export function CatalogApp() {
};
const renderPageTemplate = (template: PageTemplateDefinition) => (
template.id === "map" ? (
<div className="catalog-page-template catalog-page-template--map">
<MapFixturePreview
key={`map-template-${mapTemplateLayout?.savedAt ?? "default"}`}
ref={mapTemplatePreviewRef}
expanded
initialLayout={mapTemplateLayout}
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
/>
<div className="catalog-page-template__actions">
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
<span>Карта масштабируется по высоте за правый нижний угол. Настройки через Inspector.</span>
</div>
</div>
) : (
<div className="catalog-page-template">
<SettingsCard
eyebrow={`PAGE TEMPLATE ${template.version}`}
title={template.title}
description={template.description}
>
<div className="catalog-map-template-preview" aria-label="Превью Map Page Template">
<div className="catalog-map-template-preview__grid" />
<div className="catalog-map-template-preview__route" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--one" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--two" />
<div className="catalog-map-template-preview__actions">
{template.actions.map((action) => <span key={action.id}><Icon name={action.feature === "assistant" ? "apps" : action.feature === "toolbar" ? "sliders" : "panel"} /></span>)}
</div>
<div className="catalog-map-template-preview__inspector">
<strong>Inspector</strong>
<span>Selection</span>
<span>Layers</span>
<span>Appearance</span>
</div>
</div>
<MapFixturePreview features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))} />
<div className="catalog-page-template__actions">
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
<span>Только утверждённые features и slots без свободного canvas.</span>
@@ -1548,6 +1601,7 @@ export function CatalogApp() {
</SettingsCard>
</div>
</div>
)
);
const renderApplicationPage = () => {
@@ -1562,14 +1616,7 @@ export function CatalogApp() {
}));
return (
<div className="catalog-application-page">
<div className="catalog-map-template-preview catalog-map-template-preview--application" aria-label={`Preview ${page.title}`}>
<div className="catalog-map-template-preview__grid" />
<div className="catalog-map-template-preview__route" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--one" />
<div className="catalog-map-template-preview__point catalog-map-template-preview__point--two" />
{page.features.toolbar ? <div className="catalog-map-template-preview__actions"><span><Icon name="panel" /></span><span><Icon name="sliders" /></span>{page.features.assistant ? <span><Icon name="apps" /></span> : null}</div> : null}
{page.features.inspector ? <div className="catalog-map-template-preview__inspector"><strong>Inspector</strong><span>Selection</span><span>Layers</span><span>Appearance</span></div> : null}
</div>
<MapFixturePreview key={page.id} ref={applicationMapPreviewRef} initialLayout={page.layout?.map ?? null} features={page.features} expanded />
{applicationMode === "edit" ? (
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
<div className="catalog-application-draft__features">
@@ -1735,6 +1782,12 @@ export function CatalogApp() {
description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
utilityActions={activePageTemplate.id === "map" ? [{
label: mapTemplateSaveState === "saving" ? "Сохраняем layout страницы" : mapTemplateSaveState === "saved" ? "Layout страницы сохранён" : mapTemplateSaveState === "error" ? "Повторить сохранение layout страницы" : "Сохранить layout страницы",
icon: "save",
onClick: () => { void saveMapPageTemplate(); },
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
}] : undefined}
onClose={workspace.closeView}
>
<div className="catalog-panel-content">{renderPageTemplate(activePageTemplate)}</div>
+572
View File
@@ -0,0 +1,572 @@
import { useEffect, useRef } from "react";
import {
Cartesian2,
Cartesian3,
BingMapsImageryProvider,
BingMapsStyle,
Color,
Credit,
Cesium3DTileset,
Cesium3DTileStyle,
CesiumTerrainProvider,
CustomDataSource,
DefaultProxy,
DistanceDisplayCondition,
EllipsoidTerrainProvider,
Entity,
HeadingPitchRange,
ImageryLayer,
JulianDate,
Matrix4,
Math as CesiumMath,
PolygonHierarchy,
Resource,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
SunLight,
Viewer,
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
type Position = [number, number, number?];
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
ionReady: boolean;
gatewayReady: boolean;
osmBuildingsReady: boolean;
gaussianSplatsReady: boolean;
gaussianAssetId?: string | null;
assetEndpointBase: string;
resourceProxyBase?: string | null;
gatewayHealthUrl?: string | null;
};
export type MapGatewayHealth = {
cache?: { mode?: string; entries?: number; bytes?: number; maxBytes?: number; persistent?: boolean };
ionConfigured?: boolean;
};
type IonAssetEndpoint = {
assetId: string;
type: "TERRAIN" | "3DTILES" | "IMAGERY";
url?: string;
accessToken?: string;
externalType?: "BING";
options?: { url?: string; key?: string; mapStyle?: string };
attributions: Array<{ html?: string; collapsible?: boolean }>;
};
export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
monochromeColor: string;
imageryGamma: number;
imageryHue: number;
imageryAlpha: number;
globeColor: string;
backgroundColor: string;
atmosphereEnabled: boolean;
atmosphereHue: number;
atmosphereSaturation: number;
atmosphereBrightness: number;
fogEnabled: boolean;
fogDensity: number;
sunEnabled: boolean;
sunHour: number;
sunIntensity: number;
shadowsEnabled: boolean;
buildingsVisible: boolean;
buildingsColor: string;
buildingsOpacity: number;
buildingsDetail: number;
imageryBrightness: number;
imageryContrast: number;
imagerySaturation: number;
gridVisible: boolean;
gridLodEnabled: boolean;
gridHeightMeters: number;
gridLod1MaxHeightKm: number;
gridLod1StepKm: number;
gridLod2MaxHeightKm: number;
gridLod2StepKm: number;
gridLod3StepKm: number;
gridRadiusKm: number;
gridLineWidth: number;
gridColor: string;
gridOpacity: number;
gridDotsEnabled: boolean;
gridDotsSize: number;
gridDotsColor: string;
gridDotsOpacity: number;
cacheRefresh: boolean;
};
export type MapCameraView = {
longitude: number;
latitude: number;
height: number;
heading: number;
pitch: number;
roll: number;
};
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic;
return {
longitude: CesiumMath.toDegrees(position.longitude),
latitude: CesiumMath.toDegrees(position.latitude),
height: position.height,
heading: viewer.camera.heading,
pitch: viewer.camera.pitch,
roll: viewer.camera.roll,
};
}
function addFixtureEntities(viewer: Viewer) {
for (const place of sceneFixture.scene.places) {
viewer.entities.add({
id: place.id,
position: toCartesian(place.position as Position),
label: {
text: place.label.text,
font: "700 28px Arial",
fillColor: Color.WHITE,
outlineColor: Color.BLACK.withAlpha(0.8),
outlineWidth: 4,
style: 2,
distanceDisplayCondition: new DistanceDisplayCondition(100_000, 50_000_000),
},
});
}
for (const route of sceneFixture.scene.routes) {
viewer.entities.add({
id: route.id,
polyline: {
positions: toCartesianArray(route.coordinates as Position[]),
width: 4,
material: accent.withAlpha(0.92),
clampToGround: true,
},
});
}
for (const track of sceneFixture.scene.tracks) {
viewer.entities.add({
id: track.id,
polyline: {
positions: toCartesianArray(track.coordinates as Position[]),
width: 2,
material: violet.withAlpha(0.88),
clampToGround: true,
},
});
}
for (const zone of sceneFixture.scene.zones) {
const ring = zone.geometry.type === "Polygon" ? zone.geometry.coordinates[0] : zone.geometry.coordinates[0][0];
viewer.entities.add({
id: zone.id,
polygon: {
hierarchy: new PolygonHierarchy(toCartesianArray(ring as Position[])),
material: accent.withAlpha(0.2),
},
});
viewer.entities.add({
id: `${zone.id}:boundary`,
polyline: {
positions: toCartesianArray(ring as Position[]),
width: 2,
material: accent.withAlpha(0.78),
clampToGround: true,
},
});
}
for (const object of sceneFixture.scene.movingObjects) {
if (object.trace) {
viewer.entities.add({
id: `${object.id}:trace`,
polyline: {
positions: toCartesianArray(object.trace as Position[]),
width: 2,
material: accent.withAlpha(0.52),
clampToGround: true,
},
});
}
viewer.entities.add({
id: object.id,
position: toCartesian(object.position as Position),
point: { pixelSize: 18, color: accent, outlineColor: Color.WHITE, outlineWidth: 3 },
label: {
text: object.label.text,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(0, -31),
},
});
}
for (const station of sceneFixture.scene.stations) {
viewer.entities.add({
id: station.id,
position: toCartesian(station.position as Position),
point: { pixelSize: station.stationType === "metro" ? 21 : 18, color: violet, outlineColor: Color.WHITE, outlineWidth: 3 },
label: {
text: station.label.text,
font: "700 14px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(0, -33),
},
});
}
}
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
const entities = dataSource.entities;
entities.removeAll();
if (!presentation.gridVisible) return;
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000);
const stepKm = !presentation.gridLodEnabled || cameraHeightKm <= presentation.gridLod1MaxHeightKm
? presentation.gridLod1StepKm
: cameraHeightKm <= presentation.gridLod2MaxHeightKm
? presentation.gridLod2StepKm
: presentation.gridLod3StepKm;
const safeStepKm = clamp(stepKm, 0.25, 100);
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
const center = sceneFixture.viewport.center as Position;
const latitude = center[1];
const longitude = center[0];
const metersPerLatitudeDegree = 110_574;
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
const stepMeters = safeStepKm * 1000;
const radiusMeters = stepsPerSide * stepMeters;
const deltaLatitude = stepMeters / metersPerLatitudeDegree;
const deltaLongitude = stepMeters / metersPerLongitudeDegree;
const radiusLatitude = radiusMeters / metersPerLatitudeDegree;
const radiusLongitude = radiusMeters / metersPerLongitudeDegree;
const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1));
const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1));
const elevation = Math.max(0, presentation.gridHeightMeters);
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
const nextLatitude = latitude + index * deltaLatitude;
const nextLongitude = longitude + index * deltaLongitude;
entities.add({
polyline: {
positions: [
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, elevation),
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, elevation),
],
width: clamp(presentation.gridLineWidth, 1, 8),
material: lineColor,
},
});
entities.add({
polyline: {
positions: [
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, elevation),
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, elevation),
],
width: clamp(presentation.gridLineWidth, 1, 8),
material: lineColor,
},
});
}
if (!presentation.gridDotsEnabled) return;
const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25));
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) {
for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) {
entities.add({
position: Cartesian3.fromDegrees(longitude + column * deltaLongitude, latitude + row * deltaLatitude, elevation),
point: {
pixelSize: clamp(presentation.gridDotsSize, 2, 28),
color: dotColor,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
}
}
}
function applyPresentation(
viewer: Viewer,
imageryLayer: ImageryLayer | null,
buildings: Cesium3DTileset | null,
terrain: { world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider },
presentation: MapPresentation,
) {
if (imageryLayer) {
imageryLayer.show = presentation.imageryVisible && !presentation.monochrome;
imageryLayer.brightness = presentation.imageryBrightness / 100;
imageryLayer.contrast = presentation.imageryContrast / 100;
imageryLayer.saturation = presentation.imagerySaturation / 100;
imageryLayer.gamma = presentation.imageryGamma / 100;
imageryLayer.hue = CesiumMath.toRadians(presentation.imageryHue);
imageryLayer.alpha = presentation.imageryAlpha / 100;
}
if (buildings) {
buildings.show = presentation.buildingsVisible;
buildings.maximumScreenSpaceError = presentation.buildingsDetail;
buildings.style = new Cesium3DTileStyle({
color: `color('${presentation.buildingsColor}', ${presentation.buildingsOpacity})`,
});
}
viewer.terrainProvider = presentation.terrainEnabled && terrain.world ? terrain.world : terrain.ellipsoid;
viewer.scene.globe.show = true;
viewer.scene.globe.baseColor = Color.fromCssColorString(presentation.monochrome ? presentation.monochromeColor : presentation.globeColor);
viewer.scene.globe.enableLighting = presentation.sunEnabled;
(viewer.scene as unknown as { verticalExaggeration?: number }).verticalExaggeration = clamp(presentation.terrainExaggeration, 0.25, 3);
viewer.scene.backgroundColor = Color.fromCssColorString(presentation.backgroundColor);
viewer.scene.fog.enabled = presentation.fogEnabled;
viewer.scene.fog.density = clamp(presentation.fogDensity / 10_000, 0, 0.01);
const atmosphere = viewer.scene.skyAtmosphere;
if (atmosphere) {
atmosphere.show = presentation.atmosphereEnabled;
atmosphere.hueShift = clamp(presentation.atmosphereHue / 100, -1, 1);
atmosphere.saturationShift = clamp(presentation.atmosphereSaturation / 100, -1, 1);
atmosphere.brightnessShift = clamp(presentation.atmosphereBrightness / 100, -1, 1);
}
viewer.shadows = presentation.shadowsEnabled;
const sunDate = JulianDate.toDate(JulianDate.now());
sunDate.setUTCHours(clamp(Math.round(presentation.sunHour), 0, 24), 0, 0, 0);
viewer.clock.currentTime = JulianDate.fromDate(sunDate);
viewer.clock.shouldAnimate = false;
const sun = new SunLight();
(sun as unknown as { intensity?: number }).intensity = clamp(presentation.sunIntensity / 100, 0, 2);
viewer.scene.light = sun;
viewer.scene.requestRender();
}
export function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onCameraChange,
initialCamera,
presentation,
}: {
onSelect?: (entityId: string) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onCameraChange?: (camera: MapCameraView) => void;
initialCamera?: MapCameraView;
presentation: MapPresentation;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
const imageryLayerRef = useRef<ImageryLayer | null>(null);
const buildingsRef = useRef<Cesium3DTileset | null>(null);
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
const rebuildGridRef = useRef<(() => void) | null>(null);
const presentationRef = useRef(presentation);
const onCameraChangeRef = useRef(onCameraChange);
useEffect(() => {
onCameraChangeRef.current = onCameraChange;
}, [onCameraChange]);
useEffect(() => {
presentationRef.current = presentation;
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.();
}, [presentation]);
useEffect(() => {
let viewer: Viewer | undefined;
let handler: ScreenSpaceEventHandler | undefined;
let resizeObserver: ResizeObserver | undefined;
let removeGridCameraListener: (() => void) | undefined;
let cancelled = false;
const start = async () => {
try {
const response = await fetch("/api/map/runtime-config");
const config = response.ok ? await response.json() as RuntimeConfig : null;
if (config?.gatewayHealthUrl) {
void fetch(config.gatewayHealthUrl)
.then((healthResponse) => healthResponse.ok ? healthResponse.json() as Promise<MapGatewayHealth> : null)
.then((health) => { if (!cancelled) onGatewayHealth?.(health); })
.catch(() => { if (!cancelled) onGatewayHealth?.(null); });
}
if (!containerRef.current || cancelled) return;
viewer = new Viewer(containerRef.current, {
animation: false,
baseLayer: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
navigationHelpButton: false,
sceneModePicker: false,
selectionIndicator: false,
timeline: false,
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
// Sandbox-only: Cesium writes credits into a dedicated, visually
// suppressed container. Runtime attribution metadata is preserved;
// external and commercial surfaces must provide visible credits.
creditContainer: creditContainerRef.current ?? undefined,
});
const resourceProxy = config?.resourceProxyBase ? new DefaultProxy(config.resourceProxyBase) : undefined;
const buildResource = (url: string, accessToken?: string) => {
// Put cache intent into the upstream URL itself. Cesium providers
// derive child resources (metadata, imagery tiles, terrain and 3D
// tiles) from this URL; DefaultProxy then forwards the exact intent
// to Gateway for every derived request.
const routedUrl = new URL(url);
if (!presentationRef.current.cacheEnabled) routedUrl.searchParams.set("nodedc_cache_mode", "passthrough");
if (presentationRef.current.cacheRefresh) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
return new Resource({
url: routedUrl.toString(),
queryParameters: {
...(accessToken ? { access_token: accessToken } : {}),
},
proxy: resourceProxy,
});
};
const loadEndpoint = async (assetId: string) => {
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`);
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
return endpointResponse.json() as Promise<IonAssetEndpoint>;
};
const endpoint = await loadEndpoint("2");
if (endpoint.externalType !== "BING" || !endpoint.options?.url || !endpoint.options?.key) throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
key: endpoint.options.key,
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
const imageryLayer = viewer.imageryLayers.addImageryProvider(imageryProvider);
const gridDataSource = new CustomDataSource("nodedc-map-grid");
viewer.dataSources.add(gridDataSource);
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
addFixtureEntities(viewer);
viewerRef.current = viewer;
imageryLayerRef.current = imageryLayer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
rebuildGridRef.current = rebuildGrid;
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer!));
});
if (config?.gatewayReady) {
void loadEndpoint("1").then(async (terrainEndpoint) => {
if (!terrainEndpoint.url || !terrainEndpoint.accessToken) throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url, terrainEndpoint.accessToken);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return;
terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
}).catch(() => undefined);
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
if (!buildingsEndpoint.url || !buildingsEndpoint.accessToken) throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url, buildingsEndpoint.accessToken);
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(buildings);
buildingsRef.current = buildings;
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildings, terrain, presentationRef.current);
}).catch(() => undefined);
if (config.gaussianSplatsReady && config.gaussianAssetId) {
const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId);
if (!gaussianEndpoint.url || !gaussianEndpoint.accessToken) throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url, gaussianEndpoint.accessToken);
viewer.scene.primitives.add(await Cesium3DTileset.fromUrl(gaussianResource));
}
}
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
if (initialCamera) {
viewer.camera.setView({
destination: Cartesian3.fromDegrees(initialCamera.longitude, initialCamera.latitude, initialCamera.height),
orientation: {
heading: initialCamera.heading,
pitch: initialCamera.pitch,
roll: initialCamera.roll,
},
});
} else {
const center = toCartesian(sceneFixture.viewport.center as Position);
viewer.camera.lookAt(center, new HeadingPitchRange(
CesiumMath.toRadians(sceneFixture.viewport.heading),
CesiumMath.toRadians(sceneFixture.viewport.pitch),
sceneFixture.viewport.range,
));
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
}
rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer));
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer?.scene.pick(movement.position);
const entity = picked?.id instanceof Entity ? picked.id : undefined;
if (entity?.id) onSelect?.(entity.id);
}, ScreenSpaceEventType.LEFT_CLICK);
resizeObserver = new ResizeObserver(() => {
if (!viewer || viewer.isDestroyed()) return;
viewer.resize();
viewer.scene.requestRender();
});
resizeObserver.observe(containerRef.current);
} catch {
// The canvas stays available even if an optional provider fails. Its
// detailed state belongs to the Inspector, not to a map overlay label.
}
};
void start();
return () => {
cancelled = true;
resizeObserver?.disconnect();
removeGridCameraListener?.();
handler?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
imageryLayerRef.current = null;
buildingsRef.current = null;
terrainRef.current = null;
rebuildGridRef.current = null;
};
}, [onGatewayHealth, onSelect]);
return (
<div className="catalog-cesium-map">
<div ref={containerRef} className="catalog-cesium-map__canvas" />
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
</div>
);
}
+356
View File
@@ -0,0 +1,356 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
import type { MapCameraView, MapGatewayHealth, MapPresentation } from "./CesiumMapRenderer.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
export type MapPageLayout = {
schemaVersion: 1;
pageId: "map";
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
savedAt?: string;
};
export type MapFixturePreviewHandle = {
getLayout: () => MapPageLayout | null;
};
const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 100,
imageryHue: 0,
imageryAlpha: 100,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
atmosphereHue: 0,
atmosphereSaturation: 0,
atmosphereBrightness: 0,
fogEnabled: true,
fogDensity: 2,
sunEnabled: true,
sunHour: 12,
sunIntensity: 200,
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 0.82,
buildingsDetail: 16,
imageryBrightness: 100,
imageryContrast: 100,
imagerySaturation: 100,
gridVisible: true,
gridLodEnabled: true,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod3StepKm: 25,
gridRadiusKm: 40,
gridLineWidth: 4,
gridColor: "#f5f5f5",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsColor: "#ffffff",
gridDotsOpacity: 58,
};
// A valid, deterministic scene view is available before Cesium emits its
// first move-end event. It makes the page contract immediately saveable;
// the renderer replaces it with the exact live camera as soon as it is ready.
const fallbackMapCamera: MapCameraView = {
longitude: sceneFixture.viewport.center[0],
latitude: sceneFixture.viewport.center[1],
height: sceneFixture.viewport.range,
heading: (sceneFixture.viewport.heading * Math.PI) / 180,
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180,
roll: 0,
};
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
features?: PreviewFeatures;
expanded?: boolean;
initialLayout?: MapPageLayout | null;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null }, ref) {
const selectable = useMemo(() => [
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
], []);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? selectable[0]?.id);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({ ...initialMapSettings, ...initialLayout?.settings }));
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
// The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's
// render cycle to publish a ready camera.
const mapCameraRef = useRef<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const [rendererRevision, setRendererRevision] = useState(0);
const [gatewayHealth, setGatewayHealth] = useState<MapGatewayHealth | null>(null);
const [gatewayEndpoint, setGatewayEndpoint] = useState<string | null>(null);
const [gatewayCheckState, setGatewayCheckState] = useState<"idle" | "checking" | "ready" | "error">("idle");
const [gatewayCheckError, setGatewayCheckError] = useState<string | null>(null);
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const presentation: MapPresentation = { ...mapSettings, cacheRefresh: false };
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled });
setRendererRevision((value) => value + 1);
};
const handleCameraChange = useCallback((camera: MapCameraView) => {
mapCameraRef.current = camera;
setMapCamera(camera);
}, []);
useImperativeHandle(ref, () => ({
getLayout: () => ({
schemaVersion: 1,
pageId: "map",
settings: mapSettings,
mapHeight: Math.round(mapHeight),
camera: mapCameraRef.current ?? mapCamera,
}),
}), [mapCamera, mapHeight, mapSettings]);
const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return;
setSelectedId(entityId);
}, [selectable]);
const verifyGateway = useCallback(async () => {
setGatewayCheckState("checking");
setGatewayCheckError(null);
try {
const runtimeResponse = await fetch("/api/map/runtime-config");
const runtime = runtimeResponse.ok ? await runtimeResponse.json() as MapRuntimeConfig : null;
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
const healthResponse = await fetch(runtime.gatewayHealthUrl);
if (!healthResponse.ok) throw new Error("gateway_not_ready");
const health = await healthResponse.json() as MapGatewayHealth;
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
setGatewayHealth(health);
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl).origin);
setGatewayCheckState("ready");
} catch (error) {
const code = error instanceof Error ? error.message : "gateway_not_ready";
setGatewayHealth(null);
setGatewayEndpoint(null);
setGatewayCheckError(code === "persistent_cache_unavailable"
? "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой."
: "Platform Map Gateway недоступен: проверьте runtime profile и persistent volume.");
setGatewayCheckState("error");
}
}, []);
useEffect(() => {
if (!inspectorOpen && !layersOpen) return;
void verifyGateway();
const interval = window.setInterval(() => void verifyGateway(), 5000);
return () => window.clearInterval(interval);
}, [inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache;
const liveCacheSummary = liveCacheStatus
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} MB`
: "индекс ещё не получен";
const startResize = (event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = mapHeight;
const onMove = (move: globalThis.PointerEvent) => {
const maximum = Math.max(380, Math.round(window.innerHeight * 0.78));
setMapHeight(Math.max(360, Math.min(maximum, startHeight + move.clientY - startY)));
};
const onEnd = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onEnd);
window.removeEventListener("pointercancel", onEnd);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onEnd);
window.addEventListener("pointercancel", onEnd);
};
const inspectorSections = [
{
id: "map-base",
label: "Подложка и terrain",
description: "provider-neutral surface",
group: "Карта",
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" description="Рельеф — отдельный слой под imagery." onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
</>,
},
{
id: "map-atmosphere",
label: "Атмосфера и освещение",
description: "scene / color correction",
group: "Карта",
content: <>
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
</>,
},
{
id: "map-buildings",
label: "3D здания",
description: "3D Tiles / detail",
group: "Карта",
content: <>
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
{
id: "map-grid",
label: "Сетка и LOD",
description: "first adapter control",
group: "Слои",
content: <>
<Checker checked={mapSettings.gridVisible} label="3D-сетка" description="Сетка размещается над поверхностью и меняет шаг по высоте камеры." onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
<RangeControl label="LOD 1: шаг" value={mapSettings.gridLod1StepKm} min={1} max={10} formatValue={(value) => `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} />
<RangeControl label="LOD 2: до высоты" value={mapSettings.gridLod2MaxHeightKm} min={10} max={200} formatValue={(value) => `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} />
<RangeControl label="LOD 2: шаг" value={mapSettings.gridLod2StepKm} min={1} max={25} formatValue={(value) => `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} />
<RangeControl label="LOD 3: шаг" value={mapSettings.gridLod3StepKm} min={5} max={100} formatValue={(value) => `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} />
<RangeControl label="Радиус сетки" value={mapSettings.gridRadiusKm} min={5} max={150} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} />
<ControlRow label="Цвет линий"><ColorField label="Цвет линий сетки" value={mapSettings.gridColor} onChange={(gridColor) => updateMapSettings({ gridColor })} /></ControlRow>
<RangeControl label="Толщина линий" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} />
<RangeControl label="Прозрачность сетки" value={mapSettings.gridOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} />
<Checker checked={mapSettings.gridDotsEnabled} label="Точки в пересечениях" onChange={(gridDotsEnabled) => updateMapSettings({ gridDotsEnabled })} />
<RangeControl label="Размер точки" value={mapSettings.gridDotsSize} min={2} max={28} formatValue={(value) => `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} />
<ControlRow label="Цвет точек"><ColorField label="Цвет точек сетки" value={mapSettings.gridDotsColor} onChange={(gridDotsColor) => updateMapSettings({ gridDotsColor })} /></ControlRow>
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
</>,
},
{
id: "map-cache",
label: "TileCache",
description: "Platform Map Gateway",
group: "Хранение",
content: <>
<small className="catalog-map-inspector__note">По умолчанию официальный live-маршрут. Включите запись, чтобы одновременно смотреть карту и пополнять серверный cache.</small>
<Checker className="catalog-map-inspector__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? "Live + Cache" : "Live"}</strong></ControlRow>
<ControlRow label="Хранилище"><strong>Platform Map Gateway</strong></ControlRow>
<ControlRow label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow label="Записано"><strong>{liveCacheSummary}</strong></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? "Hybrid: provider остаётся официальным, новые tiles сохраняются в серверный cache." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayCheckState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
id: "map-selection",
label: "Выбранная сущность",
description: "selection contract",
group: "Данные",
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
</>,
},
];
return (
<div
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
aria-label="Map Page Cesium adapter"
>
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}>
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={setGatewayHealth} onCameraChange={handleCameraChange} initialCamera={mapCamera ?? undefined} presentation={presentation} />
</Suspense>
<div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={() => setLayersOpen((value) => !value)}><Icon name="grid" /></IconButton>
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
</div>
{layersOpen ? (
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
<div className="catalog-map-fixture__provider"><strong>Cesium World Imagery</strong><small>официальный live provider</small></div>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
</GlassSurface>
) : null}
{toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
<IconButton label="Обзор"><Icon name="globe" /></IconButton>
<IconButton label="Поиск"><Icon name="search" /></IconButton>
</div>
) : null}
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
<Window
open={inspectorOpen && Boolean(features.inspector)}
title="Настройки карты"
subtitle="MAP / draggable inspector"
placement="end"
draggable
closeOnBackdrop={false}
lockBodyScroll={false}
trapFocus={false}
onClose={() => setInspectorOpen(false)}
>
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
</Window>
</div>
);
});
+5
View File
@@ -1,4 +1,5 @@
import type { NodedcTheme } from "@nodedc/ui-core";
import type { MapPageLayout } from "./MapFixturePreview.js";
export const applicationManifestSchemaVersion = "0.1.0" as const;
@@ -19,6 +20,10 @@ export interface ApplicationPageManifest {
order: number;
};
features: Record<string, boolean>;
/** Runtime configuration belongs to the page instance, never to the visual template. */
layout?: {
map?: MapPageLayout;
};
}
export interface ApplicationManifestV01 {
+307
View File
@@ -552,6 +552,313 @@ textarea {
.catalog-map-template-preview--application { min-height: 42rem; }
.catalog-map-fixture {
position: relative;
height: var(--catalog-map-height, 30rem);
min-height: 22.5rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background:
radial-gradient(circle at 70% 28%, color-mix(in srgb, var(--catalog-accent) 16%, transparent), transparent 32%),
linear-gradient(145deg, color-mix(in srgb, var(--nodedc-nested-surface) 88%, #18322e), var(--nodedc-nested-surface));
isolation: isolate;
}
.catalog-map-fixture--expanded { min-height: 22.5rem; }
.catalog-map-fixture__loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
}
.catalog-cesium-map,
.catalog-cesium-map__canvas,
.catalog-cesium-map__canvas > .cesium-viewer,
.catalog-cesium-map__canvas .cesium-viewer-cesiumWidgetContainer,
.catalog-cesium-map__canvas .cesium-widget,
.catalog-cesium-map__canvas canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.catalog-cesium-map__canvas canvas {
touch-action: none;
}
/* Sandbox-only visual mode. Restore provider credits before any external or
commercial deployment; the runtime still retains the attribution metadata. */
.catalog-cesium-map__credits,
.catalog-cesium-map__credits .cesium-widget-credits,
.catalog-cesium-map__credits .cesium-credit-logoContainer,
.catalog-cesium-map__credits .cesium-credit-expand-link,
.catalog-cesium-map__credits .cesium-credit-textContainer {
display: none;
}
.catalog-map-fixture__grid {
position: absolute;
inset: 0;
opacity: 0.32;
background-image:
radial-gradient(circle, color-mix(in srgb, var(--catalog-accent) 54%, transparent) 1px, transparent 1.4px),
linear-gradient(color-mix(in srgb, var(--nodedc-text-muted) 18%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--nodedc-text-muted) 18%, transparent) 1px, transparent 1px);
background-position: 0 0, center, center;
background-size: 1.25rem 1.25rem, 5rem 5rem, 5rem 5rem;
}
.catalog-map-fixture__geometry {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.catalog-map-fixture__zone {
fill: color-mix(in srgb, var(--catalog-accent) 18%, transparent);
stroke: color-mix(in srgb, var(--catalog-accent) 70%, transparent);
stroke-width: 0.45;
vector-effect: non-scaling-stroke;
}
.catalog-map-fixture__track,
.catalog-map-fixture__route,
.catalog-map-fixture__trace {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.catalog-map-fixture__track { stroke: color-mix(in srgb, #8f72dc 74%, var(--nodedc-text-primary)); stroke-width: 2; }
.catalog-map-fixture__route { stroke: var(--catalog-accent); stroke-width: 4; }
.catalog-map-fixture__trace { stroke: color-mix(in srgb, var(--catalog-accent) 58%, transparent); stroke-width: 2; stroke-dasharray: 5 5; }
.catalog-map-fixture__place {
position: absolute;
z-index: 1;
translate: -50% -50%;
color: color-mix(in srgb, var(--nodedc-text-primary) 72%, transparent);
font-size: clamp(1.2rem, 2.4vw, 2.35rem);
font-weight: 700;
letter-spacing: 0.16em;
pointer-events: none;
}
.catalog-map-fixture__entity {
position: absolute;
z-index: 4;
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
border: 0;
background: transparent;
color: var(--nodedc-text-primary);
translate: -1.35rem -50%;
cursor: pointer;
}
.catalog-map-fixture__pin {
display: grid;
width: 2.35rem;
height: 2.35rem;
flex: 0 0 auto;
place-items: center;
border: 0.2rem solid color-mix(in srgb, var(--nodedc-text-primary) 84%, transparent);
border-radius: 50%;
background: var(--catalog-accent);
color: var(--nodedc-text-on-accent);
box-shadow: 0 0 0 0.45rem color-mix(in srgb, var(--catalog-accent) 18%, transparent);
}
.catalog-map-fixture__entity[data-kind="station"] .catalog-map-fixture__pin {
background: color-mix(in srgb, #8f72dc 82%, var(--nodedc-panel-item-bg));
}
.catalog-map-fixture__entity[data-active] .catalog-map-fixture__pin {
box-shadow: 0 0 0 0.7rem color-mix(in srgb, var(--catalog-accent) 26%, transparent), 0 0 1.6rem color-mix(in srgb, var(--catalog-accent) 58%, transparent);
}
.catalog-map-fixture__label {
max-width: 12rem;
overflow: hidden;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-panel-item-bg);
padding: 0.5rem 0.72rem;
box-shadow: var(--nodedc-glass-control-shadow);
font-size: var(--nodedc-font-size-xs);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-fixture__status {
position: absolute;
z-index: 5;
top: 1rem;
left: 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-panel-item-bg);
padding: 0.5rem 0.8rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-map-fixture__status span {
color: var(--nodedc-status-success);
font-weight: 800;
text-transform: uppercase;
}
.catalog-map-fixture__actions {
position: absolute;
z-index: 8;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.45rem;
}
.catalog-map-fixture__actions .nodedc-icon-button,
.catalog-map-fixture__toolbar .nodedc-icon-button {
border: 1px solid var(--nodedc-glass-outline);
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 76%, transparent);
box-shadow: var(--nodedc-glass-control-shadow);
backdrop-filter: blur(var(--nodedc-blur-control));
}
.catalog-map-fixture__actions .nodedc-icon-button:hover,
.catalog-map-fixture__toolbar .nodedc-icon-button:hover {
background: var(--nodedc-glass-control-hover);
}
.catalog-map-fixture__actions .nodedc-icon-button[data-active],
.catalog-map-fixture__toolbar .nodedc-icon-button[data-active] {
border-color: transparent;
background: var(--nodedc-glass-control-active);
color: var(--nodedc-glass-control-active-text);
}
.catalog-map-fixture__layers {
position: absolute;
z-index: 8;
top: 4.8rem;
right: 1rem;
display: grid;
width: min(20rem, calc(100% - 2rem));
gap: 0.55rem;
box-shadow: var(--nodedc-glass-dropdown-shadow);
}
.catalog-map-fixture__layers-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.catalog-map-fixture__layers .nodedc-checker {
min-height: 3rem;
}
.catalog-map-fixture__provider {
display: grid;
gap: 0.16rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-glass-control-bg);
padding: 0.82rem 0.9rem;
}
.catalog-map-fixture__provider small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-map-fixture__cache-toggle,
.catalog-map-inspector__cache-toggle {
min-height: 3.4rem;
}
.catalog-map-fixture__toolbar {
position: absolute;
z-index: 8;
bottom: 1rem;
left: 50%;
display: flex;
gap: 0.35rem;
border-radius: var(--nodedc-radius-circle);
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 76%, transparent);
padding: 0.4rem;
translate: -50% 0;
backdrop-filter: blur(var(--nodedc-blur-control));
box-shadow: var(--nodedc-glass-dropdown-shadow);
}
.catalog-map-inspector__note {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.catalog-map-inspector__note--error {
color: rgb(var(--nodedc-danger-rgb));
font-weight: var(--nodedc-font-weight-strong);
}
.catalog-map-fixture__assistant {
position: absolute;
z-index: 9;
right: 1rem;
bottom: 1rem;
display: grid;
max-width: 22rem;
gap: 0.35rem;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-modal-surface);
padding: 1rem;
box-shadow: var(--nodedc-modal-shadow);
backdrop-filter: blur(var(--nodedc-blur-modal));
}
.catalog-map-fixture__assistant span { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.catalog-map-fixture__resize {
position: absolute;
z-index: 9;
right: 0;
bottom: 0;
display: grid;
width: 2.9rem;
height: 2.9rem;
place-items: end;
border: 0;
background: linear-gradient(135deg, transparent 50%, color-mix(in srgb, var(--nodedc-panel-item-bg) 86%, transparent) 50%);
cursor: ns-resize;
}
.catalog-map-fixture__resize > span {
width: 1.05rem;
height: 1.05rem;
margin: 0.48rem;
border-right: 1px solid color-mix(in srgb, var(--nodedc-text-primary) 68%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--nodedc-text-primary) 68%, transparent);
background: repeating-linear-gradient(135deg, transparent 0 0.2rem, color-mix(in srgb, var(--nodedc-text-primary) 58%, transparent) 0.2rem 0.26rem, transparent 0.26rem 0.4rem);
opacity: 0.8;
}
.catalog-map-fixture__resize:hover > span { opacity: 1; }
.catalog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));