feat(module-studio): add configurable map page
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user