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