From 1a2ca8c82c1032930b99764807732f5ee219ca15 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 13 Jul 2026 17:14:34 +0300 Subject: [PATCH] feat(module-studio): add configurable map page --- .env.example | 5 + .gitignore | 3 + apps/catalog/package.json | 4 +- apps/catalog/src/CatalogApp.tsx | 101 +++- apps/catalog/src/CesiumMapRenderer.tsx | 572 ++++++++++++++++++ apps/catalog/src/MapFixturePreview.tsx | 356 +++++++++++ apps/catalog/src/applicationManifest.ts | 5 + apps/catalog/src/styles.css | 307 ++++++++++ apps/catalog/vite.config.ts | 18 +- docs/MAP_TEMPLATE.md | 80 +++ docs/MODULE_STUDIO.md | 10 + package-lock.json | 508 +++++++++++++++- packages/page-patterns/src/index.ts | 17 + packages/ui-core/styles.css | 10 +- .../fixtures/map/map-empty-offline-v0.1.json | 31 + .../fixtures/map/map-lod-policies-v0.1.json | 71 +++ .../fixtures/map/map-operational-v0.1.json | 128 ++++ registry/pages.json | 11 +- registry/registry.json | 3 + .../schemas/map-lod-policy-v0.1.schema.json | 59 ++ .../map-scene-fixture-v0.1.schema.json | 223 +++++++ scripts/validate-registry.mjs | 82 +++ server/catalog-server.mjs | 127 ++++ 23 files changed, 2700 insertions(+), 31 deletions(-) create mode 100644 .env.example create mode 100644 apps/catalog/src/CesiumMapRenderer.tsx create mode 100644 apps/catalog/src/MapFixturePreview.tsx create mode 100644 docs/MAP_TEMPLATE.md create mode 100644 registry/fixtures/map/map-empty-offline-v0.1.json create mode 100644 registry/fixtures/map/map-lod-policies-v0.1.json create mode 100644 registry/fixtures/map/map-operational-v0.1.json create mode 100644 registry/schemas/map-lod-policy-v0.1.schema.json create mode 100644 registry/schemas/map-scene-fixture-v0.1.schema.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..865cf15 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Runtime secrets are injected by the deployment environment and never committed. +CESIUM_ION_TOKEN= +NODEDC_MAP_GATEWAY_URL= +CESIUM_ION_ASSET_ALLOWLIST=1,96188 +CESIUM_GAUSSIAN_SPLAT_ASSET_ID= diff --git a/.gitignore b/.gitignore index 55ed5f4..df65446 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ coverage/ *.tsbuildinfo runtime-data/ +.env +.env.* +!.env.example diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 098dc4a..23dc4f9 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -12,6 +12,7 @@ "@nodedc/page-patterns": "0.1.0", "@nodedc/ui-core": "0.6.0", "@nodedc/ui-react": "0.6.0", + "cesium": "^1.143.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -20,6 +21,7 @@ "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.6.0", "typescript": "^5.8.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vite-plugin-static-copy": "^4.1.1" } } diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 3c240e0..1b97152 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -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(null); const [applicationSaveState, setApplicationSaveState] = useState("idle"); const [applicationError, setApplicationError] = useState(""); + const [mapTemplateLayout, setMapTemplateLayout] = useState(null); + const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); + const mapTemplatePreviewRef = useRef(null); + const applicationMapPreviewRef = useRef(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" ? ( +
+ [feature.id, feature.required ? true : feature.defaultVisible]))} + /> +
+ + Карта масштабируется по высоте за правый нижний угол. Настройки — через Inspector. +
+
+ ) : (
-
-
-
-
-
-
- {template.actions.map((action) => )} -
-
- Inspector - Selection - Layers - Appearance -
-
+ [feature.id, feature.required ? true : feature.defaultVisible]))} />
Только утверждённые features и slots — без свободного canvas. @@ -1548,6 +1601,7 @@ export function CatalogApp() {
+ ) ); const renderApplicationPage = () => { @@ -1562,14 +1616,7 @@ export function CatalogApp() { })); return (
-
-
-
-
-
- {page.features.toolbar ?
{page.features.assistant ? : null}
: null} - {page.features.inspector ?
InspectorSelectionLayersAppearance
: null} -
+ {applicationMode === "edit" ? (
@@ -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} >
{renderPageTemplate(activePageTemplate)}
diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx new file mode 100644 index 0000000..1ddbad5 --- /dev/null +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -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(null); + const creditContainerRef = useRef(null); + const viewerRef = useRef(null); + const imageryLayerRef = useRef(null); + const buildingsRef = useRef(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 : 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; + }; + 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 ( +
+
+ + ); +} diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx new file mode 100644 index 0000000..c273ca6 --- /dev/null +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -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; +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(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(() => ({ ...initialMapSettings, ...initialLayout?.settings })); + const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470)); + const [mapCamera, setMapCamera] = useState(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(initialLayout?.camera ?? fallbackMapCamera); + const [rendererRevision, setRendererRevision] = useState(0); + const [gatewayHealth, setGatewayHealth] = useState(null); + const [gatewayEndpoint, setGatewayEndpoint] = useState(null); + const [gatewayCheckState, setGatewayCheckState] = useState<"idle" | "checking" | "ready" | "error">("idle"); + const [gatewayCheckError, setGatewayCheckError] = useState(null); + const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; + const presentation: MapPresentation = { ...mapSettings, cacheRefresh: false }; + const updateMapSettings = (patch: Partial) => 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) => { + 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: <> + Cesium World Imagery + Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. + updateMapSettings({ terrainEnabled })} /> + `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> + updateMapSettings({ monochrome })} /> + updateMapSettings({ monochromeColor })} /> + `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} /> + `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} /> + `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} /> + `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} /> + `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} /> + `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} /> + updateMapSettings({ globeColor })} /> + updateMapSettings({ backgroundColor })} /> + , + }, + { + id: "map-atmosphere", + label: "Атмосфера и освещение", + description: "scene / color correction", + group: "Карта", + content: <> + updateMapSettings({ atmosphereEnabled })} /> + `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> + `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} /> + `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} /> + updateMapSettings({ fogEnabled })} /> + `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} /> + updateMapSettings({ sunEnabled })} /> + `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} /> + `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} /> + updateMapSettings({ shadowsEnabled })} /> + , + }, + { + id: "map-buildings", + label: "3D здания", + description: "3D Tiles / detail", + group: "Карта", + content: <> + updateMapSettings({ buildingsVisible })} /> + updateMapSettings({ buildingsColor })} /> + `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} /> + `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> + , + }, + { + id: "map-grid", + label: "Сетка и LOD", + description: "first adapter control", + group: "Слои", + content: <> + updateMapSettings({ gridVisible })} /> + updateMapSettings({ gridLodEnabled })} /> + `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> + `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> + `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} /> + `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} /> + `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} /> + `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} /> + `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> + updateMapSettings({ gridColor })} /> + `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> + `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} /> + updateMapSettings({ gridDotsEnabled })} /> + `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} /> + updateMapSettings({ gridDotsColor })} /> + `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> + , + }, + { + id: "map-cache", + label: "TileCache", + description: "Platform Map Gateway", + group: "Хранение", + content: <> + По умолчанию — официальный live-маршрут. Включите запись, чтобы одновременно смотреть карту и пополнять серверный cache. + + {mapSettings.cacheEnabled ? "Live + Cache" : "Live"} + Platform Map Gateway + {gatewayEndpoint ?? "runtime profile · не проверено"} + {liveCacheSummary} + + Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} + {mapSettings.cacheEnabled ? "Hybrid: provider остаётся официальным, новые tiles сохраняются в серверный cache." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."} + {gatewayCheckState === "error" ? {gatewayCheckError} : null} + , + }, + { + id: "map-selection", + label: "Выбранная сущность", + description: "selection contract", + group: "Данные", + content: <> + {selected?.title ?? "Нет выбора"} + {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} + , + }, + ]; + + return ( +
+ Загрузка карты…
}> + + + +
+ setInspectorOpen(true)}> + setLayersOpen((value) => !value)}> + {features.toolbar ? setToolbarOpen((value) => !value)}> : null} + {features.assistant ? setAssistantOpen((value) => !value)}> : null} +
+ + {layersOpen ? ( + +
Слои карты setLayersOpen(false)}>
+
Cesium World Imageryофициальный live provider
+ updateMapSettings({ terrainEnabled })} /> + updateMapSettings({ buildingsVisible })} /> + updateMapSettings({ gridVisible })} /> + Live Cache: {liveCacheSummary} + +
+ ) : null} + + {toolbarOpen ? ( +
+ + +
+ ) : null} + + {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} + + + setInspectorOpen(false)} + > + + +
+ ); +}); diff --git a/apps/catalog/src/applicationManifest.ts b/apps/catalog/src/applicationManifest.ts index 74337ea..6c989a2 100644 --- a/apps/catalog/src/applicationManifest.ts +++ b/apps/catalog/src/applicationManifest.ts @@ -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; + /** Runtime configuration belongs to the page instance, never to the visual template. */ + layout?: { + map?: MapPageLayout; + }; } export interface ApplicationManifestV01 { diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index ec6dcc1..35ffbb1 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -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)); diff --git a/apps/catalog/vite.config.ts b/apps/catalog/vite.config.ts index 3842a0c..7ffe30a 100644 --- a/apps/catalog/vite.config.ts +++ b/apps/catalog/vite.config.ts @@ -1,10 +1,24 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { viteStaticCopy } from "vite-plugin-static-copy"; + +const cesiumBuild = new URL("../../node_modules/cesium/Build/Cesium", import.meta.url).pathname; export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + viteStaticCopy({ + targets: ["Workers", "ThirdParty", "Assets", "Widgets"].map((directory) => ({ + src: `${cesiumBuild}/${directory}/**/*`, + dest: `cesium/${directory}`, + rename: { stripBase: 5 }, + })), + }), + ], + define: { + CESIUM_BASE_URL: JSON.stringify("/cesium/"), + }, build: { sourcemap: true, }, }); - diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md new file mode 100644 index 0000000..a21e7b9 --- /dev/null +++ b/docs/MAP_TEMPLATE.md @@ -0,0 +1,80 @@ +# Map Template + +`Map Page 0.1.0` — первый готовый функциональный шаблон NDC Module Studio. Он описывает пространственный интерфейс через стабильные доменные сущности, а не через API конкретного renderer. + +## Границы ответственности + +- Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями. +- Map Scene Fixture задаёт минимальный проверочный набор пространственных сущностей и состояний. +- Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility. +- Platform capability binding позже связывает слоты шаблона с NDC runtime и потоками данных. +- Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer. + +Engine/NDC остаётся средой создания и исполнения автоматизаций. Он не является владельцем визуального языка Map Template и не экспортирует в Studio внутренние Cesium objects. + +## Зафиксированные сущности v0.1 + +- viewport и базовые capabilities; +- base/buildings/grid layers; +- planet-scale grid с LOD bands; +- place targets; +- moving objects и traces; +- универсальные pins и labels; +- rail/metro/stop/terminal stations; +- routes и track segments; +- polygon/multipolygon zones; +- selection; +- ready/loading/empty/stale/error/offline states. + +Размеры и внешний вид разновидностей сущностей задаются ссылками на `styleProfiles`. Это позволяет одному доменному типу иметь разные подтверждённые варианты, не создавая новый renderer-specific тип. + +## Acceptance fixtures + +- `registry/fixtures/map/map-operational-v0.1.json` — минимальная рабочая сцена с сеткой, транспортом, станциями, маршрутом, железнодорожным путём, зоной и selection. +- `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий. + +Fixtures малы и детерминированы. Большие геоданные, tile cache, credentials и реальные streaming snapshots в репозиторий гайдлайнов не входят. + +## Sandbox credit overlay + +Во внутренней sandbox-сборке Map Page временно скрывает визуальный credit overlay renderer, потому что он перекрывает рабочую композицию во время настройки шаблона. Метаданные provider attribution не удаляются из runtime. Перед любым внешним, пользовательским или коммерческим развёртыванием overlay должен быть возвращён в соответствии с условиями выбранного provider. Это зафиксированный технический долг, а не правило production-интерфейса. + +## Cesium adapter 1.143 + +Текущий reference adapter использует CesiumJS `1.143.0`. Он загружается отдельным lazy chunk только при открытии Map Page и преобразует fixture в реальные Cesium entities: points, labels, routes, tracks, zones и selection. Без настроенного ion gateway используется development fallback `WGS84 globe + OpenStreetMap imagery`. + +Server-side runtime contract: + +- `GET /api/map/runtime-config` сообщает версию renderer и readiness возможностей, но не возвращает master token; +- `GET /api/map/ion/assets/:assetId/endpoint` разрешает только allowlisted assets и обменивает server-side master token на asset-scoped endpoint token; +- `CESIUM_ION_TOKEN` передаётся процессу через deployment environment/secret; +- `CESIUM_ION_ASSET_ALLOWLIST` ограничивает terrain/buildings/Gaussian assets; +- production endpoint переезжает в `platform/services/map-gateway`. + +Development gateway уже позволяет проверить World Terrain и 3D Buildings, не сериализуя master token во frontend. Asset-scoped token является частью официального ion endpoint contract и ограничен конкретным asset. + +## TileCache boundary + +Runtime tile cache не является исходным кодом и не хранится в Git или Docker image layer. Целевой `map-gateway` использует отдельный persistent volume либо object storage и поддерживает: + +- online-record и offline-fallback; +- нормализацию cache key без credentials; +- upstream allowlist и SSRF protection; +- ограничения размера объекта и общего объёма; +- LRU/eviction, stats, health и наблюдаемость; +- отдельный export/import версионируемых seed snapshots при необходимости. + +Существующий Engine cache используется как donor поведения, но его runtime-файлы не копируются в Studio. + +## Следующие контракты + +`registry/schemas/map-lod-policy-v0.1.schema.json` и `registry/fixtures/map/map-lod-policies-v0.1.json` фиксируют первый provider-neutral LOD/visibility contract. Единственная метрика v0.1 — расстояние камеры в метрах; интервалы полуоткрытые `[minRange, maxRange)`, а `hysteresisRatio` предотвращает дрожание на границах. Renderer переводит эту политику в собственный API, но не меняет её смысл. + +Дальше: + +1. уточнить единый Label Style contract и варианты размеров уже на живой сцене; +2. спроектировать batching/instancing contract для больших потоков объектов и геозон; +3. вынести development gateway в `platform/services/map-gateway` и подключить persistent cache; +4. добавить реальный Gaussian Splat 3D Tiles acceptance asset; +5. добавить capability binding между NDC runtime и slots страницы; +6. оформить renderer adapter capability matrix для Cesium и будущих providers. diff --git a/docs/MODULE_STUDIO.md b/docs/MODULE_STUDIO.md index 04eceb6..4773c68 100644 --- a/docs/MODULE_STUDIO.md +++ b/docs/MODULE_STUDIO.md @@ -16,6 +16,16 @@ Шаблон не содержит Cesium token, Engine workflow, React component tree или свободные координаты элементов. Renderer/Cesium adapter и Capability Bindings являются отдельными следующими слоями. +## Map Scene Fixture v0.1 + +Каноническая JSON Schema: `registry/schemas/map-scene-fixture-v0.1.schema.json`. Проверочные сцены перечислены прямо в контракте `map@0.1.0` и лежат в `registry/fixtures/map`. + +Fixture фиксирует provider-neutral viewport, capabilities, общие style profiles, слои, города, движущиеся объекты, станции, маршруты, пути, зоны, selection и ожидаемое поведение. Он не хранит Cesium types, токены, renderer instances, Engine workflow или transport payload конкретного источника. + +`map-operational-v0.1.json` проверяет содержательную сцену и переиспользуемый язык подписей/маркеров. `map-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Registry validation дополнительно проверяет уникальность id, ссылки на style profiles, selection и непрерывность LOD-диапазонов. + +LOD вынесен в отдельный контракт `registry/schemas/map-lod-policy-v0.1.schema.json` и policy set `registry/fixtures/map/map-lod-policies-v0.1.json`. Он использует расстояние камеры в метрах, полуоткрытые диапазоны и hysteresis, поэтому не зависит от Cesium `DistanceDisplayCondition`. Дальнейшая калибровка bands выполняется на живом Map shell, а не в отрыве от визуального результата. + ## Application Manifest v0.1 Каноническая JSON Schema: `registry/schemas/application-manifest-v0.1.schema.json`. diff --git a/package-lock.json b/package-lock.json index cde33c5..c1d6541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@nodedc/page-patterns": "0.1.0", "@nodedc/ui-core": "0.6.0", "@nodedc/ui-react": "0.6.0", + "cesium": "^1.143.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -38,7 +39,8 @@ "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.6.0", "typescript": "^5.8.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vite-plugin-static-copy": "^4.1.1" } }, "node_modules/@babel/code-frame": { @@ -323,6 +325,57 @@ "node": ">=6.9.0" } }, + "node_modules/@cesium/engine": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-26.1.0.tgz", + "integrity": "sha512-WzeJEcmGD7/htBJ8JEZt95OgZjErPwUHqyAlJY/mCzmDm7hfwpuucLBSUhiynWW3khqBT1C7dhibjb7tdic7oA==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/wasm-splats": "^0.1.0-alpha.2", + "@spz-loader/core": "0.3.1", + "@tweenjs/tween.js": "^25.0.0", + "@zip.js/zip.js": "^2.8.1", + "autolinker": "^4.0.0", + "bitmap-sdf": "^1.0.3", + "dompurify": "^3.3.0", + "draco3d": "^1.5.1", + "earcut": "3.0.2", + "grapheme-splitter": "^1.0.4", + "jsep": "^1.3.8", + "kdbush": "^4.0.1", + "ktx-parse": "^1.0.0", + "lerc": "^2.0.0", + "mersenne-twister": "^1.1.0", + "meshoptimizer": "^1.0.1", + "pako": "^3.0.0", + "protobufjs": "^8.6.5", + "rbush": "^4.0.1", + "topojson-client": "^3.1.0", + "urijs": "^1.19.7" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cesium/wasm-splats": { + "version": "0.1.0-alpha.2", + "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", + "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==", + "license": "Apache-2.0" + }, + "node_modules/@cesium/widgets": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-16.1.0.tgz", + "integrity": "sha512-c+jt8F3YIZs2x6+X+HS4q0S9nd2jVcbPVShUmlrDK1s+C7IlEvXceDGrkjivHRElAVCzfaWEEwY1g7Lg5HCGmA==", + "license": "Apache-2.0", + "dependencies": { + "@cesium/engine": "^26.1.0", + "nosleep.js": "^0.12.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1249,6 +1302,22 @@ "win32" ] }, + "node_modules/@spz-loader/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.1.tgz", + "integrity": "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16", + "pnpm": ">=8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1331,6 +1400,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1352,6 +1428,56 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/autolinker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", + "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "pnpm": ">=10.10.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -1365,6 +1491,38 @@ "node": ">=6.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -1420,6 +1578,56 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cesium": { + "version": "1.143.0", + "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.143.0.tgz", + "integrity": "sha512-EHz61NBp/2UPYE4iWCZ+YK9Jcb0d2hzRfqkj4DRaUM3cJmA9hE2FTmcPPZ2iL7w+W0YUScI0gc9XeSTXlPIQtA==", + "license": "Apache-2.0", + "workspaces": [ + "packages/engine", + "packages/widgets", + "packages/sandcastle" + ], + "dependencies": { + "@cesium/engine": "^26.1.0", + "@cesium/widgets": "^16.1.0", + "protobufjs": "^8.6.5" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1452,6 +1660,27 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -1529,6 +1758,19 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1554,6 +1796,71 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1561,6 +1868,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1587,6 +1903,30 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/ktx-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", + "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", + "license": "MIT" + }, + "node_modules/lerc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", + "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", + "license": "Apache-2.0" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1606,6 +1946,18 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/mersenne-twister": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", + "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.2.0.tgz", + "integrity": "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1642,6 +1994,51 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nosleep.js": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", + "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", + "license": "MIT" + }, + "node_modules/p-map": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pako/-/pako-3.0.1.tgz", + "integrity": "sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1691,6 +2088,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.0.tgz", + "integrity": "sha512-uu52JNxLh3vsL7tXU/h0gDaywufvuUCTbGSi0NKQKBZ2ZopkmrWQJSQO/EFqzu/5YhiwgVM8rq/a/iVpx4eZ0g==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1722,6 +2146,32 @@ "node": ">=0.10.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1810,6 +2260,33 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1868,6 +2345,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1943,6 +2426,29 @@ } } }, + "node_modules/vite-plugin-static-copy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz", + "integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.17" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/packages/page-patterns/src/index.ts b/packages/page-patterns/src/index.ts index 069bef0..2182402 100644 --- a/packages/page-patterns/src/index.ts +++ b/packages/page-patterns/src/index.ts @@ -26,6 +26,13 @@ export type PageTemplateAction = { feature?: string; }; +export type PageTemplateContracts = { + fixtureSchema: string; + acceptanceFixtures: readonly string[]; + lodPolicySchema?: string; + lodPolicies?: string; +}; + export type PageTemplateDefinition = { schemaVersion: "0.1.0"; id: string; @@ -42,6 +49,7 @@ export type PageTemplateDefinition = { features: PageTemplateFeature[]; slots: PageTemplateSlot[]; actions: PageTemplateAction[]; + contracts?: PageTemplateContracts; }; export const mapPageTemplate = { @@ -76,6 +84,15 @@ export const mapPageTemplate = { { id: "toggle-toolbar", label: "Toolbar", command: "map.toolbar.toggle", feature: "toolbar" }, { id: "open-assistant", label: "Assistant", command: "assistant.open", feature: "assistant" }, ], + contracts: { + fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json", + acceptanceFixtures: [ + "registry/fixtures/map/map-operational-v0.1.json", + "registry/fixtures/map/map-empty-offline-v0.1.json", + ], + lodPolicySchema: "registry/schemas/map-lod-policy-v0.1.schema.json", + lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json", + }, } as const satisfies PageTemplateDefinition; export const pageTemplates = [mapPageTemplate] as const satisfies readonly PageTemplateDefinition[]; diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index c77b9bc..7da060a 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -2582,18 +2582,24 @@ textarea.nodedc-field__control { cursor: pointer; } -.nodedc-inspector__section-trigger:hover, -.nodedc-inspector__section-trigger[data-open="true"] { +.nodedc-inspector__section-trigger:not([data-open="true"]):hover { background: var(--nodedc-glass-control-hover); color: var(--nodedc-text-primary); } +.nodedc-inspector__section-trigger[data-open="true"], .nodedc-inspector__section-trigger[data-active="true"], .nodedc-inspector__section-trigger[data-tone="accent"] { background: rgb(var(--nodedc-accent-rgb)); color: rgb(var(--nodedc-on-accent-rgb)); } +.nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-description, +.nodedc-inspector__section-trigger[data-active="true"] .nodedc-inspector__section-description, +.nodedc-inspector__section-trigger[data-tone="accent"] .nodedc-inspector__section-description { + color: rgb(var(--nodedc-on-accent-rgb)); +} + .nodedc-inspector__section-label { overflow: hidden; font-size: var(--nodedc-font-size-md); diff --git a/registry/fixtures/map/map-empty-offline-v0.1.json b/registry/fixtures/map/map-empty-offline-v0.1.json new file mode 100644 index 0000000..ee1e905 --- /dev/null +++ b/registry/fixtures/map/map-empty-offline-v0.1.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": "0.1.0", + "fixtureId": "map.empty-offline", + "template": { "id": "map", "version": "0.1.0" }, + "title": "Empty offline spatial scene", + "description": "Acceptance state proving that the page shell remains usable without provider data or live entity streams.", + "state": "offline", + "viewport": { + "center": [37.6176, 55.7558, 250], + "heading": 0, + "pitch": -45, + "range": 18000 + }, + "capabilities": ["camera", "selection"], + "styleProfiles": [], + "scene": { + "layers": [], + "places": [], + "movingObjects": [], + "stations": [], + "routes": [], + "tracks": [], + "zones": [] + }, + "selection": { "entityId": null }, + "acceptance": [ + { "id": "state.offline-shell", "expect": ["Header, navigation, Inspector entry and retry action remain available."] }, + { "id": "state.no-phantom-data", "expect": ["The renderer does not retain entities from a previously opened scene."] }, + { "id": "state.explainable", "expect": ["The page distinguishes offline from loading, empty and error states."] } + ] +} diff --git a/registry/fixtures/map/map-lod-policies-v0.1.json b/registry/fixtures/map/map-lod-policies-v0.1.json new file mode 100644 index 0000000..54bddca --- /dev/null +++ b/registry/fixtures/map/map-lod-policies-v0.1.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": "0.1.0", + "template": { "id": "map", "version": "0.1.0" }, + "metric": "camera-range-meters", + "intervalRule": "min-inclusive-max-exclusive", + "policies": [ + { + "id": "grid.planetary", + "target": "grid", + "outsideRange": "nearest-band", + "hysteresisRatio": 0.08, + "bands": [ + { "id": "local", "minRange": 0, "maxRange": 40000, "representation": "geometry", "styleVariant": "grid-local", "density": 1 }, + { "id": "district", "minRange": 40000, "maxRange": 180000, "representation": "geometry", "styleVariant": "grid-district", "density": 0.5 }, + { "id": "regional", "minRange": 180000, "maxRange": 700000, "representation": "geometry", "styleVariant": "grid-regional", "density": 0.2 }, + { "id": "continental", "minRange": 700000, "maxRange": 3000000, "representation": "geometry", "styleVariant": "grid-continental", "density": 0.08 }, + { "id": "planetary", "minRange": 3000000, "maxRange": 50000000, "representation": "geometry", "styleVariant": "grid-planetary", "density": 0.02 } + ] + }, + { + "id": "place.city", + "target": "place", + "outsideRange": "hidden", + "hysteresisRatio": 0.1, + "bands": [ + { "id": "regional", "minRange": 100000, "maxRange": 900000, "representation": "standard", "styleVariant": "place-medium" }, + { "id": "planetary", "minRange": 900000, "maxRange": 50000000, "representation": "summary", "styleVariant": "place-large" } + ] + }, + { + "id": "moving-object.transport", + "target": "moving-object", + "outsideRange": "hidden", + "hysteresisRatio": 0.08, + "bands": [ + { "id": "detail", "minRange": 0, "maxRange": 15000, "representation": "detailed", "styleVariant": "transport-detail", "maxItems": 2500 }, + { "id": "standard", "minRange": 15000, "maxRange": 70000, "representation": "standard", "styleVariant": "transport-standard", "maxItems": 1500 }, + { "id": "cluster", "minRange": 70000, "maxRange": 300000, "representation": "cluster", "styleVariant": "transport-cluster", "maxItems": 400 } + ] + }, + { + "id": "station.transport", + "target": "station", + "outsideRange": "hidden", + "hysteresisRatio": 0.08, + "bands": [ + { "id": "detail", "minRange": 0, "maxRange": 30000, "representation": "detailed", "styleVariant": "station-detail" }, + { "id": "standard", "minRange": 30000, "maxRange": 120000, "representation": "standard", "styleVariant": "station-standard" } + ] + }, + { + "id": "zone.operational", + "target": "zone", + "outsideRange": "hidden", + "hysteresisRatio": 0.05, + "bands": [ + { "id": "geometry", "minRange": 0, "maxRange": 100000, "representation": "geometry", "styleVariant": "zone-geometry", "maxItems": 500 }, + { "id": "summary", "minRange": 100000, "maxRange": 500000, "representation": "summary", "styleVariant": "zone-summary", "maxItems": 100 } + ] + }, + { + "id": "track.rail", + "target": "track", + "outsideRange": "hidden", + "hysteresisRatio": 0.05, + "bands": [ + { "id": "geometry", "minRange": 0, "maxRange": 600000, "representation": "geometry", "styleVariant": "rail-track" } + ] + } + ] +} diff --git a/registry/fixtures/map/map-operational-v0.1.json b/registry/fixtures/map/map-operational-v0.1.json new file mode 100644 index 0000000..3cb34ec --- /dev/null +++ b/registry/fixtures/map/map-operational-v0.1.json @@ -0,0 +1,128 @@ +{ + "schemaVersion": "0.1.0", + "fixtureId": "map.operational", + "template": { "id": "map", "version": "0.1.0" }, + "title": "Operational spatial scene", + "description": "Minimal provider-neutral scene covering the reusable spatial entities already proven in NODE.DC Engine.", + "state": "ready", + "viewport": { + "center": [37.6176, 55.7558, 250], + "heading": 15, + "pitch": -42, + "range": 18000 + }, + "capabilities": ["camera", "base-layer", "buildings", "grid", "labels", "pins", "moving-objects", "traces", "routes", "tracks", "stations", "zones", "selection"], + "styleProfiles": [ + { "id": "layer.base.neutral", "kind": "layer", "variant": "neutral-dark", "opacity": 1 }, + { "id": "layer.buildings.default", "kind": "layer", "variant": "extruded-neutral", "opacity": 0.86 }, + { "id": "grid.planetary.major", "kind": "grid", "variant": "major", "color": "#8f72dc", "opacity": 0.48, "width": 1 }, + { "id": "label.place.large", "kind": "label", "variant": "place-large", "color": "#ffffff", "opacity": 1, "size": 28 }, + { "id": "label.entity.compact", "kind": "label", "variant": "entity-compact", "color": "#ffffff", "opacity": 0.92, "size": 13 }, + { "id": "label.station.medium", "kind": "label", "variant": "station-medium", "color": "#ffffff", "opacity": 0.95, "size": 16 }, + { "id": "pin.transport.active", "kind": "pin", "variant": "transport-active", "color": "#ff2f92", "opacity": 1, "size": 22 }, + { "id": "pin.station.metro", "kind": "pin", "variant": "station-metro", "color": "#8f72dc", "opacity": 1, "size": 26 }, + { "id": "pin.station.rail", "kind": "pin", "variant": "station-rail", "color": "#ffffff", "opacity": 1, "size": 22 }, + { "id": "line.route.primary", "kind": "line", "variant": "route-primary", "color": "#ff2f92", "opacity": 0.9, "width": 4 }, + { "id": "line.track.rail", "kind": "line", "variant": "track-rail", "color": "#8f72dc", "opacity": 0.8, "width": 2 }, + { "id": "fill.zone.operational", "kind": "fill", "variant": "operational-zone", "color": "#ff2f92", "opacity": 0.22 } + ], + "scene": { + "layers": [ + { "id": "base.default", "type": "base", "visible": true, "mode": "surface", "styleProfileId": "layer.base.neutral" }, + { "id": "buildings.default", "type": "buildings", "visible": true, "mode": "volume", "styleProfileId": "layer.buildings.default", "visibility": { "minRange": 0, "maxRange": 70000 } }, + { + "id": "grid.planetary", + "type": "grid", + "visible": true, + "mode": "graticule", + "styleProfileId": "grid.planetary.major", + "lodLevels": [ + { "level": 0, "minRange": 3000000, "maxRange": 50000000 }, + { "level": 1, "minRange": 700000, "maxRange": 3000000 }, + { "level": 2, "minRange": 180000, "maxRange": 700000 }, + { "level": 3, "minRange": 40000, "maxRange": 180000 }, + { "level": 4, "minRange": 0, "maxRange": 40000 } + ] + } + ], + "places": [ + { + "id": "place.moscow", + "name": "Москва", + "position": [37.6176, 55.7558, 220], + "label": { "text": "МОСКВА", "styleProfileId": "label.place.large" }, + "visibility": { "minRange": 100000, "maxRange": 50000000 } + } + ], + "movingObjects": [ + { + "id": "vehicle.tram.017", + "objectType": "tram", + "status": "active", + "position": [37.6242, 55.7604, 4], + "heading": 124, + "pinStyleProfileId": "pin.transport.active", + "label": { "text": "ТМ 17", "styleProfileId": "label.entity.compact" }, + "trace": [[37.615, 55.756, 4], [37.619, 55.758, 4], [37.6242, 55.7604, 4]] + }, + { + "id": "vehicle.rail.mcd2", + "objectType": "rail", + "status": "delayed", + "position": [37.579, 55.769, 4], + "heading": 82, + "pinStyleProfileId": "pin.transport.active", + "label": { "text": "МЦД-2 · +3 мин", "styleProfileId": "label.entity.compact" } + } + ], + "stations": [ + { + "id": "station.metro.tverskaya", + "stationType": "metro", + "position": [37.604, 55.7647, -20], + "pinStyleProfileId": "pin.station.metro", + "label": { "text": "Тверская", "styleProfileId": "label.station.medium" } + }, + { + "id": "station.rail.rizhskaya", + "stationType": "rail", + "position": [37.6328, 55.7927, 4], + "pinStyleProfileId": "pin.station.rail", + "label": { "text": "Рижская", "styleProfileId": "label.station.medium" } + } + ], + "routes": [ + { + "id": "route.tram.017", + "coordinates": [[37.595, 55.748, 4], [37.607, 55.754, 4], [37.6242, 55.7604, 4], [37.64, 55.772, 4]], + "styleProfileId": "line.route.primary", + "label": { "text": "Маршрут 17", "styleProfileId": "label.entity.compact" } + } + ], + "tracks": [ + { + "id": "track.rail.north-east", + "coordinates": [[37.579, 55.769, 4], [37.606, 55.781, 4], [37.6328, 55.7927, 4]], + "styleProfileId": "line.track.rail" + } + ], + "zones": [ + { + "id": "zone.depot.control", + "geometry": { + "type": "Polygon", + "coordinates": [[[37.61, 55.752], [37.632, 55.752], [37.632, 55.765], [37.61, 55.765], [37.61, 55.752]]] + }, + "styleProfileId": "fill.zone.operational", + "label": { "text": "Операционная зона", "styleProfileId": "label.entity.compact" } + } + ] + }, + "selection": { "entityId": "vehicle.tram.017" }, + "acceptance": [ + { "id": "scene.provider-neutral", "expect": ["Scene contains no renderer credentials or provider-specific object types."] }, + { "id": "scene.shared-label-language", "expect": ["Places, vehicles, stations, routes and zones resolve labels through shared style profiles."] }, + { "id": "scene.grid-lod", "expect": ["Grid changes density through five non-overlapping range bands without disappearing at a boundary."] }, + { "id": "scene.selection", "expect": ["Selection resolves to an existing scene entity and can open the canonical Inspector."] } + ] +} diff --git a/registry/pages.json b/registry/pages.json index 7a5fc39..26668e4 100644 --- a/registry/pages.json +++ b/registry/pages.json @@ -32,7 +32,16 @@ { "id": "open-inspector", "label": "Inspector", "command": "map.inspector.open", "feature": "inspector" }, { "id": "toggle-toolbar", "label": "Toolbar", "command": "map.toolbar.toggle", "feature": "toolbar" }, { "id": "open-assistant", "label": "Assistant", "command": "assistant.open", "feature": "assistant" } - ] + ], + "contracts": { + "fixtureSchema": "registry/schemas/map-scene-fixture-v0.1.schema.json", + "acceptanceFixtures": [ + "registry/fixtures/map/map-operational-v0.1.json", + "registry/fixtures/map/map-empty-offline-v0.1.json" + ], + "lodPolicySchema": "registry/schemas/map-lod-policy-v0.1.schema.json", + "lodPolicies": "registry/fixtures/map/map-lod-policies-v0.1.json" + } } ] } diff --git a/registry/registry.json b/registry/registry.json index 7dd8875..07562d9 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -46,10 +46,13 @@ "candidates": "../docs/CANDIDATES.md", "applicationTemplate": "../docs/APPLICATION_TEMPLATE.md", "moduleStudio": "../docs/MODULE_STUDIO.md", + "mapTemplate": "../docs/MAP_TEMPLATE.md", "icons": "../docs/ICONS.md", "consumption": "../docs/CONSUMPTION.md" }, "applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json", "designProfileSchema": "schemas/design-profile-v0.1.schema.json", + "mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json", + "mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json", "operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy." } diff --git a/registry/schemas/map-lod-policy-v0.1.schema.json b/registry/schemas/map-lod-policy-v0.1.schema.json new file mode 100644 index 0000000..aed094a --- /dev/null +++ b/registry/schemas/map-lod-policy-v0.1.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nodedc.ru/schemas/map-lod-policy-v0.1.schema.json", + "title": "NODE.DC Map LOD Policy v0.1", + "description": "Provider-neutral visibility and representation policy based on camera range in meters.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "template", "metric", "intervalRule", "policies"], + "properties": { + "schemaVersion": { "const": "0.1.0" }, + "template": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "const": "map" }, + "version": { "const": "0.1.0" } + } + }, + "metric": { "const": "camera-range-meters" }, + "intervalRule": { "const": "min-inclusive-max-exclusive" }, + "policies": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "target", "outsideRange", "hysteresisRatio", "bands"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "target": { "enum": ["grid", "place", "moving-object", "station", "route", "track", "zone", "label", "pin"] }, + "outsideRange": { "enum": ["hidden", "nearest-band"] }, + "hysteresisRatio": { "type": "number", "minimum": 0, "maximum": 0.5 }, + "bands": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "minRange", "maxRange", "representation"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "minRange": { "type": "number", "minimum": 0 }, + "maxRange": { "type": "number", "exclusiveMinimum": 0 }, + "representation": { "enum": ["hidden", "cluster", "summary", "compact", "standard", "detailed", "geometry"] }, + "styleVariant": { "type": "string", "minLength": 1 }, + "density": { "type": "number", "exclusiveMinimum": 0 }, + "maxItems": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" } + } +} diff --git a/registry/schemas/map-scene-fixture-v0.1.schema.json b/registry/schemas/map-scene-fixture-v0.1.schema.json new file mode 100644 index 0000000..71c9668 --- /dev/null +++ b/registry/schemas/map-scene-fixture-v0.1.schema.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nodedc.ru/schemas/map-scene-fixture-v0.1.schema.json", + "title": "NODE.DC Map Scene Fixture v0.1", + "description": "Provider-neutral acceptance scene for the canonical Map Page Template.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "fixtureId", "template", "title", "state", "viewport", "capabilities", "styleProfiles", "scene", "selection", "acceptance"], + "properties": { + "schemaVersion": { "const": "0.1.0" }, + "fixtureId": { "$ref": "#/$defs/id" }, + "template": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "const": "map" }, + "version": { "const": "0.1.0" } + } + }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "maxLength": 500 }, + "state": { "enum": ["ready", "loading", "empty", "stale", "error", "offline"] }, + "viewport": { + "type": "object", + "additionalProperties": false, + "required": ["center", "heading", "pitch", "range"], + "properties": { + "center": { "$ref": "#/$defs/position" }, + "heading": { "type": "number", "minimum": 0, "maximum": 360 }, + "pitch": { "type": "number", "minimum": -90, "maximum": 90 }, + "range": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["camera", "base-layer", "buildings", "grid", "labels", "pins", "moving-objects", "traces", "routes", "tracks", "stations", "zones", "selection"] + } + }, + "styleProfiles": { + "type": "array", + "items": { "$ref": "#/$defs/styleProfile" } + }, + "scene": { + "type": "object", + "additionalProperties": false, + "required": ["layers", "places", "movingObjects", "stations", "routes", "tracks", "zones"], + "properties": { + "layers": { "type": "array", "items": { "$ref": "#/$defs/layer" } }, + "places": { "type": "array", "items": { "$ref": "#/$defs/place" } }, + "movingObjects": { "type": "array", "items": { "$ref": "#/$defs/movingObject" } }, + "stations": { "type": "array", "items": { "$ref": "#/$defs/station" } }, + "routes": { "type": "array", "items": { "$ref": "#/$defs/linearEntity" } }, + "tracks": { "type": "array", "items": { "$ref": "#/$defs/linearEntity" } }, + "zones": { "type": "array", "items": { "$ref": "#/$defs/zone" } } + } + }, + "selection": { + "type": "object", + "additionalProperties": false, + "required": ["entityId"], + "properties": { + "entityId": { "type": ["string", "null"] } + } + }, + "acceptance": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "expect"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "expect": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } + } + } + }, + "$defs": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" }, + "position": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "prefixItems": [ + { "type": "number", "minimum": -180, "maximum": 180 }, + { "type": "number", "minimum": -90, "maximum": 90 }, + { "type": "number" } + ] + }, + "styleProfile": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "variant"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "kind": { "enum": ["layer", "grid", "label", "pin", "line", "fill"] }, + "variant": { "type": "string", "minLength": 1 }, + "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "size": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "visibility": { + "type": "object", + "additionalProperties": false, + "required": ["minRange", "maxRange"], + "properties": { + "minRange": { "type": "number", "minimum": 0 }, + "maxRange": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "label": { + "type": "object", + "additionalProperties": false, + "required": ["text", "styleProfileId"], + "properties": { + "text": { "type": "string", "minLength": 1 }, + "styleProfileId": { "$ref": "#/$defs/id" } + } + }, + "layer": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "visible", "styleProfileId"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "type": { "enum": ["base", "buildings", "grid"] }, + "visible": { "type": "boolean" }, + "mode": { "enum": ["surface", "volume", "graticule"] }, + "styleProfileId": { "$ref": "#/$defs/id" }, + "visibility": { "$ref": "#/$defs/visibility" }, + "lodLevels": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["level", "minRange", "maxRange"], + "properties": { + "level": { "type": "integer", "minimum": 0 }, + "minRange": { "type": "number", "minimum": 0 }, + "maxRange": { "type": "number", "exclusiveMinimum": 0 } + } + } + } + } + }, + "place": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "position", "label", "visibility"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "name": { "type": "string", "minLength": 1 }, + "position": { "$ref": "#/$defs/position" }, + "label": { "$ref": "#/$defs/label" }, + "visibility": { "$ref": "#/$defs/visibility" } + } + }, + "movingObject": { + "type": "object", + "additionalProperties": false, + "required": ["id", "objectType", "status", "position", "heading", "pinStyleProfileId", "label"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "objectType": { "enum": ["rail", "metro", "tram", "bus", "vehicle", "drone", "generic"] }, + "status": { "enum": ["active", "idle", "delayed", "warning", "offline", "unknown"] }, + "position": { "$ref": "#/$defs/position" }, + "heading": { "type": "number", "minimum": 0, "maximum": 360 }, + "pinStyleProfileId": { "$ref": "#/$defs/id" }, + "label": { "$ref": "#/$defs/label" }, + "trace": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/position" } } + } + }, + "station": { + "type": "object", + "additionalProperties": false, + "required": ["id", "stationType", "position", "pinStyleProfileId", "label"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "stationType": { "enum": ["rail", "metro", "stop", "terminal", "generic"] }, + "position": { "$ref": "#/$defs/position" }, + "pinStyleProfileId": { "$ref": "#/$defs/id" }, + "label": { "$ref": "#/$defs/label" } + } + }, + "linearEntity": { + "type": "object", + "additionalProperties": false, + "required": ["id", "coordinates", "styleProfileId"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "coordinates": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/position" } }, + "styleProfileId": { "$ref": "#/$defs/id" }, + "label": { "$ref": "#/$defs/label" } + } + }, + "zone": { + "type": "object", + "additionalProperties": false, + "required": ["id", "geometry", "styleProfileId", "label"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "geometry": { + "type": "object", + "additionalProperties": false, + "required": ["type", "coordinates"], + "properties": { + "type": { "enum": ["Polygon", "MultiPolygon"] }, + "coordinates": { "type": "array", "minItems": 1 } + } + }, + "styleProfileId": { "$ref": "#/$defs/id" }, + "label": { "$ref": "#/$defs/label" } + } + } + } +} diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 0a222bf..eeb1a16 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -13,6 +13,8 @@ const icons = await parse("registry/icons.json"); const pages = await parse("registry/pages.json"); const applicationManifestSchema = await parse("registry/schemas/application-manifest-v0.1.schema.json"); const designProfileSchema = await parse("registry/schemas/design-profile-v0.1.schema.json"); +const mapSceneFixtureSchema = await parse("registry/schemas/map-scene-fixture-v0.1.schema.json"); +const mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json"); const failures = []; const requireValue = (condition, message) => { @@ -30,8 +32,12 @@ requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template"); requireValue(registry.applicationManifestSchema === "schemas/application-manifest-v0.1.schema.json", "application manifest schema must be registered"); requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schema.json", "design profile schema must be registered"); +requireValue(registry.mapSceneFixtureSchema === "schemas/map-scene-fixture-v0.1.schema.json", "map scene fixture schema must be registered"); +requireValue(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered"); requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0"); requireValue(designProfileSchema.properties?.schemaVersion?.const === "0.1.0", "design profile schema version must be 0.1.0"); +requireValue(mapSceneFixtureSchema.properties?.schemaVersion?.const === "0.1.0", "map scene fixture schema version must be 0.1.0"); +requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0"); const ids = components.components.map((component) => component.id); requireValue(new Set(ids).size === ids.length, "component ids must be unique"); @@ -50,6 +56,82 @@ for (const template of pages.templates) { requireValue(Array.isArray(template.features), `page template features are missing: ${template.id ?? "unknown"}`); requireValue(Array.isArray(template.slots), `page template slots are missing: ${template.id ?? "unknown"}`); requireValue(Array.isArray(template.actions), `page template actions are missing: ${template.id ?? "unknown"}`); + + if (template.contracts) { + requireValue(typeof template.contracts.fixtureSchema === "string", `page template fixture schema is missing: ${template.id ?? "unknown"}`); + requireValue(Array.isArray(template.contracts.acceptanceFixtures) && template.contracts.acceptanceFixtures.length >= 1, `page template acceptance fixtures are missing: ${template.id ?? "unknown"}`); + + for (const fixturePath of template.contracts.acceptanceFixtures ?? []) { + let fixture; + try { + fixture = await parse(fixturePath); + } catch { + failures.push(`missing or invalid acceptance fixture: ${fixturePath}`); + continue; + } + + requireValue(fixture.schemaVersion === "0.1.0", `unsupported fixture schema: ${fixturePath}`); + requireValue(fixture.template?.id === template.id && fixture.template?.version === template.version, `fixture template mismatch: ${fixturePath}`); + requireValue(["ready", "loading", "empty", "stale", "error", "offline"].includes(fixture.state), `unsupported fixture state: ${fixturePath}`); + requireValue(Array.isArray(fixture.capabilities), `fixture capabilities are missing: ${fixturePath}`); + requireValue(Array.isArray(fixture.styleProfiles), `fixture style profiles are missing: ${fixturePath}`); + requireValue(Array.isArray(fixture.acceptance) && fixture.acceptance.length >= 1, `fixture acceptance checks are missing: ${fixturePath}`); + + const collections = ["layers", "places", "movingObjects", "stations", "routes", "tracks", "zones"]; + requireValue(collections.every((name) => Array.isArray(fixture.scene?.[name])), `fixture scene collections are incomplete: ${fixturePath}`); + + const entities = collections.flatMap((name) => fixture.scene?.[name] ?? []); + const entityIds = entities.map((entity) => entity.id); + requireValue(entityIds.every(Boolean), `fixture entity id is missing: ${fixturePath}`); + requireValue(new Set(entityIds).size === entityIds.length, `fixture entity ids must be unique: ${fixturePath}`); + + const styleIds = fixture.styleProfiles?.map((style) => style.id) ?? []; + requireValue(styleIds.every(Boolean), `fixture style profile id is missing: ${fixturePath}`); + requireValue(new Set(styleIds).size === styleIds.length, `fixture style profile ids must be unique: ${fixturePath}`); + + const referencedStyleIds = entities.flatMap((entity) => [entity.styleProfileId, entity.pinStyleProfileId, entity.label?.styleProfileId]).filter(Boolean); + requireValue(referencedStyleIds.every((id) => styleIds.includes(id)), `fixture references an unknown style profile: ${fixturePath}`); + + const selectedEntityId = fixture.selection?.entityId; + requireValue(selectedEntityId === null || entityIds.includes(selectedEntityId), `fixture selection references an unknown entity: ${fixturePath}`); + + for (const layer of fixture.scene?.layers ?? []) { + if (!layer.lodLevels) continue; + const levels = [...layer.lodLevels].sort((a, b) => a.minRange - b.minRange); + requireValue(levels.every((level) => level.maxRange > level.minRange), `fixture LOD range is invalid: ${fixturePath}#${layer.id}`); + requireValue(levels.every((level, index) => index === 0 || level.minRange === levels[index - 1].maxRange), `fixture LOD ranges must be contiguous: ${fixturePath}#${layer.id}`); + } + } + + if (template.contracts.lodPolicies) { + let policySet; + try { + policySet = await parse(template.contracts.lodPolicies); + } catch { + failures.push(`missing or invalid LOD policy set: ${template.contracts.lodPolicies}`); + } + + if (policySet) { + requireValue(policySet.schemaVersion === "0.1.0", `unsupported LOD policy schema: ${template.contracts.lodPolicies}`); + requireValue(policySet.template?.id === template.id && policySet.template?.version === template.version, `LOD policy template mismatch: ${template.contracts.lodPolicies}`); + requireValue(policySet.metric === "camera-range-meters", `LOD policy metric must be provider-neutral camera range: ${template.contracts.lodPolicies}`); + requireValue(policySet.intervalRule === "min-inclusive-max-exclusive", `LOD interval rule is unsupported: ${template.contracts.lodPolicies}`); + requireValue(Array.isArray(policySet.policies) && policySet.policies.length >= 1, `LOD policies are missing: ${template.contracts.lodPolicies}`); + + const policyIds = policySet.policies?.map((policy) => policy.id) ?? []; + requireValue(new Set(policyIds).size === policyIds.length, `LOD policy ids must be unique: ${template.contracts.lodPolicies}`); + + for (const policy of policySet.policies ?? []) { + const bands = [...(policy.bands ?? [])].sort((a, b) => a.minRange - b.minRange); + requireValue(bands.length >= 1, `LOD policy bands are missing: ${policy.id}`); + requireValue(new Set(bands.map((band) => band.id)).size === bands.length, `LOD band ids must be unique: ${policy.id}`); + requireValue(bands.every((band) => band.maxRange > band.minRange), `LOD range is invalid: ${policy.id}`); + requireValue(bands.every((band, index) => index === 0 || band.minRange === bands[index - 1].maxRange), `LOD ranges must be contiguous: ${policy.id}`); + requireValue(typeof policy.hysteresisRatio === "number" && policy.hysteresisRatio >= 0 && policy.hysteresisRatio <= 0.5, `LOD hysteresis ratio is invalid: ${policy.id}`); + } + } + } + } } for (const component of components.components) { diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 2e5b5d8..e13eb37 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -13,14 +13,22 @@ const uploadDir = join(runtimeDir, "uploads"); const applicationsDir = join(runtimeDir, "applications"); const designProfilesDir = join(runtimeDir, "design-profiles"); const designProfileReleasesDir = join(runtimeDir, "design-profile-releases"); +const pageLayoutsDir = join(runtimeDir, "page-layouts"); const layoutPath = join(runtimeDir, "layout.json"); const pageRegistryPath = join(root, "registry", "pages.json"); const port = Number(process.env.PORT || 3333); +const cesiumIonAssetAllowlist = new Set( + String(process.env.CESIUM_ION_ASSET_ALLOWLIST || "1,96188") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), +); await mkdir(uploadDir, { recursive: true }); await mkdir(applicationsDir, { recursive: true }); await mkdir(designProfilesDir, { recursive: true }); await mkdir(designProfileReleasesDir, { recursive: true }); +await mkdir(pageLayoutsDir, { recursive: true }); const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8")); function findPageTemplate(id, version) { @@ -90,6 +98,14 @@ function designProfileReleasePath(id, version) { return join(designProfileReleaseDir(id), `${safeVersion}.json`); } +function pageLayoutPath(pageId) { + const safePageId = String(pageId || ""); + if (!/^[a-z0-9-]+$/i.test(safePageId) || !findPageTemplate(safePageId)) { + throw applicationError("unknown_page_template"); + } + return join(pageLayoutsDir, `${safePageId}.json`); +} + const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value); const requireHex = (value, code) => { const normalized = String(value || ""); @@ -119,6 +135,42 @@ const requireBoolean = (value, code) => { return value; }; +function validateMapPageLayout(value) { + if (!isObject(value)) throw applicationError("invalid_map_page_layout"); + if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_map_page_layout"); + if (!isObject(value.settings)) throw applicationError("invalid_map_page_settings"); + if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera"); + const settings = value.settings; + for (const key of ["imageryVisible", "cacheEnabled", "terrainEnabled", "monochrome", "atmosphereEnabled", "fogEnabled", "sunEnabled", "shadowsEnabled", "buildingsVisible", "gridVisible", "gridLodEnabled", "gridDotsEnabled"]) { + requireBoolean(settings[key], `invalid_map_page_setting_${key}`); + } + requireString(settings.imagerySource, "invalid_map_page_imagery_source", 64); + for (const key of ["terrainExaggeration", "imageryGamma", "imageryHue", "imageryAlpha", "atmosphereHue", "atmosphereSaturation", "atmosphereBrightness", "fogDensity", "sunHour", "sunIntensity", "buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast", "imagerySaturation", "gridHeightMeters", "gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm", "gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridOpacity", "gridDotsSize", "gridDotsOpacity"]) { + requireNumber(settings[key], -100000, 100000, `invalid_map_page_setting_${key}`); + } + for (const key of ["monochromeColor", "globeColor", "backgroundColor", "buildingsColor", "gridColor", "gridDotsColor"]) { + requireHex(settings[key], `invalid_map_page_setting_${key}`); + } + const camera = value.camera; + for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) { + requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`); + } + return { + schemaVersion: 1, + pageId: "map", + settings, + mapHeight: requireInteger(value.mapHeight, 360, 5000, "invalid_map_page_height"), + camera: { + longitude: camera.longitude, + latitude: camera.latitude, + height: camera.height, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + }; +} + function validateMaterial(value, theme) { if (!isObject(value)) throw applicationError(`invalid_${theme}_material`); return { @@ -335,6 +387,10 @@ function validateApplicationManifest(value) { if (!template) throw applicationError("unknown_application_page_template"); if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation"); if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features"); + if (page.layout !== undefined) { + if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout"); + if (page.template.id === "map" && page.layout.map !== undefined) validateMapPageLayout(page.layout.map); + } const allowedFeatures = new Set(template.features.map((feature) => feature.id)); if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature"); for (const feature of template.features) { @@ -533,6 +589,77 @@ const server = createServer(async (request, response) => { return; } + const pageLayoutMatch = url.pathname.match(/^\/api\/page-layouts\/([a-z0-9-]+)$/i); + if (pageLayoutMatch && request.method === "GET") { + try { + json(response, 200, JSON.parse(await readFile(pageLayoutPath(pageLayoutMatch[1]), "utf8"))); + } catch (error) { + if (error?.code === "ENOENT") return json(response, 200, null); + throw error; + } + return; + } + if (pageLayoutMatch && request.method === "PUT") { + const pageId = pageLayoutMatch[1]; + if (pageId !== "map") throw applicationError("unsupported_page_layout"); + const layout = validateMapPageLayout(await readJsonBody(request)); + const next = { ...layout, savedAt: new Date().toISOString() }; + const targetPath = pageLayoutPath(pageId); + const tempPath = `${targetPath}.${randomUUID()}.tmp`; + await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + await rename(tempPath, targetPath); + json(response, 200, next); + return; + } + + if (url.pathname === "/api/map/runtime-config" && request.method === "GET") { + const ionReady = Boolean(process.env.CESIUM_ION_TOKEN); + // The local Platform gateway is part of the Module Studio development + // topology. Production must receive its address explicitly; a browser + // must never receive the Ion token itself in either case. + const gatewayUrl = String( + process.env.NODEDC_MAP_GATEWAY_URL || (process.env.NODE_ENV === "production" ? "" : "http://127.0.0.1:18103"), + ).trim(); + const gatewayBase = gatewayUrl.replace(/\/$/, ""); + const localGatewayReady = ionReady; + const gaussianAssetId = String(process.env.CESIUM_GAUSSIAN_SPLAT_ASSET_ID || "").trim(); + json(response, 200, { + cesiumVersion: "1.143.0", + provider: gatewayUrl ? "nodedc-map-gateway" : localGatewayReady ? "studio-dev-gateway" : "osm", + ionReady, + gatewayReady: Boolean(gatewayUrl) || localGatewayReady, + osmBuildingsReady: Boolean(gatewayUrl) || localGatewayReady, + gaussianSplatsReady: (Boolean(gatewayUrl) || localGatewayReady) && Boolean(gaussianAssetId), + gaussianAssetId: gaussianAssetId || null, + assetEndpointBase: gatewayUrl ? `${gatewayBase}/api/map/ion/assets` : "/api/map/ion/assets", + resourceProxyBase: gatewayUrl ? `${gatewayBase}/api/map/cache?url=` : null, + gatewayHealthUrl: gatewayUrl ? `${gatewayBase}/healthz` : null, + cache: { mode: gatewayUrl ? "gateway" : "external-required", persistent: Boolean(gatewayUrl) }, + }); + return; + } + + const cesiumAssetEndpointMatch = url.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/); + if (cesiumAssetEndpointMatch && request.method === "GET") { + const masterToken = String(process.env.CESIUM_ION_TOKEN || "").trim(); + const assetId = cesiumAssetEndpointMatch[1]; + if (!masterToken) return json(response, 503, { error: "cesium_ion_not_configured" }); + if (!cesiumIonAssetAllowlist.has(assetId)) return json(response, 403, { error: "cesium_asset_not_allowed" }); + const upstream = await fetch(`https://api.cesium.com/v1/assets/${assetId}/endpoint`, { + headers: { authorization: `Bearer ${masterToken}` }, + }); + if (!upstream.ok) return json(response, upstream.status, { error: "cesium_ion_endpoint_unavailable" }); + const endpoint = await upstream.json(); + json(response, 200, { + assetId, + type: endpoint.type, + url: endpoint.url, + accessToken: endpoint.accessToken, + attributions: Array.isArray(endpoint.attributions) ? endpoint.attributions : [], + }); + return; + } + if (url.pathname === "/api/design-profiles" && request.method === "GET") { const profiles = []; for (const entry of await readdir(designProfilesDir, { withFileTypes: true })) {