feat(module-studio): add configurable map page

This commit is contained in:
DCCONSTRUCTIONS
2026-07-13 17:14:34 +03:00
parent 1f2dffc031
commit 1a2ca8c82c
23 changed files with 2700 additions and 31 deletions
+127
View File
@@ -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 })) {