feat(map): add reference layers and stabilize workspace controls
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -132,7 +132,7 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
|
||||
assert.deepEqual(subjectDetailTool.inputSchema.properties.profile.required, ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
|
||||
assert.deepEqual(
|
||||
subjectDetailTool.inputSchema.properties.profile.properties.tabs.items.properties.sections.items.properties.fields.items.properties.format.enum,
|
||||
["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"],
|
||||
["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "string_list", "telemetry_readings"],
|
||||
);
|
||||
const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
|
||||
assert.ok(mapSettingsTool);
|
||||
@@ -140,6 +140,8 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
|
||||
assert.equal(Object.hasOwn(mapSettingsTool.inputSchema.properties.settings.properties, "providerUrl"), false);
|
||||
const mapViewStateTool = foundryList.result.tools.find((tool) => tool.name === "foundry_save_map_page_view_state");
|
||||
assert.ok(mapViewStateTool);
|
||||
assert.equal(mapViewStateTool.inputSchema.properties.viewState.properties.inspectorOpenSections.maxItems, 1);
|
||||
assert.equal(mapViewStateTool.inputSchema.properties.viewState.properties.inspectorOpenSections.uniqueItems, true);
|
||||
const subjectStateSchema = mapViewStateTool.inputSchema.properties.viewState.properties.subjectStates.items;
|
||||
assert.deepEqual(subjectStateSchema.required, ["bindingId", "visible", "filters", "window"]);
|
||||
assert.equal(subjectStateSchema.properties.filters.additionalProperties.items.type, "string");
|
||||
|
||||
@@ -688,6 +688,13 @@ const tools = [
|
||||
},
|
||||
},
|
||||
subjectStates: { type: "array", maxItems: 64, items: mapSubjectStateInputSchema },
|
||||
inspectorOpenSections: {
|
||||
type: "array",
|
||||
maxItems: 1,
|
||||
uniqueItems: true,
|
||||
description: "Exact expanded Map Inspector section ids. Empty means every section is collapsed.",
|
||||
items: { type: "string", minLength: 1, maxLength: 256 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user