feat(map): add reference layers and stabilize workspace controls

This commit is contained in:
Codex
2026-07-25 13:12:24 +03:00
parent c859ae4db0
commit eaecce9f56
29 changed files with 1593 additions and 247 deletions
+87 -2
View File
@@ -55,6 +55,32 @@ if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-p
throw new Error("map_presentation_profile_registry_invalid");
}
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
const canonicalMapReferencePresentationProfiles = canonicalMapPresentationProfiles.filter((profile) => (
profile.id.startsWith("map.reference.transport.")
));
const canonicalMapReferenceLayers = Object.freeze([
Object.freeze({
id: "reference.transport.metro",
referenceProfileId: "transport-stations.v1",
category: "metro",
presentationProfileId: "map.reference.transport.metro.v1",
visible: true,
}),
Object.freeze({
id: "reference.transport.railway_terminal",
referenceProfileId: "transport-stations.v1",
category: "railway_terminal",
presentationProfileId: "map.reference.transport.terminal.v1",
visible: true,
}),
Object.freeze({
id: "reference.transport.railway_station",
referenceProfileId: "transport-stations.v1",
category: "railway_station",
presentationProfileId: "map.reference.transport.railway-station.v1",
visible: true,
}),
]);
const mapSubjectDetailProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-subject-detail-profiles.json"), "utf8"));
if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") {
throw new Error("map_subject_detail_profile_registry_invalid");
@@ -458,6 +484,33 @@ function validateMapSubjectStates(value, dataProductBindings) {
return dataProductBindings.map((binding, index) => states.get(binding.id) ?? defaultMapSubjectState(binding.id, index));
}
function validateMapReferenceLayers(value) {
if (!Array.isArray(value) || value.length > canonicalMapReferenceLayers.length) {
throw applicationError("invalid_map_reference_layers");
}
const canonicalById = new Map(canonicalMapReferenceLayers.map((layer) => [layer.id, layer]));
const visibilityById = new Map();
for (const raw of value) {
if (!isObject(raw)) throw applicationError("invalid_map_reference_layer");
if (Object.keys(raw).some((key) => !new Set([
"id", "referenceProfileId", "category", "presentationProfileId", "visible",
]).has(key))) throw applicationError("invalid_map_reference_layer_field");
const canonical = canonicalById.get(raw.id);
if (!canonical
|| raw.referenceProfileId !== canonical.referenceProfileId
|| raw.category !== canonical.category
|| raw.presentationProfileId !== canonical.presentationProfileId) {
throw applicationError("map_reference_layer_not_registered");
}
if (visibilityById.has(raw.id)) throw applicationError("duplicate_map_reference_layer_id");
visibilityById.set(raw.id, requireBoolean(raw.visible, "invalid_map_reference_layer_visibility"));
}
return canonicalMapReferenceLayers.map((layer) => ({
...layer,
visible: visibilityById.get(layer.id) ?? layer.visible,
}));
}
const MAP_PAGE_SETTING_KEYS = new Set([
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
@@ -477,6 +530,21 @@ function validateMapPageSettingsPatch(value) {
return value;
}
function validateMapInspectorOpenSections(value) {
if (!Array.isArray(value) || value.length > 1) {
throw applicationError("invalid_map_inspector_open_sections");
}
const sections = value.map((section) => requireNonEmptyString(
section,
"invalid_map_inspector_open_section",
256,
));
if (new Set(sections).size !== sections.length) {
throw applicationError("duplicate_map_inspector_open_section");
}
return sections;
}
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");
@@ -501,7 +569,12 @@ function validateMapPageLayout(value) {
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}`);
}
const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
const suppliedPresentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
const suppliedPresentationProfileIds = new Set(suppliedPresentationProfiles.map((profile) => profile.id));
const presentationProfiles = normalizeMapPresentationProfiles([
...suppliedPresentationProfiles,
...canonicalMapReferencePresentationProfiles.filter((profile) => !suppliedPresentationProfileIds.has(profile.id)),
]);
const subjectDetailProfiles = normalizeMapSubjectDetailProfiles(
value.subjectDetailProfiles === undefined
? structuredClone(canonicalMapSubjectDetailProfiles)
@@ -509,6 +582,7 @@ function validateMapPageLayout(value) {
);
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
const referenceLayers = validateMapReferenceLayers(value.referenceLayers === undefined ? [] : value.referenceLayers);
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
throw applicationError("map_data_product_presentation_profile_not_found");
@@ -571,6 +645,10 @@ function validateMapPageLayout(value) {
subjectDetailProfiles,
dataProductBindings,
subjectStates,
referenceLayers,
inspectorOpenSections: validateMapInspectorOpenSections(
value.inspectorOpenSections === undefined ? ["map-base"] : value.inspectorOpenSections,
),
};
}
@@ -663,6 +741,8 @@ function defaultMapPageLayout() {
subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles),
dataProductBindings: [],
subjectStates: [],
referenceLayers: structuredClone(canonicalMapReferenceLayers),
inspectorOpenSections: ["map-base"],
};
}
@@ -2159,6 +2239,9 @@ const foundryMcpOperations = {
mapHeight: input.viewState.mapHeight,
camera: input.viewState.camera,
subjectStates: input.viewState.subjectStates,
inspectorOpenSections: input.viewState.inspectorOpenSections === undefined
? layout.inspectorOpenSections
: input.viewState.inspectorOpenSections,
}) },
};
return updatedPage;
@@ -2170,6 +2253,7 @@ const foundryMcpOperations = {
mapHeight: updatedPage.layout.map.mapHeight,
camera: updatedPage.layout.map.camera,
subjectStates: updatedPage.layout.map.subjectStates,
inspectorOpenSections: updatedPage.layout.map.inspectorOpenSections,
} };
},
});
@@ -2703,7 +2787,8 @@ const server = createServer(async (request, response) => {
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")));
const stored = JSON.parse(await readFile(pageLayoutPath(pageLayoutMatch[1]), "utf8"));
json(response, 200, pageLayoutMatch[1] === "map" ? validateMapPageLayout(stored) : stored);
} catch (error) {
if (error?.code === "ENOENT") return json(response, 200, null);
throw error;