feat(foundry): close the operational map data loop

This commit is contained in:
Codex 2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions

View File

@ -7,7 +7,7 @@
<link rel="icon" href="/favicon/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/favicon/icon-adaptive.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon/icon-adaptive.svg" />
<link rel="apple-touch-icon" href="/favicon/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/manifest.webmanifest.json" /> <link rel="manifest" href="/favicon/manifest.webmanifest.json" crossorigin="use-credentials" />
<title>NODE.DC UI Catalog</title> <title>NODE.DC UI Catalog</title>
</head> </head>
<body> <body>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns"; import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
import { import {
@ -37,6 +37,8 @@ import {
Switch, Switch,
TextAreaField, TextAreaField,
TextField, TextField,
ToastCard,
ToastStack,
Toolbar, Toolbar,
UserProfileMenu, UserProfileMenu,
useApplicationWorkspace, useApplicationWorkspace,
@ -45,6 +47,8 @@ import {
WorkspaceWindow, WorkspaceWindow,
type IconName, type IconName,
type ShareAccessMember, type ShareAccessMember,
type ToastItem,
type ToastTone,
type ToolbarPlacement, type ToolbarPlacement,
type WorkspaceWindowRect, type WorkspaceWindowRect,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
@ -60,15 +64,38 @@ import {
type DesignProfileSummary, type DesignProfileSummary,
type DesignProfileStatus, type DesignProfileStatus,
} from "./applicationManifest.js"; } from "./applicationManifest.js";
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js"; import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
import {
mapDesignFragmentForLayout,
mapDesignFragmentFromLayout,
mapDesignOverridesFromResolved,
mapDesignProfileKey,
resolveMapDesignLayout,
type MaterialDraft,
type StoredLayout,
} from "./designProfile.js";
import { FoundrySettingsModal } from "./FoundrySettingsModal.js"; import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons"; type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
type StudioContext = "visual" | "pages" | "applications"; type StudioContext = "visual" | "pages" | "applications";
type StudioView = CatalogSection | `page-template:${string}` | `application:${string}` | `application:${string}/page:${string}`; type StudioView = CatalogSection | `page-template:${string}` | `application:${string}` | `application:${string}/page:${string}`;
type DesignProfileSaveScope = { kind: "global" } | { kind: "page"; templateId: "map"; templateVersion: string };
type DesignProfileDocument = {
id: string;
name: string;
version: string;
status: DesignProfileStatus;
layout: StoredLayout;
timestamps: { updatedAt: string; publishedAt?: string };
};
const pageTemplateViewId = (id: string) => `page-template:${id}` as const; const pageTemplateViewId = (id: string) => `page-template:${id}` as const;
const pageTemplateIdFromView = (view: string | null) => view?.startsWith("page-template:") ? view.slice("page-template:".length) : null; const pageTemplateIdFromView = (view: string | null) => view?.startsWith("page-template:") ? view.slice("page-template:".length) : null;
const designProfileDocumentPath = (reference: { id: string; version: string; status: DesignProfileStatus }) => (
reference.status === "published"
? `/api/design-profiles/${reference.id}/versions/${reference.version}`
: `/api/design-profiles/${reference.id}`
);
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
@ -77,56 +104,6 @@ const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" }, { label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
]; ];
type MaterialDraft = {
panelHex: string;
panelOpacity: number;
fieldHex: string;
fieldOpacity: number;
nestedHex: string;
};
interface StoredLayout {
theme?: NodedcTheme;
accentHex?: string;
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
environment?: {
lightColor?: string;
brightness?: number;
glowDistance?: number;
connectionType?: string;
connectionColor?: string;
usePortColors?: boolean;
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeOpacity?: number;
};
media?: {
source?: "file" | "url";
url?: string;
fileName?: string;
fileSrc?: string;
visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
};
}
interface FoundrySessionProfile { interface FoundrySessionProfile {
user: { user: {
id: string; id: string;
@ -198,14 +175,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Единый материал только для модальных окон и перемещаемого Inspector.", description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps", icon: "apps",
}, },
status: {
eyebrow: "04 / STATUS NOTIFICATIONS",
title: "Статусы",
description: "Канонические стеклянные уведомления о сохранении, обновлении и ошибках — стек снизу справа.",
icon: "activity",
},
modals: { modals: {
eyebrow: "04 / MODALS", eyebrow: "05 / MODALS",
title: "Модалки", title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.", description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard", icon: "clipboard",
}, },
icons: { icons: {
eyebrow: "05 / ICONS", eyebrow: "06 / ICONS",
title: "Иконки", title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.", description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid", icon: "grid",
@ -236,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{ {
title: "Сущности", title: "Сущности",
note: "Общий словарь", note: "Общий словарь",
icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"], icons: ["profile", "users", "building", "globe", "target", "database", "network", "inbox", "mail"],
}, },
{ {
title: "Контент", title: "Контент",
@ -276,6 +259,7 @@ const iconLabels: Record<IconName, string> = {
minimize: "Свернуть", minimize: "Свернуть",
network: "Связи", network: "Связи",
panel: "Панель", panel: "Панель",
target: "Таргеты",
plus: "Добавить", plus: "Добавить",
profile: "Профиль", profile: "Профиль",
refresh: "Обновить", refresh: "Обновить",
@ -328,9 +312,13 @@ export function CatalogApp() {
const [studioContext, setStudioContext] = useState<StudioContext>("visual"); const [studioContext, setStudioContext] = useState<StudioContext>("visual");
const [applicationSummaries, setApplicationSummaries] = useState<ApplicationSummary[]>([]); const [applicationSummaries, setApplicationSummaries] = useState<ApplicationSummary[]>([]);
const [designProfiles, setDesignProfiles] = useState<DesignProfileSummary[]>([]); const [designProfiles, setDesignProfiles] = useState<DesignProfileSummary[]>([]);
const [activeDesignProfileLayout, setActiveDesignProfileLayout] = useState<StoredLayout | null>(null);
const [applicationDesignProfileLayout, setApplicationDesignProfileLayout] = useState<StoredLayout | null>(null);
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null); const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle"); const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState(""); const [applicationError, setApplicationError] = useState("");
const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSequenceRef = useRef(0);
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null); const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false); const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false); const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
@ -351,6 +339,7 @@ export function CatalogApp() {
const [applicationMode, setApplicationMode] = useState<"edit" | "preview">("edit"); const [applicationMode, setApplicationMode] = useState<"edit" | "preview">("edit");
const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false); const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false);
const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save"); const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save");
const [designProfileSaveScope, setDesignProfileSaveScope] = useState<DesignProfileSaveScope>({ kind: "global" });
const [designProfileName, setDesignProfileName] = useState(""); const [designProfileName, setDesignProfileName] = useState("");
const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle"); const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
const [activeDesignProfileId, setActiveDesignProfileId] = useState("default"); const [activeDesignProfileId, setActiveDesignProfileId] = useState("default");
@ -433,6 +422,64 @@ export function CatalogApp() {
{ id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" }, { id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" },
]); ]);
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((item) => item.id !== id));
}, []);
const pushToast = useCallback((tone: ToastTone, title: string, description?: string, durationMs?: number | null) => {
toastSequenceRef.current += 1;
const id = `foundry-toast-${toastSequenceRef.current}`;
setToasts((current) => [...current.slice(-3), { id, tone, title, description, durationMs }]);
return id;
}, []);
const updateToast = useCallback((id: string, patch: Partial<Omit<ToastItem, "id">>) => {
setToasts((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));
}, []);
const applyDesignProfileLayout = useCallback((stored: StoredLayout) => {
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
if (stored.accentHex) setAccentHex(stored.accentHex);
if (stored.materialByTheme) setMaterialByTheme((current) => ({
dark: { ...current.dark, ...stored.materialByTheme?.dark },
light: { ...current.light, ...stored.materialByTheme?.light },
}));
if (stored.environment) {
if (stored.environment.lightColor) setLightColor(stored.environment.lightColor);
if (typeof stored.environment.brightness === "number") setBrightness(stored.environment.brightness);
if (typeof stored.environment.glowDistance === "number") setGlowDistance(stored.environment.glowDistance);
if (stored.environment.connectionType) setConnectionType(stored.environment.connectionType);
if (stored.environment.connectionColor) setConnectionColor(stored.environment.connectionColor);
if (typeof stored.environment.usePortColors === "boolean") setUsePortColors(stored.environment.usePortColors);
if (stored.environment.fillColor) setFillColor(stored.environment.fillColor);
if (typeof stored.environment.fillOpacity === "number") setFillOpacity(stored.environment.fillOpacity);
if (stored.environment.strokeColor) setStrokeColor(stored.environment.strokeColor);
if (typeof stored.environment.strokeOpacity === "number") setStrokeOpacity(stored.environment.strokeOpacity);
}
if (stored.media) {
if (stored.media.source === "file" || stored.media.source === "url") setMediaSource(stored.media.source);
if (typeof stored.media.url === "string") setMediaUrl(stored.media.url);
if (stored.media.fileName) setMediaFileName(stored.media.fileName);
if (stored.media.fileSrc) setFileMediaSrc(stored.media.fileSrc);
if (typeof stored.media.visible === "boolean") setMediaVisible(stored.media.visible);
if (stored.media.logoSource === "file" || stored.media.logoSource === "url") setLogoSource(stored.media.logoSource);
if (typeof stored.media.logoUrl === "string") setLogoUrl(stored.media.logoUrl);
if (stored.media.logoFileName) setLogoFileName(stored.media.logoFileName);
if (stored.media.logoFileSrc) setLogoFileSrc(stored.media.logoFileSrc);
if (stored.media.faviconFileName) setFaviconFileName(stored.media.faviconFileName);
if (stored.media.faviconAssets) setFaviconAssets(stored.media.faviconAssets);
}
if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass });
if (stored.toolbar?.placement) setToolbarPlacement(stored.toolbar.placement);
if (stored.toolbar?.background) setToolbarBg(stored.toolbar.background);
if (stored.toolbar?.border) setToolbarBorder(stored.toolbar.border);
if (stored.toolbar?.outline) setToolbarOutline(stored.toolbar.outline);
if (typeof stored.toolbar?.minSize === "number") setToolbarMinSize(stored.toolbar.minSize);
if (typeof stored.toolbar?.maxSize === "number") setToolbarMaxSize(stored.toolbar.maxSize);
if (typeof stored.toolbar?.lensCount === "number") setToolbarLensCount(stored.toolbar.lensCount);
if (typeof stored.toolbar?.autoHide === "boolean") setToolbarAutoHide(stored.toolbar.autoHide);
}, []);
const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl) const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl)
? mediaUrl ? mediaUrl
: null; : null;
@ -640,6 +687,26 @@ export function CatalogApp() {
return () => { active = false; }; return () => { active = false; };
}, []); }, []);
useEffect(() => {
let active = true;
fetch(`/api/design-profiles/${activeDesignProfileId}`, { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("design_profile_load_failed");
return await response.json() as DesignProfileDocument;
})
.then((profile) => {
if (!active) return;
const stored = profile.layout;
setActiveDesignProfileLayout(stored);
applyDesignProfileLayout(stored);
setLayoutSaveState("saved");
})
.catch(() => {
if (active) setLayoutSaveState("error");
});
return () => { active = false; };
}, [activeDesignProfileId, applyDesignProfileLayout]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
fetch("/api/applications", { cache: "no-store" }) fetch("/api/applications", { cache: "no-store" })
@ -734,21 +801,8 @@ export function CatalogApp() {
workspace.openView(next as CatalogSection); workspace.openView(next as CatalogSection);
}; };
const openDesignProfile = async (id: string) => { const openDesignProfile = (id: string) => {
setActiveDesignProfileId(id); setActiveDesignProfileId(id);
const response = await fetch(`/api/design-profiles/${id}`, { cache: "no-store" });
if (!response.ok) return setLayoutSaveState("error");
const profile = await response.json() as { layout: StoredLayout };
const stored = profile.layout;
if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme);
if (stored.accentHex) setAccentHex(stored.accentHex);
if (stored.materialByTheme) setMaterialByTheme((current) => ({ dark: { ...current.dark, ...stored.materialByTheme?.dark }, light: { ...current.light, ...stored.materialByTheme?.light } }));
if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass });
if (stored.toolbar?.placement) setToolbarPlacement(stored.toolbar.placement);
if (stored.toolbar?.background) setToolbarBg(stored.toolbar.background);
if (stored.toolbar?.border) setToolbarBorder(stored.toolbar.border);
if (stored.toolbar?.outline) setToolbarOutline(stored.toolbar.outline);
setLayoutSaveState("saved");
}; };
const openApplicationDraft = async (id: string) => { const openApplicationDraft = async (id: string) => {
@ -760,16 +814,47 @@ export function CatalogApp() {
const response = await fetch(`/api/applications/${id}`, { cache: "no-store" }); const response = await fetch(`/api/applications/${id}`, { cache: "no-store" });
if (!response.ok) throw new Error("application_load_failed"); if (!response.ok) throw new Error("application_load_failed");
const manifest = await response.json() as ApplicationManifestV01; const manifest = await response.json() as ApplicationManifestV01;
const profileResponse = await fetch(designProfileDocumentPath(manifest.designProfile), { cache: "no-store" });
if (!profileResponse.ok) throw new Error("application_design_profile_load_failed");
const profile = await profileResponse.json() as DesignProfileDocument;
setApplicationDraft(manifest); setApplicationDraft(manifest);
setTheme(manifest.designProfile.theme); setApplicationDesignProfileLayout(profile.layout);
applyDesignProfileLayout(profile.layout);
setApplicationSaveState("saved"); setApplicationSaveState("saved");
} catch { } catch {
setApplicationDraft(null); setApplicationDraft(null);
setApplicationDesignProfileLayout(null);
setApplicationSaveState("error"); setApplicationSaveState("error");
setApplicationError("Не удалось открыть Application Draft."); setApplicationError("Не удалось открыть Application Draft.");
} }
}; };
const selectApplicationDesignProfile = async (reference: string) => {
if (!applicationDraft) return;
const [profileId, version, statusValue] = reference.split("@");
const profile = designProfiles.find((item) => item.id === profileId);
if (!profile) return;
const status: DesignProfileStatus = statusValue === "published" ? "published" : "draft";
const release = status === "published" ? profile.versions.find((item) => item.version === version) : undefined;
const profileTheme = release?.theme ?? profile.theme;
setApplicationSaveState("loading");
setApplicationError("");
try {
const response = await fetch(designProfileDocumentPath({ id: profile.id, version, status }), { cache: "no-store" });
if (!response.ok) throw new Error("design_profile_load_failed");
const document = await response.json() as DesignProfileDocument;
setApplicationDesignProfileLayout(document.layout);
applyDesignProfileLayout(document.layout);
updateApplicationDraft((current) => ({
...current,
designProfile: { id: profile.id, version, status, theme: profileTheme },
}));
} catch {
setApplicationSaveState("error");
setApplicationError("Не удалось применить выбранный Design Profile.");
}
};
const openPageTemplate = (id: string) => { const openPageTemplate = (id: string) => {
setStudioContext("pages"); setStudioContext("pages");
workspace.openView(pageTemplateViewId(id)); workspace.openView(pageTemplateViewId(id));
@ -794,7 +879,12 @@ export function CatalogApp() {
}); });
if (!response.ok) throw new Error("application_create_failed"); if (!response.ok) throw new Error("application_create_failed");
const manifest = await response.json() as ApplicationManifestV01; const manifest = await response.json() as ApplicationManifestV01;
const profileResponse = await fetch(designProfileDocumentPath(manifest.designProfile), { cache: "no-store" });
if (!profileResponse.ok) throw new Error("application_design_profile_load_failed");
const profile = await profileResponse.json() as DesignProfileDocument;
setApplicationDraft(manifest); setApplicationDraft(manifest);
setApplicationDesignProfileLayout(profile.layout);
applyDesignProfileLayout(profile.layout);
setApplicationSummaries((current) => [ setApplicationSummaries((current) => [
{ {
id: manifest.id, id: manifest.id,
@ -838,12 +928,27 @@ export function CatalogApp() {
const saveApplicationDraft = async () => { const saveApplicationDraft = async () => {
if (!applicationDraft) return; if (!applicationDraft) return;
const toastId = pushToast("loading", "Сохраняем Application", applicationDraft.metadata.name, null);
const activeMapLayout = activeApplicationPageId ? applicationMapPreviewRef.current?.getLayout() : null; const activeMapLayout = activeApplicationPageId ? applicationMapPreviewRef.current?.getLayout() : null;
const activePage = activeApplicationPageId
? applicationDraft.pages.find((page) => page.id === activeApplicationPageId)
: undefined;
const activeMapFragment = activePage?.template.id === "map"
? mapDesignFragmentForLayout(applicationDesignProfileLayout, activePage.template.version)
: null;
const activeMapOverrides = activeMapLayout && activeMapFragment
? mapDesignOverridesFromResolved(activeMapLayout, activeMapFragment)
: undefined;
const draftToSave = activeMapLayout && activeApplicationPageId ? { const draftToSave = activeMapLayout && activeApplicationPageId ? {
...applicationDraft, ...applicationDraft,
pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? { pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? {
...page, ...page,
layout: { ...page.layout, map: activeMapLayout }, layout: { ...page.layout, map: activeMapLayout },
...(activeMapFragment ? {
designOverrides: activeMapOverrides
? { ...page.designOverrides, map: activeMapOverrides }
: undefined,
} : {}),
} : page), } : page),
} : applicationDraft; } : applicationDraft;
setApplicationSaveState("saving"); setApplicationSaveState("saving");
@ -871,31 +976,20 @@ export function CatalogApp() {
...current.filter((item) => item.id !== saved.id), ...current.filter((item) => item.id !== saved.id),
]); ]);
setApplicationSaveState("saved"); setApplicationSaveState("saved");
updateToast(toastId, { tone: "success", title: "Application сохранён", description: saved.metadata.name, durationMs: 4200 });
} catch { } catch {
setApplicationSaveState("error"); setApplicationSaveState("error");
setApplicationError("Draft не сохранён. Проверьте название и slug."); setApplicationError("Draft не сохранён. Проверьте название и slug.");
updateToast(toastId, { tone: "error", title: "Application не сохранён", description: "Проверьте manifest и повторите попытку.", durationMs: 6500 });
} }
}; };
const saveMapPageTemplate = async () => { const openMapPageDesignSave = (templateVersion: string) => {
const layout = mapTemplatePreviewRef.current?.getLayout(); const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!layout) { setDesignProfileName(currentProfile?.name ?? "");
setMapTemplateSaveState("error"); setDesignProfileSaveScope({ kind: "page", templateId: "map", templateVersion });
return; setDesignProfileSaveMode("save");
} setDesignProfileSaveOpen(true);
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) => { const changeStudioContext = (next: StudioContext) => {
@ -1029,20 +1123,48 @@ export function CatalogApp() {
}; };
const saveDesignProfile = async (mode = designProfileSaveMode) => { const saveDesignProfile = async (mode = designProfileSaveMode) => {
const layoutSaved = await saveEnvironmentLayout();
if (!layoutSaved) return;
const layoutResponse = await fetch("/api/layout", { cache: "no-store" });
if (!layoutResponse.ok) return setLayoutSaveState("error");
const layout = await layoutResponse.json() as StoredLayout;
const creating = mode === "save-as";
const selected = designProfiles.find((profile) => profile.id === activeDesignProfileId); const selected = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!selected) return;
const toastId = pushToast(
"loading",
designProfileSaveScope.kind === "page" ? "Сохраняем дизайн страницы" : "Сохраняем Design Profile",
designProfileSaveScope.kind === "page" ? `Map@${designProfileSaveScope.templateVersion} · ${selected.name}` : selected.name,
null,
);
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("saving");
else setLayoutSaveState("saving");
try {
const currentResponse = await fetch(`/api/design-profiles/${activeDesignProfileId}`, { cache: "no-store" });
if (!currentResponse.ok) throw new Error("design_profile_load_failed");
const currentProfile = await currentResponse.json() as DesignProfileDocument;
let layout: StoredLayout;
if (designProfileSaveScope.kind === "page") {
const pageLayout = mapTemplatePreviewRef.current?.getLayout();
if (!pageLayout) throw new Error("map_page_layout_unavailable");
const pageKey = mapDesignProfileKey(designProfileSaveScope.templateVersion);
layout = {
...currentProfile.layout,
pageTypes: {
...currentProfile.layout.pageTypes,
[pageKey]: mapDesignFragmentFromLayout(pageLayout, designProfileSaveScope.templateVersion),
},
};
} else {
const layoutSaved = await saveEnvironmentLayout();
if (!layoutSaved) throw new Error("global_layout_save_failed");
const layoutResponse = await fetch("/api/layout", { cache: "no-store" });
if (!layoutResponse.ok) throw new Error("global_layout_load_failed");
const globalLayout = await layoutResponse.json() as StoredLayout;
layout = { ...globalLayout, pageTypes: currentProfile.layout.pageTypes ?? {} };
}
const creating = mode === "save-as";
const response = await fetch(creating ? "/api/design-profiles" : `/api/design-profiles/${activeDesignProfileId}`, { const response = await fetch(creating ? "/api/design-profiles" : `/api/design-profiles/${activeDesignProfileId}`, {
method: creating ? "POST" : "PUT", method: creating ? "POST" : "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ name: creating ? designProfileName : selected?.name, layout }), body: JSON.stringify({ name: creating ? designProfileName : selected.name, layout }),
}); });
if (!response.ok) return setLayoutSaveState("error"); if (!response.ok) throw new Error("design_profile_save_failed");
const saved = await response.json() as { id: string; name: string; version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { updatedAt: string } }; const saved = await response.json() as DesignProfileDocument;
const previous = designProfiles.find((profile) => profile.id === saved.id); const previous = designProfiles.find((profile) => profile.id === saved.id);
const summary: DesignProfileSummary = { const summary: DesignProfileSummary = {
id: saved.id, id: saved.id,
@ -1056,18 +1178,33 @@ export function CatalogApp() {
}; };
setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name))); setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name)));
setActiveDesignProfileId(summary.id); setActiveDesignProfileId(summary.id);
setActiveDesignProfileLayout(saved.layout);
setDesignProfileSaveOpen(false); setDesignProfileSaveOpen(false);
setDesignProfileName(""); setDesignProfileName("");
setLayoutSaveState("saved"); setLayoutSaveState("saved");
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("saved");
updateToast(toastId, {
tone: "success",
title: mode === "save-as" ? "Design Profile создан" : "Design Profile обновлён",
description: `${saved.name} · v${saved.version}`,
durationMs: 4200,
});
} catch {
if (designProfileSaveScope.kind === "page") setMapTemplateSaveState("error");
else setLayoutSaveState("error");
updateToast(toastId, { tone: "error", title: "Design Profile не сохранён", description: "Текущий профиль не изменён.", durationMs: 6500 });
}
}; };
const publishDesignProfile = async () => { const publishDesignProfile = async () => {
const current = designProfiles.find((profile) => profile.id === activeDesignProfileId); const current = designProfiles.find((profile) => profile.id === activeDesignProfileId);
if (!current || current.versions.some((release) => release.version === current.version)) return; if (!current || current.versions.some((release) => release.version === current.version)) return;
const toastId = pushToast("loading", "Публикуем Design Profile", `${current.name} · v${current.version}`, null);
setDesignProfilePublishState("publishing"); setDesignProfilePublishState("publishing");
const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" }); const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" });
if (!response.ok) { if (!response.ok) {
setDesignProfilePublishState("error"); setDesignProfilePublishState("error");
updateToast(toastId, { tone: "error", title: "Публикация не выполнена", description: "Draft остался доступен без изменений.", durationMs: 6500 });
return; return;
} }
const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } }; const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } };
@ -1078,6 +1215,7 @@ export function CatalogApp() {
})); }));
setDesignProfilePublishState("published"); setDesignProfilePublishState("published");
setDesignProfileSaveOpen(false); setDesignProfileSaveOpen(false);
updateToast(toastId, { tone: "success", title: "Design Profile опубликован", description: `${current.name} · v${release.version}`, durationMs: 4200 });
}; };
const addPageTemplateToApplication = (template: PageTemplateDefinition) => { const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
@ -1448,6 +1586,33 @@ export function CatalogApp() {
</SettingsCard> </SettingsCard>
</div> </div>
); );
case "status":
return (
<div className="catalog-status-library">
<SettingsCard
eyebrow="TASKER / PROPEL CANON"
title="Статусные уведомления"
description="Один стеклянный паттерн для сохранения, обновления, предупреждения и ошибки. Runtime-стек появляется снизу справа и не блокирует интерфейс."
>
<div className="catalog-toast-library-grid">
<ToastCard item={{ id: "preview-success", tone: "success", title: "Проект сохранён", description: "Application manifest обновлён." }} />
<ToastCard item={{ id: "preview-info", tone: "info", title: "Профиль применён", description: "Map-фрагмент будет использован страницами этого типа." }} />
<ToastCard item={{ id: "preview-warning", tone: "warning", title: "Есть несохранённые изменения", description: "Текущий draft отличается от release." }} />
<ToastCard item={{ id: "preview-error", tone: "error", title: "Сохранение не выполнено", description: "Предыдущее состояние не изменено." }} />
<ToastCard item={{ id: "preview-loading", tone: "loading", title: "Сохраняем Design Profile", description: "Пожалуйста, не закрывайте окно." }} />
</div>
</SettingsCard>
<SettingsCard eyebrow="LIVE STATES" title="Проверка стека" description="Кнопки используют тот же bottom-right viewport, что и реальные save/update flows.">
<div className="catalog-inline catalog-inline--wrap">
<Button onClick={() => pushToast("success", "Изменения сохранены", "Статус автоматически исчезнет.")}>Success</Button>
<Button onClick={() => pushToast("info", "Профиль применён", "Application использует выбранную версию.")}>Info</Button>
<Button onClick={() => pushToast("warning", "Нужна проверка", "Перед публикацией проверьте preview.")}>Warning</Button>
<Button variant="danger" onClick={() => pushToast("error", "Операция не выполнена", "Состояние не изменено.", 6500)}>Error</Button>
<Button icon={<Icon name="refresh" />} onClick={() => pushToast("loading", "Выполняется операция", "Закройте вручную после проверки.", null)}>Loading</Button>
</div>
</SettingsCard>
</div>
);
case "modals": case "modals":
return ( return (
<div className="catalog-modal-groups"> <div className="catalog-modal-groups">
@ -1468,7 +1633,7 @@ export function CatalogApp() {
</Preview> </Preview>
<Preview title="Сохранить / Сохранить как" note="DESIGN PROFILE" className="catalog-modal-preview"> <Preview title="Сохранить / Сохранить как" note="DESIGN PROFILE" className="catalog-modal-preview">
<p className="catalog-preview__explanation">Обновляет текущий preset или создаёт новый именованный профиль.</p> <p className="catalog-preview__explanation">Обновляет текущий preset или создаёт новый именованный профиль.</p>
<Button shape="pill" icon={<Icon name="save" />} onClick={() => { setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button> <Button shape="pill" icon={<Icon name="save" />} onClick={() => { setDesignProfileSaveScope({ kind: "global" }); setDesignProfileSaveMode("save"); setDesignProfileSaveOpen(true); }}>Открыть сохранение</Button>
</Preview> </Preview>
</div> </div>
</section> </section>
@ -1606,16 +1771,7 @@ export function CatalogApp() {
label="Design Profile" label="Design Profile"
value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`} value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
options={designProfileOptions} options={designProfileOptions}
onChange={(profileReference) => { onChange={(profileReference) => { void selectApplicationDesignProfile(profileReference); }}
const [profileId, version, statusValue] = profileReference.split("@");
const profile = designProfiles.find((item) => item.id === profileId);
if (!profile) return;
const status: DesignProfileStatus = statusValue === "published" ? "published" : "draft";
const release = status === "published" ? profile.versions.find((item) => item.version === version) : undefined;
const profileTheme = release?.theme ?? profile.theme;
setTheme(profileTheme);
updateApplicationDraft((current) => ({ ...current, designProfile: { id: profile.id, version, status, theme: profileTheme } }));
}}
/> />
</SettingsCard> </SettingsCard>
@ -1682,7 +1838,6 @@ export function CatalogApp() {
</DragDropRoot> </DragDropRoot>
<div className="catalog-page-composer__footer"> <div className="catalog-page-composer__footer">
<span data-state={applicationSaveState}>{applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "saved" ? "Конфигурация сохранена" : applicationSaveState === "error" ? "Ошибка сохранения" : "Есть несохранённые изменения"}</span> <span data-state={applicationSaveState}>{applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "saved" ? "Конфигурация сохранена" : applicationSaveState === "error" ? "Ошибка сохранения" : "Есть несохранённые изменения"}</span>
<Button variant="primary" shape="pill" icon={<Icon name="save" />} onClick={() => { void saveApplicationDraft(); }}>Сохранить конфигурацию</Button>
</div> </div>
</SettingsCard> </SettingsCard>
{applicationError ? <p className="nodedc-media-field__error" role="alert">{applicationError}</p> : null} {applicationError ? <p className="nodedc-media-field__error" role="alert">{applicationError}</p> : null}
@ -1690,14 +1845,20 @@ export function CatalogApp() {
); );
}; };
const renderPageTemplate = (template: PageTemplateDefinition) => ( const renderPageTemplate = (template: PageTemplateDefinition) => {
template.id === "map" ? ( const mapProfileFragment = template.id === "map"
? mapDesignFragmentForLayout(activeDesignProfileLayout, template.version)
: null;
const mapDesignLayout = template.id === "map"
? resolveMapDesignLayout(mapTemplateLayout ?? createDefaultMapPageLayout(true), mapProfileFragment)
: null;
return template.id === "map" ? (
<div className="catalog-page-template catalog-page-template--map"> <div className="catalog-page-template catalog-page-template--map">
<MapFixturePreview <MapFixturePreview
key={`map-template-${mapTemplateLayout?.savedAt ?? "default"}`} key={`map-template-${activeDesignProfileId}-${mapDesignProfileKey(template.version)}-${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? "draft"}`}
ref={mapTemplatePreviewRef} ref={mapTemplatePreviewRef}
expanded expanded
initialLayout={mapTemplateLayout} initialLayout={mapDesignLayout}
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))} features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
/> />
<div className="catalog-page-template__actions"> <div className="catalog-page-template__actions">
@ -1736,8 +1897,8 @@ export function CatalogApp() {
</SettingsCard> </SettingsCard>
</div> </div>
</div> </div>
)
); );
};
const renderApplicationPage = () => { const renderApplicationPage = () => {
if (!applicationDraft || !activeApplicationPageId) return null; if (!applicationDraft || !activeApplicationPageId) return null;
@ -1745,6 +1906,12 @@ export function CatalogApp() {
if (!page) return null; if (!page) return null;
const template = getPageTemplate(page.template.id, page.template.version); const template = getPageTemplate(page.template.id, page.template.version);
if (!template) return null; if (!template) return null;
const mapProfileFragment = page.template.id === "map"
? mapDesignFragmentForLayout(applicationDesignProfileLayout, page.template.version)
: null;
const resolvedMapLayout = page.template.id === "map"
? resolveMapDesignLayout(page.layout?.map ?? createDefaultMapPageLayout(true), mapProfileFragment, page.designOverrides?.map)
: null;
const setFeature = (featureId: string, value: boolean) => updateApplicationDraft((current) => ({ const setFeature = (featureId: string, value: boolean) => updateApplicationDraft((current) => ({
...current, ...current,
pages: current.pages.map((item) => item.id === page.id ? { ...item, features: { ...item.features, [featureId]: value } } : item), pages: current.pages.map((item) => item.id === page.id ? { ...item, features: { ...item.features, [featureId]: value } } : item),
@ -1752,11 +1919,11 @@ export function CatalogApp() {
return ( return (
<div className="catalog-application-page"> <div className="catalog-application-page">
<MapFixturePreview <MapFixturePreview
key={page.id} key={`${page.id}-${applicationDraft.designProfile.id}-${applicationDraft.designProfile.version}-${applicationDraft.designProfile.status}`}
ref={applicationMapPreviewRef} ref={applicationMapPreviewRef}
applicationId={applicationDraft.id} applicationId={applicationDraft.id}
pageId={page.id} pageId={page.id}
initialLayout={page.layout?.map ?? null} initialLayout={resolvedMapLayout}
features={page.features} features={page.features}
expanded expanded
/> />
@ -1866,7 +2033,7 @@ export function CatalogApp() {
emptyLabel="Модули не созданы" emptyLabel="Модули не созданы"
onChange={(id) => { void openApplicationDraft(id); }} onChange={(id) => { void openApplicationDraft(id); }}
/> />
) : studioContext === "visual" ? ( ) : studioContext === "visual" || studioContext === "pages" ? (
<Select <Select
className="catalog-module-switcher" className="catalog-module-switcher"
label="Design Profile" label="Design Profile"
@ -1918,6 +2085,7 @@ export function CatalogApp() {
onClick: () => { onClick: () => {
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId); const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
setDesignProfileName(currentProfile?.name ?? ""); setDesignProfileName(currentProfile?.name ?? "");
setDesignProfileSaveScope({ kind: "global" });
setDesignProfileSaveMode("save"); setDesignProfileSaveMode("save");
setDesignProfileSaveOpen(true); setDesignProfileSaveOpen(true);
}, },
@ -1936,9 +2104,9 @@ export function CatalogApp() {
expanded={panelExpanded} expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded} onExpandedChange={workspace.setContentExpanded}
utilityActions={activePageTemplate.id === "map" ? [{ utilityActions={activePageTemplate.id === "map" ? [{
label: mapTemplateSaveState === "saving" ? "Сохраняем layout страницы" : mapTemplateSaveState === "saved" ? "Layout страницы сохранён" : mapTemplateSaveState === "error" ? "Повторить сохранение layout страницы" : "Сохранить layout страницы", label: mapTemplateSaveState === "saving" ? "Сохраняем Map-фрагмент в Design Profile" : mapTemplateSaveState === "error" ? "Повторить сохранение Map-фрагмента" : "Сохранить дизайн Map в Design Profile",
icon: "save", icon: "save",
onClick: () => { void saveMapPageTemplate(); }, onClick: () => openMapPageDesignSave(activePageTemplate.version),
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading", disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
}] : undefined} }] : undefined}
onClose={workspace.closeView} onClose={workspace.closeView}
@ -1954,7 +2122,7 @@ export function CatalogApp() {
expanded={panelExpanded} expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded} onExpandedChange={workspace.setContentExpanded}
utilityActions={[{ utilityActions={[{
label: applicationSaveState === "saving" ? "Draft сохраняется" : applicationSaveState === "saved" ? "Draft сохранён" : applicationSaveState === "error" ? "Повторить сохранение draft" : "Сохранить Application Draft", label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
icon: "save", icon: "save",
onClick: () => { void saveApplicationDraft(); }, onClick: () => { void saveApplicationDraft(); },
disabled: applicationSaveState === "saving" || applicationSaveState === "loading", disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
@ -2042,7 +2210,7 @@ export function CatalogApp() {
<Window <Window
open={designProfileSaveOpen} open={designProfileSaveOpen}
title="Сохранить Design Profile" title="Сохранить Design Profile"
subtitle="VISUAL LIBRARY / PRESET" subtitle={designProfileSaveScope.kind === "page" ? `PAGE LIBRARY / ${designProfileSaveScope.templateId.toUpperCase()}@${designProfileSaveScope.templateVersion}` : "VISUAL LIBRARY / GLOBAL PRESET"}
size="sm" size="sm"
onClose={() => setDesignProfileSaveOpen(false)} onClose={() => setDesignProfileSaveOpen(false)}
footer={ footer={
@ -2066,6 +2234,13 @@ export function CatalogApp() {
} }
> >
<div className="catalog-form"> <div className="catalog-form">
<div className="catalog-save-profile-current">
<Icon name={designProfileSaveScope.kind === "page" ? "globe" : "settings"} />
<span>
<strong>{designProfileSaveScope.kind === "page" ? "Фрагмент дизайна страницы" : "Глобальные визуальные настройки"}</strong>
<small>{designProfileSaveScope.kind === "page" ? "В профиль попадут только Map settings и presentation profiles. Camera, bindings, subjects и credentials не сохраняются." : "Обновляются тема, материалы, media, favicon и toolbar. Уже сохранённые фрагменты страниц сохраняются без изменений."}</small>
</span>
</div>
<SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} /> <SegmentedControl label="Режим сохранения" value={designProfileSaveMode} items={[{ value: "save", label: "Сохранить" }, { value: "save-as", label: "Сохранить как новый" }]} onChange={setDesignProfileSaveMode} />
{designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : ( {designProfileSaveMode === "save-as" ? <TextField label="Название нового профиля" hint="обязательно" value={designProfileName} onChange={(event) => setDesignProfileName(event.target.value)} /> : (
<div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Сохранение создаёт новую draft-версию. Publish фиксирует текущую сохранённую версию неизменяемым release.</small></span></div> <div className="catalog-save-profile-current"><Icon name="settings" /><span><strong>{designProfiles.find((profile) => profile.id === activeDesignProfileId)?.name ?? "NODE.DC Default"}</strong><small>Сохранение создаёт новую draft-версию. Publish фиксирует текущую сохранённую версию неизменяемым release.</small></span></div>
@ -2275,6 +2450,7 @@ export function CatalogApp() {
onClose={() => setConfirmOpen(false)} onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)} onConfirm={() => setConfirmOpen(false)}
/> />
<ToastStack items={toasts} onDismiss={dismissToast} />
</> </>
); );
} }

View File

@ -10,11 +10,11 @@ import {
Cesium3DTileStyle, Cesium3DTileStyle,
CesiumTerrainProvider, CesiumTerrainProvider,
CallbackProperty, CallbackProperty,
CallbackPositionProperty,
Cartographic, Cartographic,
ConstantPositionProperty, ConstantPositionProperty,
CustomDataSource, CustomDataSource,
DefaultProxy, DefaultProxy,
DistanceDisplayCondition,
EllipsoidTerrainProvider, EllipsoidTerrainProvider,
Entity, Entity,
HeightReference, HeightReference,
@ -25,8 +25,8 @@ import {
LabelGraphics, LabelGraphics,
Matrix4, Matrix4,
Math as CesiumMath, Math as CesiumMath,
PolygonHierarchy,
PointGraphics, PointGraphics,
PolylineGraphics,
Resource, Resource,
sampleTerrainMostDetailed, sampleTerrainMostDetailed,
ScreenSpaceEventHandler, ScreenSpaceEventHandler,
@ -36,36 +36,22 @@ import {
Viewer, Viewer,
} from "cesium"; } from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css"; import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js"; import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js"; import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js"; import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsVisible,
resolveMapPresentationClass,
resolveMapPresentationStyle,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
type Position = [number, number, number?];
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300; const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000; const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
type PinPresentation = {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
labelOffsetX: number;
labelOffsetY: number;
pinHideCameraHeightMeters?: number;
labelHideCameraHeightMeters?: number;
};
type MapStyleProfile = {
id: string;
kind: string;
color?: string;
opacity?: number;
size?: number;
pinPresentation?: PinPresentation;
};
type RuntimeConfig = { type RuntimeConfig = {
cesiumVersion: string; cesiumVersion: string;
provider: string; provider: string;
@ -202,6 +188,8 @@ export type CesiumMapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean; startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void; stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null; getCameraView: () => MapCameraView | null;
fitRuntimeEntities: (entityIds?: string[]) => boolean;
focusRuntimeEntity: (entityId: string) => boolean;
}; };
type TerrainRouteSample = { type TerrainRouteSample = {
@ -237,28 +225,50 @@ type SpiralSession = {
previousBuildingsFoveatedTimeDelay: number | null; previousBuildingsFoveatedTimeDelay: number | null;
}; };
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 accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc"); const violet = Color.fromCssColorString("#8f72dc");
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value)); const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const defaultElevatedPin: PinPresentation = {
variant: "elevated-spike",
stemHeightMeters: 120,
headSizePx: 8,
stemWidthPx: 2,
outlineColor: "#0c0d12",
outlineOpacity: 0.6,
outlineWidthPx: 1,
labelOffsetX: 10,
labelOffsetY: 0,
};
function showBelowCameraHeight(viewer: Viewer, limit?: number) { function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true; if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false); return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
} }
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
}
function elevatedPinTopPosition(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
longitude,
latitude,
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
), false);
}
function elevatedPinStemPositions(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackProperty(() => {
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
return [
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
];
}, false);
}
function getCameraView(viewer: Viewer): MapCameraView { function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic; const position = viewer.camera.positionCartographic;
return { return {
@ -302,148 +312,6 @@ function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMe
return null; return null;
} }
function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
);
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,
},
});
}
const pinStyle = styleProfiles.get(object.pinStyleProfileId);
const pin = pinStyle?.pinPresentation ?? defaultElevatedPin;
const pinColor = Color.fromCssColorString(pinStyle?.color || "#ff2f92").withAlpha(pinStyle?.opacity ?? 1);
const outlineColor = Color.fromCssColorString(pin.outlineColor).withAlpha(pin.outlineOpacity);
const [longitude, latitude] = object.position as Position;
const base = Cartesian3.fromDegrees(longitude, latitude, 0);
const top = Cartesian3.fromDegrees(longitude, latitude, pin.stemHeightMeters);
viewer.entities.add({
id: object.id,
position: top,
polyline: {
positions: [base, top],
width: pin.stemWidthPx,
material: pinColor,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
point: {
pixelSize: pin.headSizePx,
color: pinColor,
outlineColor,
outlineWidth: pin.outlineWidthPx,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
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(pin.labelOffsetX, pin.labelOffsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, pin.labelHideCameraHeightMeters),
},
});
}
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 runtimeDisplayLabel(fact: MapRuntimeFact) {
for (const key of ["label", "name", "title", "subject_id"]) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fact.sourceId;
}
function runtimePointColor(fact: MapRuntimeFact) { function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter, // This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it // not a provider style. A renderer-neutral style profile can refine it
@ -459,6 +327,8 @@ function syncRuntimeDataSources(
viewer: Viewer, viewer: Viewer,
dataSources: Map<string, CustomDataSource>, dataSources: Map<string, CustomDataSource>,
bindings: MapRuntimeBinding[], bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
) { ) {
const activeBindings = new Map(bindings const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points") .filter((binding) => binding.slotId === "points")
@ -479,15 +349,77 @@ function syncRuntimeDataSources(
} }
const wanted = new Set<string>(); const wanted = new Set<string>();
for (const fact of binding.facts) { for (const fact of binding.facts) {
if (!fact.geometry) continue; const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
if (
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const entityId = mapRuntimeEntityId(binding.bindingId, fact); const entityId = mapRuntimeEntityId(binding.bindingId, fact);
wanted.add(entityId); wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates; const [longitude, latitude] = fact.geometry.coordinates;
const color = runtimePointColor(fact); const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const label = runtimeDisplayLabel(fact); const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId }); const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label; entity.name = label;
if (profile) {
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
),
width: profile.target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.point = new PointGraphics({
pixelSize: profile.target.headSizePx,
color,
outlineColor: Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity),
outlineWidth: profile.target.outlineWidthPx,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor),
outlineWidth: profile.label.outlineWidthPx,
style: 2,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
} else {
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0)); entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({ entity.point = new PointGraphics({
pixelSize: 10, pixelSize: 10,
color, color,
@ -509,6 +441,7 @@ function syncRuntimeDataSources(
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
}); });
} }
}
for (const entity of [...dataSource.entities.values]) { for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity); if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
} }
@ -530,9 +463,9 @@ function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, prese
const safeStepKm = clamp(stepKm, 0.25, 100); const safeStepKm = clamp(stepKm, 0.25, 100);
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150); const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm))); const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
const center = sceneFixture.viewport.center as Position; const cameraPosition = viewer.camera.positionCartographic;
const latitude = center[1]; const latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244;
const longitude = center[0]; const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423;
const metersPerLatitudeDegree = 110_574; const metersPerLatitudeDegree = 110_574;
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude))); const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
const stepMeters = safeStepKm * 1000; const stepMeters = safeStepKm * 1000;
@ -646,6 +579,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
initialCamera?: MapCameraView; initialCamera?: MapCameraView;
presentation: MapPresentation; presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[]; runtimeBindings?: MapRuntimeBinding[];
presentationProfiles?: MapPresentationProfile[];
presentationFilters?: MapPresentationFilters;
}>(function CesiumMapRenderer({ }>(function CesiumMapRenderer({
onSelect, onSelect,
onGatewayHealth, onGatewayHealth,
@ -657,6 +592,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
initialCamera, initialCamera,
presentation, presentation,
runtimeBindings = [], runtimeBindings = [],
presentationProfiles = [],
presentationFilters = {},
}, ref) { }, ref) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null); const creditContainerRef = useRef<HTMLDivElement>(null);
@ -668,6 +605,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>()); const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const presentationRef = useRef(presentation); const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings); const runtimeBindingsRef = useRef(runtimeBindings);
const presentationProfilesRef = useRef(presentationProfiles);
const presentationFiltersRef = useRef(presentationFilters);
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange); const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed); const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
@ -1020,6 +959,34 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
return true; return true;
}, [stopSpiralAnimation]); }, [stopSpiralAnimation]);
const runtimeEntities = useCallback((entityIds?: string[]) => {
const allowed = entityIds ? new Set(entityIds) : null;
return [...runtimeDataSourcesRef.current.values()].flatMap((dataSource) => (
[...dataSource.entities.values].filter((entity) => !allowed || allowed.has(String(entity.id)))
));
}, []);
const fitRuntimeEntities = useCallback((entityIds?: string[]) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entities = runtimeEntities(entityIds);
if (!entities.length) return false;
void viewer.flyTo(entities, { duration: 0.55 });
return true;
}, [runtimeEntities]);
const focusRuntimeEntity = useCallback((entityId: string) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0];
if (!entity) return false;
void viewer.flyTo(entity, {
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000),
});
return true;
}, [runtimeEntities]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
startSpiralAnimation, startSpiralAnimation,
stopSpiralAnimation, stopSpiralAnimation,
@ -1027,7 +994,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const viewer = viewerRef.current; const viewer = viewerRef.current;
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null; return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
}, },
}), [startSpiralAnimation, stopSpiralAnimation]); fitRuntimeEntities,
focusRuntimeEntity,
}), [fitRuntimeEntities, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
useEffect(() => { useEffect(() => {
const stopForPageLeave = () => stopSpiralAnimation("stopped"); const stopForPageLeave = () => stopSpiralAnimation("stopped");
@ -1066,10 +1035,18 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
useEffect(() => { useEffect(() => {
runtimeBindingsRef.current = runtimeBindings; runtimeBindingsRef.current = runtimeBindings;
presentationProfilesRef.current = presentationProfiles;
presentationFiltersRef.current = presentationFilters;
if (viewerRef.current && !viewerRef.current.isDestroyed()) { if (viewerRef.current && !viewerRef.current.isDestroyed()) {
syncRuntimeDataSources(viewerRef.current, runtimeDataSourcesRef.current, runtimeBindings); syncRuntimeDataSources(
viewerRef.current,
runtimeDataSourcesRef.current,
runtimeBindings,
presentationProfiles,
presentationFilters,
);
} }
}, [runtimeBindings]); }, [presentationFilters, presentationProfiles, runtimeBindings]);
useEffect(() => { useEffect(() => {
let viewer: Viewer | undefined; let viewer: Viewer | undefined;
@ -1146,8 +1123,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() }; const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid; viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true; viewer.scene.globe.depthTestAgainstTerrain = true;
addFixtureEntities(viewer); syncRuntimeDataSources(
syncRuntimeDataSources(viewer, runtimeDataSourcesRef.current, runtimeBindingsRef.current); viewer,
runtimeDataSourcesRef.current,
runtimeBindingsRef.current,
presentationProfilesRef.current,
presentationFiltersRef.current,
);
viewerRef.current = viewer; viewerRef.current = viewer;
terrainRef.current = terrain; terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current); const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
@ -1274,11 +1256,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
}, },
}); });
} else { } else {
const center = toCartesian(sceneFixture.viewport.center as Position); const center = Cartesian3.fromDegrees(37.618423, 55.751244, 0);
viewer.camera.lookAt(center, new HeadingPitchRange( viewer.camera.lookAt(center, new HeadingPitchRange(
CesiumMath.toRadians(sceneFixture.viewport.heading), 0,
CesiumMath.toRadians(sceneFixture.viewport.pitch), -0.9,
sceneFixture.viewport.range, 40_000,
)); ));
viewer.camera.lookAtTransform(Matrix4.IDENTITY); viewer.camera.lookAtTransform(Matrix4.IDENTITY);
} }

View File

@ -1,6 +1,6 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react"; 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, InspectorSelectField, RangeControl, Window } from "@nodedc/ui-react"; import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type { import type {
CameraSpiralState, CameraSpiralState,
CesiumMapRendererHandle, CesiumMapRendererHandle,
@ -10,6 +10,19 @@ import type {
MapProviderStatus, MapProviderStatus,
} from "./CesiumMapRenderer.js"; } from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
normalizeClientMapPresentationProfiles,
resolveMapPresentationClass,
toggleMapPresentationFacetSelection,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { import {
CAMERA_SURVEY_PRESETS, CAMERA_SURVEY_PRESETS,
DEFAULT_CAMERA_SURVEY_PRESET, DEFAULT_CAMERA_SURVEY_PRESET,
@ -18,8 +31,6 @@ import {
findCameraSurveyPreset, findCameraSurveyPreset,
type CameraSurveySelection, type CameraSurveySelection,
} from "./mapCameraPresets.js"; } from "./mapCameraPresets.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@ -92,11 +103,29 @@ export type MapPinBinding = {
*/ */
export type MapDataProductBinding = { export type MapDataProductBinding = {
id: string; id: string;
displayName?: string;
order?: number;
dataProductId: string; dataProductId: string;
slotId: string; slotId: string;
delivery: "snapshot+patch"; delivery: "snapshot+patch";
semanticTypes: string[]; semanticTypes: string[];
fieldProjection: string[]; fieldProjection: string[];
presentationProfileId?: string;
};
export type MapSubjectWindowState = {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
};
export type MapSubjectState = {
bindingId: string;
visible: boolean;
/** Missing facet means unconstrained; an explicit empty list means no matches. */
filters: Record<string, string[]>;
window: MapSubjectWindowState;
}; };
export type MapPageLayout = { export type MapPageLayout = {
@ -106,7 +135,9 @@ export type MapPageLayout = {
mapHeight: number; mapHeight: number;
camera: MapCameraView; camera: MapCameraView;
pinBindings: MapPinBinding[]; pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[];
dataProductBindings: MapDataProductBinding[]; dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
savedAt?: string; savedAt?: string;
}; };
@ -123,9 +154,9 @@ const initialMapSettings: MapPageSettings = {
terrainExaggeration: 1, terrainExaggeration: 1,
monochrome: false, monochrome: false,
monochromeColor: "#15151b", monochromeColor: "#15151b",
imageryGamma: 100, imageryGamma: 57,
imageryHue: 0, imageryHue: 13,
imageryAlpha: 100, imageryAlpha: 27,
globeColor: "#15151b", globeColor: "#15151b",
backgroundColor: "#08090d", backgroundColor: "#08090d",
atmosphereEnabled: false, atmosphereEnabled: false,
@ -140,11 +171,11 @@ const initialMapSettings: MapPageSettings = {
shadowsEnabled: true, shadowsEnabled: true,
buildingsVisible: true, buildingsVisible: true,
buildingsColor: "#a27aff", buildingsColor: "#a27aff",
buildingsOpacity: 0.82, buildingsOpacity: 1,
buildingsDetail: 16, buildingsDetail: 4,
imageryBrightness: 100, imageryBrightness: 118,
imageryContrast: 100, imageryContrast: 102,
imagerySaturation: 100, imagerySaturation: 0,
gridVisible: true, gridVisible: true,
gridLodEnabled: true, gridLodEnabled: true,
gridHeightMeters: 500, gridHeightMeters: 500,
@ -167,14 +198,28 @@ const initialMapSettings: MapPageSettings = {
// first move-end event. It makes the page contract immediately saveable; // 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. // the renderer replaces it with the exact live camera as soon as it is ready.
const fallbackMapCamera: MapCameraView = { const fallbackMapCamera: MapCameraView = {
longitude: sceneFixture.viewport.center[0], longitude: 37.618423,
latitude: sceneFixture.viewport.center[1], latitude: 55.751244,
height: sceneFixture.viewport.range, height: 40_000,
heading: (sceneFixture.viewport.heading * Math.PI) / 180, heading: 0,
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180, pitch: -0.9,
roll: 0, roll: 0,
}; };
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
return {
schemaVersion: 1,
pageId: "map",
settings: structuredClone(initialMapSettings),
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: [],
dataProductBindings: [],
subjectStates: [],
};
}
const initialProviderStatus: MapProviderStatus = { const initialProviderStatus: MapProviderStatus = {
imagery: "loading", imagery: "loading",
terrain: "loading", terrain: "loading",
@ -189,6 +234,40 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
"not-configured": "не настроен", "not-configured": "не настроен",
}; };
function defaultSubjectWindowState(index: number): MapSubjectWindowState {
return {
open: false,
rect: {
x: 24 + (index % 5) * 28,
y: 56 + (index % 5) * 28,
width: 280,
height: 260,
},
maximized: false,
zIndex: 20 + index,
};
}
const defaultLayersWindowRect: WorkspaceWindowRect = {
x: 1024,
y: 72,
width: 336,
height: 500,
};
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
return Object.fromEntries(bindings.map((binding, index) => {
const state = savedByBinding.get(binding.id);
return [binding.id, state ?? {
bindingId: binding.id,
visible: true,
filters: {},
window: defaultSubjectWindowState(index),
}];
})) as Record<string, MapSubjectState>;
}
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value)); const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value)); const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
const formatMetricDistance = (value: number) => value >= 1000 const formatMetricDistance = (value: number) => value >= 1000
@ -222,13 +301,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
applicationId?: string; applicationId?: string;
pageId?: string; pageId?: string;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) { }>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const fixtureSelectable = useMemo(() => [ const workspaceRef = useRef<HTMLDivElement>(null);
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })), const [selectedId, setSelectedId] = useState<string>();
...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 ?? fixtureSelectable[0]?.id);
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [layersWindowActive, setLayersWindowActive] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar)); const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [assistantOpen, setAssistantOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({ const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
@ -254,31 +334,91 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Map pin bindings belong to the application page instance. They are kept // Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save. // intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []); const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
// Presentation profiles are application/page-owned, versioned map.style_profile
// values. A human camera/settings save must preserve profiles provisioned by MCP.
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
));
// Data-product bindings are provisioned by Foundry MCP / Platform and do // Data-product bindings are provisioned by Foundry MCP / Platform and do
// not belong to the visual inspector. Preserve them verbatim when a human // not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout. // edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []); const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
));
const [activeSubjectBindingId, setActiveSubjectBindingId] = useState<string>();
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
visible: state.visible,
facets: state.filters,
}]),
), [subjectStates]);
const runtimeBindings = useMapDataProductRuntime({ const runtimeBindings = useMapDataProductRuntime({
applicationId, applicationId,
pageId, pageId,
bindings: dataProductBindings, bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId), enabled: Boolean(applicationId && pageId),
}); });
const selectable = useMemo(() => [ const selectable = useMemo(() => (
...fixtureSelectable, runtimeBindings.flatMap((binding) => {
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => { const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
const attributes = fact.attributes; const facts = [...binding.facts];
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id] const primaryProfile = mapPresentationProfileForFact(
.find((value) => typeof value === "string" && value.trim()); presentationProfiles,
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined); bindingConfig?.presentationProfileId,
bindingConfig?.semanticTypes[0] ?? facts[0]?.semanticType ?? "",
);
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
return facts.map((fact) => {
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
return { return {
id: mapRuntimeEntityId(binding.bindingId, fact), id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId, title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType, kind: fact.semanticType,
status, status: presentationClass?.label ?? fact.presentationStatus,
}; };
})), });
], [fixtureSelectable, runtimeBindings]); })
), [dataProductBindings, presentationProfiles, runtimeBindings]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
const facts = binding?.facts ?? [];
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType);
if (!profile) return [];
return [{
bindingId: bindingConfig.id,
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
profile,
total: facts.length,
counts: mapPresentationFacetCounts(facts, profile),
}];
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
presentationProfiles,
bindingConfig?.presentationProfileId,
fact.semanticType,
);
if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return [];
const presentationClass = resolveMapPresentationClass(fact, profile);
return [{
bindingId: binding.bindingId,
entityId: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
status: presentationClass?.label ?? "",
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
// The header Save action can be pressed immediately after Cesium finishes // The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in // constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's // state, so the imperative page-layout contract never waits for React's
@ -481,9 +621,86 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
mapHeight: Math.round(mapHeight), mapHeight: Math.round(mapHeight),
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings, pinBindings,
presentationProfiles,
dataProductBindings, dataProductBindings,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id,
visible: true,
filters: {},
window: defaultSubjectWindowState(0),
}), }),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]); }),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
const index = dataProductBindings.findIndex((binding) => binding.id === bindingId);
const state = current[bindingId] ?? {
bindingId,
visible: true,
filters: {},
window: defaultSubjectWindowState(Math.max(0, index)),
};
return { ...current, [bindingId]: update(state) };
});
};
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
updateSubjectState(bindingId, (state) => {
const filters = state.visible ? state.filters : {};
return {
...state,
visible: true,
filters: toggleMapPresentationFacetSelection(filters, field, value),
};
});
};
const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, open: true, zIndex: nextZIndex },
}));
setLayersWindowActive(false);
setActiveSubjectBindingId(bindingId);
};
const closeSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
setActiveSubjectBindingId((current) => current === bindingId ? undefined : current);
};
const activateLayersWindow = () => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setLayersWindowZIndex(nextZIndex);
setLayersWindowActive(true);
setActiveSubjectBindingId(undefined);
};
const toggleLayersWindow = () => {
if (layersOpen) {
setLayersOpen(false);
setLayersWindowActive(false);
return;
}
setLayersOpen(true);
activateLayersWindow();
};
const updatePresentationProfile = (
profileId: string,
update: (profile: MapPresentationProfile) => MapPresentationProfile,
) => setPresentationProfiles((current) => current.map((profile) => (
profile.id === profileId ? update(profile) : profile
)));
const updatePresentationStyle = (profileId: string, styleId: string, patch: Partial<MapPresentationProfile["styles"][number]>) => {
updatePresentationProfile(profileId, (profile) => ({
...profile,
styles: profile.styles.map((style) => style.id === styleId ? { ...style, ...patch } : style),
}));
};
const handleSelect = useCallback((entityId: string) => { const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return; if (!selectable.some((entity) => entity.id === entityId)) return;
@ -681,6 +898,57 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> <RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>, </>,
}, },
...presentationProfiles.flatMap((profile) => [
{
id: `map-target-${profile.id}`,
label: "Таргет",
description: profile.title,
group: "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemHeightMeters } }))} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, headSizePx } }))} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemWidthPx } }))} />
<InspectorSelectField
label="Подпись"
value={profile.label.mode}
options={[
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
{ value: "none", label: "Нет", description: "Не показывать плашку" },
]}
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
/>
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
<RangeControl label="Скрывать таргет выше" value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
</>,
},
{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
</>,
},
]),
{ {
id: "map-grid", id: "map-grid",
label: "Сетка и LOD", label: "Сетка и LOD",
@ -823,6 +1091,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
return ( return (
<div <div
ref={workspaceRef}
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`} className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties} style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
aria-label="Map Page Cesium adapter" aria-label="Map Page Cesium adapter"
@ -841,19 +1110,39 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
initialCamera={mapCamera ?? undefined} initialCamera={mapCamera ?? undefined}
presentation={presentation} presentation={presentation}
runtimeBindings={runtimeBindings} runtimeBindings={runtimeBindings}
presentationProfiles={presentationProfiles}
presentationFilters={presentationFilters}
/> />
</Suspense> </Suspense>
<div className="catalog-map-fixture__actions"> <div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton> <IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={() => setLayersOpen((value) => !value)}><Icon name="grid" /></IconButton> <IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={toggleLayersWindow}><Icon name="grid" /></IconButton>
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null} {features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null} {features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
</div> </div>
{layersOpen ? ( {layersOpen ? (
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты"> <WorkspaceWindow
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div> boundsRef={workspaceRef}
rect={layersWindowRect}
onRectChange={setLayersWindowRect}
maximized={layersWindowMaximized}
onMaximizedChange={setLayersWindowMaximized}
onActivate={activateLayersWindow}
onClose={() => {
setLayersOpen(false);
setLayersWindowActive(false);
}}
title="Слои карты"
active={layersWindowActive}
zIndex={layersWindowZIndex}
minWidth={320}
minHeight={360}
className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window"
aria-label="Настройки слоёв карты"
>
<div className="catalog-map-fixture__layers-content">
<div className="catalog-map-fixture__provider"> <div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong> <strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small> <small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
@ -868,16 +1157,113 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null} {gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} /> <Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} /> <Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</GlassSurface> </div>
</WorkspaceWindow>
) : null} ) : null}
{toolbarOpen ? ( {toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar"> <div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
<IconButton label="Обзор"><Icon name="globe" /></IconButton> <Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="menu"
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
)}
>
{({ close }) => (
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small>
</div>
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<button
type="button"
role="menuitem"
className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId}
data-open={state?.window.open || undefined}
onClick={() => {
openSubjectWindow(summary.bindingId);
close();
}}
>
<span>{summary.displayName}</span>
<small>{state?.visible === false || visibleCount === 0 ? "на карте: 0" : `на карте: ${visibleCount}`}{state?.window.open ? " · окно открыто" : ""}</small>
</button>
);
})}
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
<IconButton label="Поиск"><Icon name="search" /></IconButton> <IconButton label="Поиск"><Icon name="search" /></IconButton>
</div> </div>
) : null} ) : null}
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
if (!state?.window.open) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<WorkspaceWindow
key={summary.bindingId}
boundsRef={workspaceRef}
rect={state.window.rect}
onRectChange={(rect) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, rect },
}))}
maximized={state.window.maximized}
onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, maximized },
}))}
onActivate={() => openSubjectWindow(summary.bindingId)}
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={activeSubjectBindingId === summary.bindingId}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = state.filters[facet.field]?.includes(item.value) ?? false;
return (
<button
type="button"
key={`${facet.field}:${item.value}`}
aria-pressed={active}
data-active={active || undefined}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
>
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
);
})
))}
</div>
</section>
</div>
</WorkspaceWindow>
);
})}
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null} {assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button> <button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
@ -890,6 +1276,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
closeOnBackdrop={false} closeOnBackdrop={false}
lockBodyScroll={false} lockBodyScroll={false}
trapFocus={false} trapFocus={false}
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)} onClose={() => setInspectorOpen(false)}
> >
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen /> <Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />

View File

@ -1,5 +1,6 @@
import type { NodedcTheme } from "@nodedc/ui-core"; import type { NodedcTheme } from "@nodedc/ui-core";
import type { MapPageLayout } from "./MapFixturePreview.js"; import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export const applicationManifestSchemaVersion = "0.1.0" as const; export const applicationManifestSchemaVersion = "0.1.0" as const;
@ -24,6 +25,16 @@ export interface ApplicationPageManifest {
layout?: { layout?: {
map?: MapPageLayout; map?: MapPageLayout;
}; };
/**
* Design-only deviations from the selected Design Profile. Runtime camera,
* data bindings and provider-neutral subjects remain in `layout.map`.
*/
designOverrides?: {
map?: {
settings?: Partial<MapPageSettings>;
presentationProfiles?: MapPresentationProfile[];
};
};
} }
export interface ApplicationManifestV01 { export interface ApplicationManifestV01 {

View File

@ -0,0 +1,125 @@
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
import type { ToolbarPlacement } from "@nodedc/ui-react";
import type { FaviconAssetUrls } from "./favicon.js";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export type MaterialDraft = {
panelHex: string;
panelOpacity: number;
fieldHex: string;
fieldOpacity: number;
nestedHex: string;
};
export type MapDesignProfileFragment = {
schemaVersion: 1;
templateId: "map";
templateVersion: string;
settings: MapPageSettings;
presentationProfiles: MapPresentationProfile[];
};
export type DesignProfilePageFragment = MapDesignProfileFragment;
export interface StoredLayout {
theme?: NodedcTheme;
accentHex?: string;
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
environment?: {
lightColor?: string;
brightness?: number;
glowDistance?: number;
connectionType?: string;
connectionColor?: string;
usePortColors?: boolean;
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeOpacity?: number;
};
media?: {
source?: "file" | "url";
url?: string;
fileName?: string;
fileSrc?: string;
visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
};
/** Design fragments are keyed by a registered Page Library type/version. */
pageTypes?: Record<string, DesignProfilePageFragment>;
}
export type MapDesignOverrides = {
settings?: Partial<MapPageSettings>;
presentationProfiles?: MapPresentationProfile[];
};
export const mapDesignProfileKey = (templateVersion: string) => `map@${templateVersion}`;
export function mapDesignFragmentFromLayout(layout: MapPageLayout, templateVersion: string): MapDesignProfileFragment {
return {
schemaVersion: 1,
templateId: "map",
templateVersion,
settings: structuredClone(layout.settings),
presentationProfiles: structuredClone(layout.presentationProfiles),
};
}
export function mapDesignFragmentForLayout(layout: StoredLayout | null | undefined, templateVersion: string) {
const fragment = layout?.pageTypes?.[mapDesignProfileKey(templateVersion)];
return fragment?.templateId === "map" ? fragment : null;
}
export function resolveMapDesignLayout(
base: MapPageLayout | null | undefined,
fragment: MapDesignProfileFragment | null | undefined,
overrides?: MapDesignOverrides,
) {
if (!base) return null;
if (!fragment) return base;
return {
...base,
settings: {
...base.settings,
...fragment.settings,
...overrides?.settings,
},
presentationProfiles: structuredClone(overrides?.presentationProfiles ?? fragment.presentationProfiles),
} satisfies MapPageLayout;
}
export function mapDesignOverridesFromResolved(
resolved: MapPageLayout,
fragment: MapDesignProfileFragment | null | undefined,
): MapDesignOverrides | undefined {
if (!fragment) return undefined;
const settings = Object.fromEntries(Object.entries(resolved.settings).filter(([key, value]) => (
fragment.settings[key as keyof MapPageSettings] !== value
))) as Partial<MapPageSettings>;
const presentationProfiles = JSON.stringify(resolved.presentationProfiles) === JSON.stringify(fragment.presentationProfiles)
? undefined
: structuredClone(resolved.presentationProfiles);
if (Object.keys(settings).length === 0 && !presentationProfiles) return undefined;
return {
...(Object.keys(settings).length > 0 ? { settings } : {}),
...(presentationProfiles ? { presentationProfiles } : {}),
};
}

View File

@ -0,0 +1,255 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapPresentationFacetValue = {
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never be normalized to all. */
visible: boolean;
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
facets: Record<string, string[]>;
};
/** Application view state is keyed by stable binding id, never by editable labels. */
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
/**
* Application manifests persisted before profile v1.1 used the internal key
* `pin`. Normalize that storage shape before the first React render so an old
* application cannot crash while it is being upgraded to the public `target`
* contract through MCP.
*/
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
return profiles.flatMap((profile) => {
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* Apply one interactive facet-chip transition without collapsing the storage
* contract. A missing field means unconstrained, while an explicitly persisted
* empty array remains available to represent an intentional match-nothing view.
*/
export function toggleMapPresentationFacetSelection(
facets: Record<string, string[]>,
field: string,
value: string,
) {
const selected = facets[field];
if (!selected?.includes(value)) {
return { ...facets, [field]: [...(selected ?? []), value] };
}
const nextSelected = selected.filter((item) => item !== value);
if (nextSelected.length > 0) return { ...facets, [field]: nextSelected };
const { [field]: _removed, ...unconstrained } = facets;
return unconstrained;
}
export function mapPresentationProfileForFact(
profiles: MapPresentationProfile[],
presentationProfileId: string | undefined,
semanticType: string,
) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
if (selected === undefined) return [];
return [{ facet, selected }];
});
// Persisted empty arrays are an explicit match-nothing state. Interactive
// deselection removes the field instead, so this branch is only reached for
// a deliberately saved empty view.
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
// Chips form one global union. This mirrors the objects window: choosing
// values from two categories expands the visible set instead of requiring a
// fact to satisfy both categories simultaneously.
return selectedFacets.some(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(
facts: MapRuntimeFact[],
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
})) as Record<string, Record<string, number>>;
}
/**
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
* The operational Map, however, must not present a stale last speed as a
* current movement state. When both canonical facets exist, the movement
* facet is therefore scoped to currently active subjects. Other profiles and
* fields keep their ordinary independent-facet behaviour.
*/
function mapFactParticipatesInFacet(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
facet: MapPresentationFacet,
) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order: string[], value: string) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}

View File

@ -603,126 +603,6 @@ textarea {
display: none; 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 { .catalog-map-fixture__actions {
position: absolute; position: absolute;
z-index: 8; z-index: 8;
@ -734,15 +614,16 @@ textarea {
.catalog-map-fixture__actions .nodedc-icon-button, .catalog-map-fixture__actions .nodedc-icon-button,
.catalog-map-fixture__toolbar .nodedc-icon-button { .catalog-map-fixture__toolbar .nodedc-icon-button {
border: 1px solid var(--nodedc-glass-outline); border: 0;
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 76%, transparent); background: var(--nodedc-map-glass-bg);
color: var(--nodedc-map-glass-text);
box-shadow: var(--nodedc-glass-control-shadow); box-shadow: var(--nodedc-glass-control-shadow);
backdrop-filter: blur(var(--nodedc-blur-control)); backdrop-filter: blur(var(--nodedc-blur-control));
} }
.catalog-map-fixture__actions .nodedc-icon-button:hover, .catalog-map-fixture__actions .nodedc-icon-button:hover,
.catalog-map-fixture__toolbar .nodedc-icon-button:hover { .catalog-map-fixture__toolbar .nodedc-icon-button:hover {
background: var(--nodedc-glass-control-hover); background: var(--nodedc-map-glass-hover);
} }
.catalog-map-fixture__actions .nodedc-icon-button[data-active], .catalog-map-fixture__actions .nodedc-icon-button[data-active],
@ -753,21 +634,46 @@ textarea {
} }
.catalog-map-fixture__layers { .catalog-map-fixture__layers {
position: absolute; --nodedc-radius-modal: 1.45rem;
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 { .catalog-map-fixture__map-glass-window,
display: flex; .catalog-map-fixture__map-settings-window {
align-items: center; --nodedc-canvas: #111216;
justify-content: space-between; --nodedc-text-primary: rgba(13, 14, 17, 0.96);
gap: 0.75rem; --nodedc-text-secondary: rgba(13, 14, 17, 0.72);
--nodedc-text-muted: rgba(13, 14, 17, 0.54);
--nodedc-field-bg: rgba(255, 255, 255, 0.88);
--nodedc-glass-control-bg: rgba(255, 255, 255, 0.78);
--nodedc-glass-control-hover: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.96);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
background: var(--nodedc-map-glass-bg);
color: var(--nodedc-text-primary);
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(128%);
-webkit-backdrop-filter: blur(var(--nodedc-blur-modal)) saturate(128%);
}
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action,
.catalog-map-fixture__map-settings-window .nodedc-window__close {
background: rgba(255, 255, 255, 0.24);
color: rgba(255, 255, 255, 0.94);
}
.catalog-map-fixture__map-glass-window .nodedc-workspace-window__action:hover,
.catalog-map-fixture__map-settings-window .nodedc-window__close:hover {
background: rgba(255, 255, 255, 0.42);
color: #ffffff;
}
.catalog-map-fixture__layers-content {
display: grid;
gap: 0.55rem;
}
.catalog-map-fixture__layers .nodedc-workspace-window__body {
padding-top: 0.35rem;
} }
.catalog-map-fixture__layers .nodedc-checker { .catalog-map-fixture__layers .nodedc-checker {
@ -799,13 +705,140 @@ textarea {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 76%, transparent); background: var(--nodedc-map-glass-bg);
padding: 0.4rem; padding: 0.4rem;
translate: -50% 0; translate: -50% 0;
backdrop-filter: blur(var(--nodedc-blur-control)); backdrop-filter: blur(var(--nodedc-blur-control));
box-shadow: var(--nodedc-glass-dropdown-shadow); box-shadow: var(--nodedc-glass-dropdown-shadow);
} }
.catalog-map-fixture__objects-menu {
display: grid;
max-height: min(60vh, 28rem);
overflow: auto;
border-radius: 1rem;
padding: 0.48rem;
}
.catalog-map-fixture__objects-menu-list,
.catalog-map-fixture__objects-menu-head,
.catalog-map-fixture__objects-menu-item {
display: grid;
}
.catalog-map-fixture__objects-menu-list {
gap: 0.24rem;
}
.catalog-map-fixture__objects-menu-head {
gap: 0.12rem;
padding: 0.48rem 0.58rem 0.55rem;
}
.catalog-map-fixture__objects-menu-head small,
.catalog-map-fixture__objects-menu-item small,
.catalog-map-fixture__objects-menu-empty {
color: var(--nodedc-map-glass-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.catalog-map-fixture__objects-menu-item {
width: 100%;
min-width: 0;
gap: 0.16rem;
border: 0;
border-radius: 0.78rem;
background: transparent;
padding: 0.64rem 0.7rem;
color: var(--nodedc-map-glass-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.catalog-map-fixture__objects-menu-item:hover,
.catalog-map-fixture__objects-menu-item:focus-visible,
.catalog-map-fixture__objects-menu-item[data-open] {
background: rgb(255 255 255 / 0.16);
}
.catalog-map-fixture__objects-menu-item > span {
overflow: hidden;
font-size: var(--nodedc-font-size-sm);
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-map-fixture__objects-menu-empty {
padding: 0.72rem;
}
.catalog-map-fixture__subject-window {
--nodedc-radius-modal: 1.45rem;
}
.catalog-map-fixture__subject-window .nodedc-workspace-window__body {
padding: 0.42rem 0.65rem 0.68rem;
}
.catalog-map-fixture__target-filters,
.catalog-map-fixture__target-filters section {
display: grid;
gap: 0.38rem;
}
.catalog-map-fixture__target-filter-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.36rem;
}
.catalog-map-fixture__target-filter-list button {
display: inline-flex;
width: 100%;
min-height: 2.05rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 0;
border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.72);
padding: 0.42rem 0.72rem;
color: rgba(8, 8, 10, 0.88);
font: inherit;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.catalog-map-fixture__target-filter-list button > span {
margin-left: auto;
color: rgba(8, 8, 10, 0.96);
font-weight: 800;
text-align: right;
}
.catalog-map-fixture__target-filter-list button:hover,
.catalog-map-fixture__target-filter-list button[data-active] {
background: rgba(255, 255, 255, 0.96);
color: rgba(8, 8, 10, 0.96);
}
.catalog-map-fixture__target-filter-list button:disabled {
cursor: default;
opacity: 0.55;
}
.catalog-map-inspector__style {
display: grid;
gap: 0.5rem;
border-top: 1px solid color-mix(in srgb, var(--nodedc-glass-outline) 44%, transparent);
padding-top: 0.65rem;
}
.catalog-map-inspector__note { .catalog-map-inspector__note {
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs); font-size: var(--nodedc-font-size-xs);
@ -1192,6 +1225,22 @@ textarea {
gap: 1.25rem; gap: 1.25rem;
} }
.catalog-status-library {
display: grid;
gap: 1rem;
}
.catalog-toast-library-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.catalog-toast-library-grid .nodedc-toast {
width: auto;
min-width: 0;
}
.catalog-modal-group { .catalog-modal-group {
display: grid; display: grid;
gap: 0.65rem; gap: 0.65rem;

View File

@ -24,6 +24,7 @@ export type MapRuntimeBinding = {
bindingId: string; bindingId: string;
dataProductId: string; dataProductId: string;
slotId: string; slotId: string;
presentationProfileId?: string;
facts: MapRuntimeFact[]; facts: MapRuntimeFact[];
cursor: string | null; cursor: string | null;
state: "idle" | "loading" | "ready" | "reconnecting" | "error"; state: "idle" | "loading" | "ready" | "reconnecting" | "error";
@ -249,6 +250,7 @@ export function useMapDataProductRuntime({
slotId: binding.slotId, slotId: binding.slotId,
semanticTypes: binding.semanticTypes, semanticTypes: binding.semanticTypes,
fieldProjection: binding.fieldProjection, fieldProjection: binding.fieldProjection,
presentationProfileId: binding.presentationProfileId,
}))), }))),
[bindings], [bindings],
); );
@ -363,6 +365,7 @@ export function useMapDataProductRuntime({
bindingId: binding.id, bindingId: binding.id,
dataProductId: binding.dataProductId, dataProductId: binding.dataProductId,
slotId: binding.slotId, slotId: binding.slotId,
presentationProfileId: binding.presentationProfileId,
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))), facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
cursor: record.cursor, cursor: record.cursor,
state: record.state, state: record.state,

View File

@ -62,6 +62,8 @@ Dropdown владеет floating-layer поведением:
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine. Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
Поверх Cesium/map imagery dropdown получает класс `nodedc-map-glass` (`MapGlassSurface`): это отдельный светлый полупрозрачный материал, совпадающий с Map Toolbar. Он не заменяет обычный theme-dependent floating surface вне карты.
## Select ## Select
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы: Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
@ -210,6 +212,10 @@ Sortable-строки ограничены вертикальной осью: г
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении. Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
## ToastStack
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation.
## Environment Controls: Inspector и ControlRow ## Environment Controls: Inspector и ControlRow
Единая accordion-панель для settings и definition controls. Источник — только новый Environment Settings и NDC Agent Inspector. Единая accordion-панель для settings и definition controls. Источник — только новый Environment Settings и NDC Agent Inspector.

View File

@ -5,7 +5,7 @@
## Границы ответственности ## Границы ответственности
- Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями. - Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями.
- Map Scene Fixture задаёт минимальный проверочный набор пространственных сущностей и состояний. - Empty Map Scene Fixture проверяет shell без каких-либо shipped domain-объектов.
- Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility. - Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility.
- Platform capability binding связывает слоты шаблона с scoped data products через Foundry runtime BFF. - Platform capability binding связывает слоты шаблона с scoped data products через Foundry runtime BFF.
- Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer. - Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer.
@ -26,14 +26,30 @@ Engine/NDC остаётся средой создания и исполнени
- selection; - selection;
- ready/loading/empty/stale/error/offline states. - ready/loading/empty/stale/error/offline states.
Размеры и внешний вид разновидностей сущностей задаются ссылками на `styleProfiles`. Это позволяет одному доменному типу иметь разные подтверждённые варианты, не создавая новый renderer-specific тип. Размеры и внешний вид разновидностей сущностей задаются ссылками на
`styleProfiles`. Для live entity-stream Application page хранит отдельные
versioned `presentationProfiles`, а binding выбирает один из них через
`presentationProfileId`. Это позволяет одному доменному типу иметь разные
подтверждённые варианты, не создавая новый renderer-specific тип.
Канонический moving-object профиль находится в
`registry/map-presentation-profiles.json`. Он задаёт универсальную геометрию
таргета, плашку подписи, LOD и два пользовательских status facets: связь и
движение. Для текущего Gelios binding значения и подписи повторяют официальный
статусный набор: `Онлайн`, `Офлайн`, `В движении`, `Стоят`. Position
quality и freshness остаются внутренними фактами качества и не дублируют
пользовательские фильтры. Классы, фильтры, счётчики и sort order читают один
profile; renderer не содержит provider-specific условий.
Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout.
Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
## Acceptance fixtures ## Acceptance fixtures
- `registry/fixtures/map/map-operational-v0.1.json` — минимальная рабочая сцена с сеткой, транспортом, станциями, маршрутом, железнодорожным путём, зоной и selection.
- `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий. - `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий.
Fixtures малы и детерминированы. Большие геоданные, tile cache, credentials и реальные streaming snapshots в репозиторий гайдлайнов не входят. Page/Visual/Application previews не содержат shipped domain fixtures. Объекты появляются только из declared Application bindings. Большие геоданные, tile cache, credentials и реальные streaming snapshots в репозиторий гайдлайнов не входят.
## Sandbox credit overlay ## Sandbox credit overlay
@ -41,7 +57,7 @@ Fixtures малы и детерминированы. Большие геодан
## Cesium adapter 1.143 ## 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`. Текущий reference adapter использует CesiumJS `1.143.0`. Он загружается отдельным lazy chunk только при открытии Map Page и преобразует только declared Application bindings в реальные Cesium entities. Пустая Map Page не содержит domain entities.
Server-side runtime contract: Server-side runtime contract:
@ -82,7 +98,8 @@ Runtime tile cache не является исходным кодом и не х
## Live data-product runtime ## Live data-product runtime
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved `Map Page` хранит только provider-neutral binding: `dataProductId`, approved
semantic types, field projection и `slotId` (`points` для live point entities). semantic types, field projection, `presentationProfileId` и `slotId` (`points`
для live point entities).
При открытии Application page browser делает same-origin запрос к Foundry: При открытии Application page browser делает same-origin запрос к Foundry:
```text ```text
@ -94,6 +111,9 @@ runtime. При первом exact consumer apply Foundry генерирует t
передаёт EDP только его digest в запросе с отдельной service-подписью; EDP сам передаёт EDP только его digest в запросе с отдельной service-подписью; EDP сам
разрешает unique active writer scope и fail-closed отклоняет ambiguity. Legacy разрешает unique active writer scope и fail-closed отклоняет ambiguity. Legacy
root-owned deployment directory используется только как совместимый fallback. root-owned deployment directory используется только как совместимый fallback.
При смене versioned Data Product Foundry создаёт successor reader generation,
bootstrap-ит новый snapshot и только затем отзывает predecessor; существующий
grant никогда не переписывается другим request hash внутри одного поколения.
Browser, manifest и Cesium adapter не получают provider Browser, manifest и Cesium adapter не получают provider
endpoint, tenant/connection scope, reader token или raw provider payload. endpoint, tenant/connection scope, reader token или raw provider payload.
Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch
@ -104,3 +124,13 @@ history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource` занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
на binding и обновляет stable entity id без пересоздания viewer. Sampling и на binding и обновляет stable entity id без пересоздания viewer. Sampling и
retention остаются политикой Data Plane, а не Map Template. retention остаются политикой Data Plane, а не Map Template.
`fleet.positions.current.v2@2.0.0` является первым state-aware moving-object
контрактом. Пользовательский status contract ограничен
`availability_state=online|offline` и `motion_state=moving|stationary`;
границы берутся из настроек объекта Gelios (`lostConnectionTimeValue` и
`minimumMovementSpeed`), а не из придуманных Foundry thresholds. Технические
`position_state`, `freshness_state` и `state_policy_version` могут оставаться в
факте для quality/diagnostics, но не образуют дополнительные UI-фильтры. Цвет,
геометрия таргета, label и порядок фильтров остаются в Foundry presentation
profile.

View File

@ -15,6 +15,8 @@ MCP не вводит новую оркестрацию. Доступ выдаё
| Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс | | Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс |
| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, provider API или произвольный внешний endpoint из Foundry MCP | | Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, provider API или произвольный внешний endpoint из Foundry MCP |
| Создавать/обновлять provider-neutral map pin bindings | Хранить provider tokens, transport payload или credentials в manifest | | Создавать/обновлять provider-neutral map pin bindings | Хранить provider tokens, transport payload или credentials в manifest |
| Создавать/обновлять versioned provider-neutral `map.style_profile` | Хардкодить provider-specific statuses, цвета или размеры в renderer |
| Задавать text label/order binding и сохранять полный Map view/window layout по `bindingId` | Использовать редактируемое имя как machine identity или превращать empty selection в all |
| Связывать versioned data product с approved Map entity-stream slot | Хранить provider ID, tenant/connection, endpoint или credential в data binding | | Связывать versioned data product с approved Map entity-stream slot | Хранить provider ID, tenant/connection, endpoint или credential в data binding |
| Управлять server-owned consumer только для persisted approved binding | Передавать reader capability, EDP URL или raw provider payload через MCP | | Управлять server-owned consumer только для persisted approved binding | Передавать reader capability, EDP URL или raw provider payload через MCP |
@ -55,6 +57,10 @@ Endpoint поддерживает два существующих контура
- `foundry_update_application_metadata` - `foundry_update_application_metadata`
- `foundry_add_page_instance` - `foundry_add_page_instance`
- `foundry_upsert_map_pin_binding` - `foundry_upsert_map_pin_binding`
- `foundry_remove_map_pin_binding`
- `foundry_upsert_map_presentation_profile`
- `foundry_update_map_page_settings`
- `foundry_save_map_page_view_state`
- `foundry_upsert_map_data_product_binding` - `foundry_upsert_map_data_product_binding`
- `foundry_plan_map_data_product_consumer` - `foundry_plan_map_data_product_consumer`
- `foundry_apply_map_data_product_consumer` - `foundry_apply_map_data_product_consumer`
@ -62,11 +68,43 @@ Endpoint поддерживает два существующих контура
- `foundry_accept_map_data_product_consumer` - `foundry_accept_map_data_product_consumer`
- `foundry_rollback_map_data_product_consumer` - `foundry_rollback_map_data_product_consumer`
`foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции: визуальная привязка и поток данных будут связываться следующими contract/ontology слоями. `foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции.
`foundry_remove_map_pin_binding` удаляет одну устаревшую визуальную привязку по
стабильному `bindingId`. Операция не затрагивает data-product consumer, его
последний безопасный snapshot или канонический шаблон `Page Library`.
`foundry_upsert_map_presentation_profile` управляет отдельным версионированным
`map.style_profile` страницы. Профиль содержит renderer-neutral геометрию
таргета, label contract, semantic styles, правила классов, facet filters,
counters и sort order. Условия классов могут ссылаться только на объявленные
provider-neutral facet fields. Data Product binding выбирает профиль через
`presentationProfileId`; binding также может задавать пользовательский
`displayName` и порядок строки в dropdown `Объекты`. Provider raw status,
endpoint и credential в профиль не допускаются. Полная machine-readable JSON Schema профиля публикуется прямо
в `tools/list`: label использует `mode`, `fields`, плашку, `fontWeight` и
`sizePx`, а target,
facets, styles, classes и sort имеют закрытые наборы полей. Поэтому новый MCP
клиент может создать профиль для другого spatial domain без чтения исходников
Foundry и без неописанного generic JSON.
`foundry_update_map_page_settings` меняет подложку, terrain, здания, атмосферу,
освещение и сетку только у указанного экземпляра Map внутри `Application`.
Операция не пишет в `Page Library`, принимает закрытый typed patch и поэтому
может безопасно воспроизводить визуальное окружение через MCP.
`foundry_save_map_page_view_state` является MCP-эквивалентом одной Application
кнопки `Сохранить`. Операция атомарно сохраняет camera/map height, typed patch
base settings, exact visibility/facet selections и все canonical binding
windows (`open`, `rect`, `maximized`, `zIndex`). Состояние keyed только по
`bindingId`: editable `displayName` не участвует в identity. Missing facet не
ограничивает выборку, явно пустой список остаётся empty после reopen и не
мигрирует обратно в `Все`.
`foundry_upsert_map_data_product_binding` сохраняет только декларацию `foundry_upsert_map_data_product_binding` сохраняет только декларацию
`data product → Map entity-stream slot`: versioned data product id, semantic `data product → Map entity-stream slot`: versioned data product id, semantic
types и допустимую field projection. Endpoint, provider, tenant, connection, types, допустимую field projection и необязательную ссылку на существующий
presentation profile. Endpoint, provider, tenant, connection,
token, credential и raw payload валидатор отклоняет. Page runtime получает token, credential и raw payload валидатор отклоняет. Page runtime получает
scoped snapshot/patch поток через Platform, но не через Foundry MCP. scoped snapshot/patch поток через Platform, но не через Foundry MCP.
@ -82,6 +120,15 @@ target-scoped token, коммитит snapshot и включает consumer; `st
сохраняет последний safe snapshot. Capability value, grant path, internal URL и сохраняет последний safe snapshot. Capability value, grant path, internal URL и
fact attributes в MCP diagnostics не возвращаются. fact attributes в MCP diagnostics не возвращаются.
Reader grant версионируется поколениями. При замене Data Product Foundry не
перезаписывает immutable request generation и не ослабляет EDP conflict fence:
он выпускает отдельный successor token/generation, проверяет им новый catalog и
коммитит новый snapshot. Только после успешного snapshot predecessor generation
отзывается exact revoke. Если bootstrap не удался, persisted predecessor
snapshot и его grant остаются рабочими. Если временно не удался только revoke,
следующий exact plan получает action `finalize-reader-grant-rotation` и завершает
отзыв без повторного bootstrap.
## Runtime data-product boundary ## Runtime data-product boundary
Для Map Page Foundry предоставляет только same-origin runtime routes: Для Map Page Foundry предоставляет только same-origin runtime routes:
@ -93,8 +140,10 @@ GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/stre
``` ```
Маршрут разрешает persisted binding, а затем server-owned consumer находит Маршрут разрешает persisted binding, а затем server-owned consumer находит
opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)`. Нормальный opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)` и persisted
grant создаётся внутри persistent private runtime Foundry; runner монтирует active generation. Generation 1 сохраняет совместимый legacy filename, а
successor capabilities хранятся отдельными root-owned immutable files.
Нормальный grant создаётся внутри persistent private runtime Foundry; runner монтирует
отдельный Ed25519 private key только для подписи digest-only provisioner отдельный Ed25519 private key только для подписи digest-only provisioner
request, а EDP получает только public trust. Старый root-owned read-only grant request, а EDP получает только public trust. Старый root-owned read-only grant
directory остаётся fallback для уже выданных grant. Сам token передаётся directory остаётся fallback для уже выданных grant. Сам token передаётся
@ -121,7 +170,9 @@ Freshness и remove принадлежат versioned provider-neutral policy и
ошибка не удаляет subject. Удаление допустимо только при отсутствии в ошибка не удаляет subject. Удаление допустимо только при отсутствии в
авторитетном snapshot rebase или по canonical `tombstone`/`revoked` operation. авторитетном snapshot rebase или по canonical `tombstone`/`revoked` operation.
Renderer получает стабильный `sourceId + semanticType`, persisted coordinates Renderer получает стабильный `sourceId + semanticType`, persisted coordinates
и Foundry-owned `presentationStatus`; provider identity на стиль и lifecycle не и Foundry-owned `presentationStatus`. Операционные visual classes, фильтры,
счётчики и сортировка разрешаются отдельно из orthogonal state facets и
page-owned `map.style_profile`; provider identity на стиль и lifecycle не
влияет. влияет.
## Внешний Codex: Foundry + отдельная Ontology MCP ## Внешний Codex: Foundry + отдельная Ontology MCP
@ -246,7 +297,7 @@ curl http://127.0.0.1:9920/healthz
## Следующие слои ## Следующие слои
1. Онтологический contract приложения, страницы, page instance, Map Page и visual entities. 1. Онтологический contract приложения, страницы, page instance, Map Page и visual entities.
2. Runtime adapter между ontology/gateway потоками и сохранёнными visual bindings. 2. Расширение runtime adapter с points на targets, zones, routes и tracks.
3. Реальные `elevated-spike`, targets, zones, routes и toolbar commands на instance Map Page. 3. Управляемая публикация и повторное использование presentation profiles между Applications.
4. Access/Hub registration и публикация module route. 4. Access/Hub registration и публикация module route.
5. Отдельная пустая программируемая страница — только после того, как готовые page templates и их MCP-контуры отработаны. 5. Отдельная пустая программируемая страница — только после того, как готовые page templates и их MCP-контуры отработаны.

View File

@ -20,9 +20,7 @@
Каноническая JSON Schema: `registry/schemas/map-scene-fixture-v0.1.schema.json`. Проверочные сцены перечислены прямо в контракте `map@0.1.0` и лежат в `registry/fixtures/map`. Каноническая 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-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Operational demo fixture удалён: Visual Library, Page Library и Applications не поставляют города, движущиеся объекты, станции, маршруты, пути, зоны или selection. Domain entities появляются только из declared Application bindings.
`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, а не в отрыве от визуального результата. 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, а не в отрыве от визуального результата.
@ -40,7 +38,7 @@ Manifest фиксирует:
- favicon source; - favicon source;
- server-controlled timestamps. - server-controlled timestamps.
Manifest не хранит runtime credentials, capability bindings, arbitrary component tree, свободные координаты элементов или deployment secrets. Manifest не хранит runtime credentials, arbitrary component tree или deployment secrets. Map page instance хранит provider-neutral capability bindings и контролируемый view state: camera, base settings, exact facet selection и геометрию canonical binding windows, keyed by stable `bindingId`.
## Draft lifecycle ## Draft lifecycle
@ -72,6 +70,10 @@ Catalog server предоставляет минимальный временн
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый / Опубликовать`. Приложение хранит pinned `profile id + version + status + theme`; media, favicon и material settings принадлежат Design Profile. Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый / Опубликовать`. Приложение хранит pinned `profile id + version + status + theme`; media, favicon и material settings принадлежат Design Profile.
Design Profile также хранит независимые design-only фрагменты по зарегистрированному типу страницы (`pageTypes["map@0.1.0"]`). Для Map в такой фрагмент входят только `settings` и provider-neutral `presentationProfiles`. Camera, высота рабочего окна, pin/data-product bindings, subjects, endpoints и credentials остаются состоянием Application/runtime и никогда не попадают в профиль.
Кнопка Save в Page Library не изменяет канонический Page Template: она открывает ту же модалку Design Profile и обновляет только фрагмент текущего типа страницы. Сохранение следующего типа страницы сливает его фрагмент в тот же профиль, не перезаписывая глобальные поля и ранее сохранённые типы. Application разрешает визуальное состояние в порядке `canonical page defaults → pinned Design Profile page fragment → Application designOverrides`; runtime bindings при этом сохраняются отдельно.
`Draft` — изменяемая рабочая голова профиля. Каждое сохранение увеличивает patch-версию. `Published` — неизменяемый снимок конкретной версии: повторная публикация той же версии запрещена. Application Manifest может ссылаться на draft для внутренней разработки, но стабильный модуль должен фиксировать published release. Изменение будущего draft или публикация следующей версии не меняют уже закреплённое приложение. `Draft` — изменяемая рабочая голова профиля. Каждое сохранение увеличивает patch-версию. `Published` — неизменяемый снимок конкретной версии: повторная публикация той же версии запрещена. Application Manifest может ссылаться на draft для внутренней разработки, но стабильный модуль должен фиксировать published release. Изменение будущего draft или публикация следующей версии не меняют уже закреплённое приложение.
Временный catalog server предоставляет: Временный catalog server предоставляет:
@ -87,13 +89,15 @@ Visual Library использует отдельный Hub-style selector про
Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта. Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта.
Все save/update/publish операции показывают канонический `ToastStack` снизу справа. Loading-state обновляется в success/error в том же toast; blocking confirmation остаётся отдельной modal-механикой.
## Baseline MCP ## Baseline MCP
Foundry предоставляет базовый MCP-контур для уже существующего сквозного AI Workspace Assistant. Он не создаёт отдельную оркестрацию: штатный entitlement adapter выдаёт доступ к Foundry только авторизованному пользователю, после чего ассистент получает ограниченный набор инструментов. Foundry предоставляет базовый MCP-контур для уже существующего сквозного AI Workspace Assistant. Он не создаёт отдельную оркестрацию: штатный entitlement adapter выдаёт доступ к Foundry только авторизованному пользователю, после чего ассистент получает ограниченный набор инструментов.
- `Page Library` и канонические шаблоны доступны только на чтение; - `Page Library` и канонические шаблоны доступны только на чтение;
- изменяются только экземпляры в `Applications`; - изменяются только экземпляры в `Applications`;
- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы и upsert provider-neutral map pin bindings; - доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles и data-product bindings;
- удаление модуля через MCP намеренно отсутствует; - удаление модуля через MCP намеренно отсутствует;
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store. - каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.

View File

@ -21,6 +21,7 @@
"test:reader-grant-provisioner": "node --test server/foundry-reader-grant-provisioner.test.mjs", "test:reader-grant-provisioner": "node --test server/foundry-reader-grant-provisioner.test.mjs",
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs", "test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs", "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs" "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
}, },

View File

@ -86,10 +86,7 @@ export const mapPageTemplate = {
], ],
contracts: { contracts: {
fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json", fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json",
acceptanceFixtures: [ acceptanceFixtures: ["registry/fixtures/map/map-empty-offline-v0.1.json"],
"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", lodPolicySchema: "registry/schemas/map-lod-policy-v0.1.schema.json",
lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json", lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json",
}, },

View File

@ -31,6 +31,12 @@
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13); --nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92); --nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96); --nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
/* Map overlays stay light over imagery in every application theme. */
--nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
--nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity)); --nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46); --nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
--nodedc-glass-rim: transparent; --nodedc-glass-rim: transparent;
@ -96,6 +102,12 @@
--nodedc-glass-control-hover: #f8f8f8; --nodedc-glass-control-hover: #f8f8f8;
--nodedc-glass-control-active: rgba(32, 32, 34, 0.96); --nodedc-glass-control-active: rgba(32, 32, 34, 0.96);
--nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96); --nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96);
/* Map overlays are theme-independent because they sit on live imagery. */
--nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
--nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity)); --nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22); --nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
--nodedc-glass-rim: transparent; --nodedc-glass-rim: transparent;

View File

@ -85,6 +85,24 @@
padding: var(--nodedc-space-6); padding: var(--nodedc-space-6);
} }
.nodedc-map-glass,
.nodedc-glass.nodedc-map-glass {
border: 0;
background: var(--nodedc-map-glass-bg);
color: var(--nodedc-map-glass-text);
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-control));
-webkit-backdrop-filter: blur(var(--nodedc-blur-control));
}
.nodedc-dropdown-surface.nodedc-map-glass {
background: var(--nodedc-map-glass-bg);
color: var(--nodedc-map-glass-text);
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-control));
-webkit-backdrop-filter: blur(var(--nodedc-blur-control));
}
.nodedc-button { .nodedc-button {
--nodedc-button-bg: var(--nodedc-glass-control-bg); --nodedc-button-bg: var(--nodedc-glass-control-bg);
--nodedc-button-color: var(--nodedc-text-primary); --nodedc-button-color: var(--nodedc-text-primary);
@ -2112,6 +2130,83 @@ textarea.nodedc-field__control {
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard); animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
} }
.nodedc-toast-viewport {
position: fixed;
z-index: 1600;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
display: grid;
width: min(24rem, calc(100vw - 2rem));
gap: 0.55rem;
pointer-events: none;
}
.nodedc-toast {
position: relative;
isolation: isolate;
display: grid;
min-height: 4.4rem;
grid-template-columns: 2.45rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.72rem;
overflow: hidden;
border-radius: var(--nodedc-radius-control);
padding: 0.72rem;
pointer-events: auto;
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
.nodedc-toast__icon {
display: grid;
width: 2.45rem;
height: 2.45rem;
place-items: center;
border-radius: 50%;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
box-shadow: var(--nodedc-glass-control-shadow);
}
.nodedc-toast[data-tone="success"] .nodedc-toast__icon { color: rgb(var(--nodedc-success-rgb)); }
.nodedc-toast[data-tone="error"] .nodedc-toast__icon { color: rgb(var(--nodedc-danger-rgb)); }
.nodedc-toast[data-tone="warning"] .nodedc-toast__icon { color: rgb(var(--nodedc-warning-rgb)); }
.nodedc-toast[data-tone="info"] .nodedc-toast__icon { color: var(--nodedc-accent); }
.nodedc-toast[data-tone="loading"] .nodedc-toast__icon svg { animation: nodedc-toast-spin 900ms linear infinite; }
.nodedc-toast__copy {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.nodedc-toast__copy strong {
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.25;
}
.nodedc-toast__copy small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.nodedc-toast__dismiss {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--nodedc-text-muted);
cursor: pointer;
}
.nodedc-toast__dismiss:hover { background: var(--nodedc-glass-control-hover); color: var(--nodedc-text-primary); }
@keyframes nodedc-toast-spin { to { transform: rotate(360deg); } }
.nodedc-window[data-size="sm"] { .nodedc-window[data-size="sm"] {
width: min(28rem, calc(100vw - 2rem)); width: min(28rem, calc(100vw - 2rem));
} }

View File

@ -52,16 +52,17 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
shape?: "circle" | "rounded"; shape?: "circle" | "rounded";
} }
export function IconButton({ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
label, label,
shape = "circle", shape = "circle",
className, className,
children, children,
type = "button", type = "button",
...props ...props
}: IconButtonProps) { }, ref) {
return ( return (
<button <button
ref={ref}
type={type} type={type}
className={cn("nodedc-icon-button", className)} className={cn("nodedc-icon-button", className)}
data-shape={shape === "circle" ? undefined : shape} data-shape={shape === "circle" ? undefined : shape}
@ -72,4 +73,4 @@ export function IconButton({
{children} {children}
</button> </button>
); );
} });

View File

@ -43,3 +43,12 @@ export interface GlassMaterialSurfaceProps extends HTMLAttributes<HTMLDivElement
export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) { export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) {
return <div className={cn("nodedc-glass-material", className)} {...props}>{children}</div>; return <div className={cn("nodedc-glass-material", className)} {...props}>{children}</div>;
} }
export interface MapGlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/** Light translucent surface used only over Cesium/map imagery. */
export function MapGlassSurface({ children, className, ...props }: MapGlassSurfaceProps) {
return <div className={cn("nodedc-map-glass", className)} {...props}>{children}</div>;
}

View File

@ -21,6 +21,7 @@ import {
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
ListChecks, ListChecks,
LocateFixed,
LockKeyhole, LockKeyhole,
MailPlus, MailPlus,
Maximize2, Maximize2,
@ -71,6 +72,7 @@ const icons = {
inbox: Inbox, inbox: Inbox,
key: KeyRound, key: KeyRound,
list: ListChecks, list: ListChecks,
target: LocateFixed,
lock: LockKeyhole, lock: LockKeyhole,
mail: MailPlus, mail: MailPlus,
minimize: Minimize2, minimize: Minimize2,

View File

@ -0,0 +1,69 @@
import { useEffect, type HTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
export type ToastTone = "success" | "error" | "warning" | "info" | "loading";
export type ToastItem = {
id: string;
tone: ToastTone;
title: string;
description?: string;
durationMs?: number | null;
};
const toastIcons: Record<ToastTone, IconName> = {
success: "check",
error: "alert",
warning: "alert",
info: "activity",
loading: "refresh",
};
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem;
onDismiss?: (id: string) => void;
}
export function ToastCard({ item, onDismiss, className, ...props }: ToastCardProps) {
return (
<div
className={cn("nodedc-toast nodedc-glass-material nodedc-material-rim", className)}
data-tone={item.tone}
role={item.tone === "error" ? "alert" : "status"}
{...props}
>
<span className="nodedc-toast__icon" aria-hidden="true"><Icon name={toastIcons[item.tone]} /></span>
<span className="nodedc-toast__copy">
<strong>{item.title}</strong>
{item.description ? <small>{item.description}</small> : null}
</span>
{onDismiss ? (
<button type="button" className="nodedc-toast__dismiss" aria-label="Закрыть уведомление" onClick={() => onDismiss(item.id)}>
<Icon name="close" />
</button>
) : null}
</div>
);
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
if (typeof document === "undefined" || items.length === 0) return null;
return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>,
document.body,
);
}

View File

@ -20,6 +20,7 @@ export * from "./StatusBadge.js";
export * from "./Settings.js"; export * from "./Settings.js";
export * from "./SharingModals.js"; export * from "./SharingModals.js";
export * from "./Toolbar.js"; export * from "./Toolbar.js";
export * from "./Toast.js";
export * from "./UserProfileMenu.js"; export * from "./UserProfileMenu.js";
export * from "./Window.js"; export * from "./Window.js";
export * from "./WorkspaceWindow.js"; export * from "./WorkspaceWindow.js";

View File

@ -72,16 +72,6 @@
"requiredBehavior": ["selected state", "drag state", "assignees", "dates", "quick actions"], "requiredBehavior": ["selected state", "drag state", "assignees", "dates", "quick actions"],
"reasonNotBaseline": "The visual pattern is mature, but task-domain fields must be separated from the reusable card shell." "reasonNotBaseline": "The visual pattern is mature, but task-domain fields must be separated from the reusable card shell."
}, },
{
"id": "toast",
"classification": "missing-primitive",
"priority": "P2",
"referenceSources": ["task-manager"],
"expectedPackage": "@nodedc/ui-react",
"dependsOn": ["status-badge", "button"],
"requiredBehavior": ["stack", "timeout", "persistent error", "action", "screen-reader announcement"],
"reasonNotBaseline": "No cross-application contract has been audited yet."
},
{ {
"id": "tooltip", "id": "tooltip",
"classification": "missing-primitive", "classification": "missing-primitive",

View File

@ -5,16 +5,17 @@
"id": "glass-surface", "id": "glass-surface",
"status": "baseline", "status": "baseline",
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["GlassSurface", "GlassMaterialSurface"], "exports": ["GlassSurface", "GlassMaterialSurface", "MapGlassSurface"],
"domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-material-rim"], "domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-map-glass", "nodedc-material-rim"],
"summary": "Matte application surfaces plus the canonical Engine V4 glass material for modal and Inspector shells.", "summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
"anatomy": ["theme-provided surface", "modal tint", "gradient rim", "backdrop blur/saturation/brightness", "content"], "anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft"], "variants": ["default", "strong", "soft", "map"],
"rules": [ "rules": [
"Geometry is invariant across application themes.", "Geometry is invariant across application themes.",
"A material rim is a glass highlight, not a hard product-colored border.", "A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise.", "Nested surfaces must use a softer tone to avoid card-inside-card noise.",
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector." "GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
"MapGlassSurface is restricted to overlays over Cesium/map imagery and must match the map Toolbar translucency."
] ]
}, },
{ {
@ -337,6 +338,21 @@
"domContract": ["nodedc-status"], "domContract": ["nodedc-status"],
"summary": "Semantic status pill whose tone is independent from the application accent." "summary": "Semantic status pill whose tone is independent from the application accent."
}, },
{
"id": "toast",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ToastCard", "ToastStack", "ToastItem", "ToastTone"],
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
"variants": ["success", "error", "warning", "info", "loading"],
"behavior": ["controlled items", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
"rules": [
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
"Loading notifications remain until the operation updates or dismisses them.",
"Status notifications never replace a blocking confirmation modal."
]
},
{ {
"id": "drag-drop", "id": "drag-drop",
"status": "baseline", "status": "baseline",

View File

@ -14,6 +14,39 @@
"no_position" "no_position"
], ],
"removeMode": "canonical-tombstone-or-snapshot-rebase" "removeMode": "canonical-tombstone-or-snapshot-rebase"
},
{
"id": "map-moving-object-current-v2",
"version": "2.0.0",
"dataProductId": "fleet.positions.current.v2",
"productVersion": "2.0.0",
"staleAfterMs": 60000,
"terminalStatuses": [
"inactive",
"no-position",
"no_position"
],
"removeMode": "canonical-tombstone-or-snapshot-rebase"
},
{
"id": "map-moving-object-current-v4",
"version": "4.0.0",
"dataProductId": "fleet.positions.current.v4",
"productVersion": "4.0.0",
"staleAfterMs": null,
"terminalStatuses": [
"inactive"
],
"statusContract": {
"attribute": "signal_state",
"allowedValues": [
"active",
"inactive"
],
"missing": "reject",
"freshness": "none"
},
"removeMode": "canonical-tombstone-or-snapshot-rebase"
} }
] ]
} }

View File

@ -1,148 +0,0 @@
{
"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": 8,
"pinPresentation": {
"variant": "elevated-spike",
"stemHeightMeters": 120,
"headSizePx": 8,
"stemWidthPx": 2,
"outlineColor": "#0c0d12",
"outlineOpacity": 0.6,
"outlineWidthPx": 1,
"labelOffsetX": 10,
"labelOffsetY": 0,
"pinHideCameraHeightMeters": 120000,
"labelHideCameraHeightMeters": 85000
}
},
{ "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."] }
]
}

View File

@ -0,0 +1,107 @@
{
"schemaVersion": "nodedc.map-presentation-profiles/v1",
"profiles": [
{
"id": "map.moving-object.operational.default",
"version": "1.3.0",
"title": "Флот",
"semanticTypes": ["map.moving_object"],
"label": {
"mode": "subject_id",
"fields": ["display_name", "label", "name", "title"],
"fontWeight": 600,
"sizePx": 18,
"color": "#f5f5f5",
"outlineColor": "#0c0d12",
"outlineWidthPx": 1,
"backgroundColor": "#0c0d12",
"backgroundOpacity": 0.86,
"paddingX": 8,
"paddingY": 5,
"maxLength": 48,
"offsetX": 15,
"offsetY": 10,
"hideCameraHeightMeters": 70000
},
"target": {
"variant": "elevated-spike",
"stemHeightMeters": 1500,
"headSizePx": 9,
"stemWidthPx": 5,
"outlineColor": "#0c0d12",
"outlineOpacity": 0.6,
"outlineWidthPx": 1,
"hideCameraHeightMeters": 100000
},
"facets": [
{
"id": "availability",
"field": "availability_state",
"label": "Связь",
"filterable": true,
"counter": true,
"values": [
{ "value": "online", "label": "Онлайн", "order": 0 },
{ "value": "offline", "label": "Офлайн", "order": 1 }
]
},
{
"id": "motion",
"field": "motion_state",
"label": "Движение",
"filterable": true,
"counter": true,
"values": [
{ "value": "moving", "label": "В движении", "order": 0 },
{ "value": "stationary", "label": "Стоят", "order": 1 }
]
}
],
"styles": [
{ "id": "online", "color": "#86fdb8", "opacity": 0.9 },
{ "id": "offline", "color": "#c2c2c2", "opacity": 0.7 },
{ "id": "moving", "color": "#6fb5fb", "opacity": 0.9 },
{ "id": "stationary", "color": "#ffd485", "opacity": 0.9 }
],
"classes": [
{
"id": "availability-offline",
"label": "Офлайн",
"priority": 400,
"match": [{ "field": "availability_state", "equals": "offline" }],
"styleId": "offline",
"renderable": true
},
{
"id": "motion-moving",
"label": "В движении",
"priority": 300,
"match": [{ "field": "motion_state", "equals": "moving" }],
"styleId": "moving",
"renderable": true
},
{
"id": "motion-stationary",
"label": "Стоят",
"priority": 200,
"match": [{ "field": "motion_state", "equals": "stationary" }],
"styleId": "stationary",
"renderable": true
},
{
"id": "availability-online",
"label": "Онлайн",
"priority": 100,
"match": [{ "field": "availability_state", "equals": "online" }],
"styleId": "online",
"renderable": true
}
],
"defaultClassId": "motion-stationary",
"sort": [
{ "field": "availability_state", "order": ["online", "offline"] },
{ "field": "motion_state", "order": ["moving", "stationary"] }
]
}
]
}

View File

@ -36,7 +36,6 @@
"contracts": { "contracts": {
"fixtureSchema": "registry/schemas/map-scene-fixture-v0.1.schema.json", "fixtureSchema": "registry/schemas/map-scene-fixture-v0.1.schema.json",
"acceptanceFixtures": [ "acceptanceFixtures": [
"registry/fixtures/map/map-operational-v0.1.json",
"registry/fixtures/map/map-empty-offline-v0.1.json" "registry/fixtures/map/map-empty-offline-v0.1.json"
], ],
"lodPolicySchema": "registry/schemas/map-lod-policy-v0.1.schema.json", "lodPolicySchema": "registry/schemas/map-lod-policy-v0.1.schema.json",

View File

@ -54,5 +54,6 @@
"designProfileSchema": "schemas/design-profile-v0.1.schema.json", "designProfileSchema": "schemas/design-profile-v0.1.schema.json",
"mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json", "mapSceneFixtureSchema": "schemas/map-scene-fixture-v0.1.schema.json",
"mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json", "mapLodPolicySchema": "schemas/map-lod-policy-v0.1.schema.json",
"mapPresentationProfileRegistry": "map-presentation-profiles.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." "operatingRule": "Check this registry before creating a NODE.DC interface element. Reuse or extend the canonical export instead of creating an application-local copy."
} }

View File

@ -64,6 +64,91 @@
"features": { "features": {
"type": "object", "type": "object",
"additionalProperties": { "type": "boolean" } "additionalProperties": { "type": "boolean" }
},
"layout": {
"type": "object",
"additionalProperties": false,
"properties": {
"map": {
"type": "object",
"properties": {
"dataProductBindings": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "minLength": 1 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 120 },
"order": { "type": "integer", "minimum": 0, "maximum": 10000 }
}
}
},
"subjectStates": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["bindingId", "visible", "filters", "window"],
"properties": {
"bindingId": { "type": "string", "minLength": 1 },
"visible": { "type": "boolean" },
"filters": {
"type": "object",
"additionalProperties": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string" }
}
},
"window": {
"type": "object",
"additionalProperties": false,
"required": ["open", "rect", "maximized", "zIndex"],
"properties": {
"open": { "type": "boolean" },
"rect": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "width", "height"],
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number", "minimum": 200 },
"height": { "type": "number", "minimum": 160 }
}
},
"maximized": { "type": "boolean" },
"zIndex": { "type": "integer", "minimum": 1 }
}
}
}
}
}
}
}
}
},
"designOverrides": {
"type": "object",
"additionalProperties": false,
"properties": {
"map": {
"type": "object",
"additionalProperties": false,
"properties": {
"settings": {
"type": "object",
"additionalProperties": { "type": ["string", "number", "boolean"] }
},
"presentationProfiles": {
"type": "array",
"items": { "type": "object" }
}
}
}
}
} }
} }
} }

View File

@ -101,6 +101,12 @@
"lensCount": { "type": "integer", "minimum": 1, "maximum": 12 }, "lensCount": { "type": "integer", "minimum": 1, "maximum": 12 },
"autoHide": { "type": "boolean" } "autoHide": { "type": "boolean" }
} }
},
"pageTypes": {
"type": "object",
"maxProperties": 64,
"propertyNames": { "pattern": "^[a-z0-9-]+@[0-9]+\\.[0-9]+\\.[0-9]+$" },
"additionalProperties": { "$ref": "#/$defs/mapPageFragment" }
} }
} }
}, },
@ -129,6 +135,25 @@
"fieldOpacity": { "$ref": "#/$defs/percent" }, "fieldOpacity": { "$ref": "#/$defs/percent" },
"nestedHex": { "$ref": "#/$defs/hex" } "nestedHex": { "$ref": "#/$defs/hex" }
} }
},
"mapPageFragment": {
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "templateId", "templateVersion", "settings", "presentationProfiles"],
"properties": {
"schemaVersion": { "const": 1 },
"templateId": { "const": "map" },
"templateVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"settings": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "type": ["string", "number", "boolean"] }
},
"presentationProfiles": {
"type": "array",
"items": { "type": "object" }
}
}
} }
} }
} }

View File

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const {
mapFactMatchesFilters,
mapPresentationBindingIsAll,
mapPresentationFacetCounts,
toggleMapPresentationFacetSelection,
} = await import(moduleUrl);
const profile = {
id: "map.moving-object.operational.default",
facets: [
{
field: "signal_state",
values: [{ value: "active" }, { value: "inactive" }],
},
{
field: "movement_state",
values: [{ value: "moving" }, { value: "stopped" }],
},
],
};
const onlineMoving = {
attributes: { signal_state: "active", movement_state: "moving" },
};
test("missing subject state is explicit all for that binding", () => {
assert.equal(mapPresentationBindingIsAll("fleet", {}), true);
assert.equal(mapFactMatchesFilters(onlineMoving, profile, {}, "fleet"), true);
});
test("a selected chip restricts only its own facet", () => {
const filters = { fleet: { visible: true, facets: { signal_state: ["active"] } } };
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "inactive", movement_state: "moving" } }, profile, filters, "fleet"), false);
});
test("deselecting the last chip remains empty and never normalizes to all", () => {
const filters = { fleet: { visible: true, facets: { signal_state: [] } } };
assert.equal(mapPresentationBindingIsAll("fleet", filters), false);
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false);
});
test("interactive deselect of the last chip removes the facet constraint", () => {
const selected = toggleMapPresentationFacetSelection({}, "signal_state", "active");
assert.deepEqual(selected, { signal_state: ["active"] });
const unconstrained = toggleMapPresentationFacetSelection(selected, "signal_state", "active");
assert.deepEqual(unconstrained, {});
assert.equal(mapPresentationBindingIsAll("fleet", { fleet: { visible: true, facets: unconstrained } }), true);
});
test("interactive deselect preserves other values and facet constraints", () => {
const facets = {
signal_state: ["active", "inactive"],
movement_state: ["moving"],
};
assert.deepEqual(toggleMapPresentationFacetSelection(facets, "signal_state", "active"), {
signal_state: ["inactive"],
movement_state: ["moving"],
});
});
test("chips from different facets form one global union", () => {
const filters = {
fleet: {
visible: true,
facets: {
signal_state: ["active"],
movement_state: ["moving"],
},
},
};
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "active", movement_state: "stopped" } }, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters({ attributes: { signal_state: "inactive", movement_state: "moving" } }, profile, filters, "fleet"), false);
});
test("movement filters and counters ignore stale movement on inactive subjects", () => {
const activeStopped = { attributes: { signal_state: "active", movement_state: "stopped" } };
const inactiveMoving = { attributes: { signal_state: "inactive", movement_state: "moving" } };
const inactiveStopped = { attributes: { signal_state: "inactive", movement_state: "stopped" } };
const filters = { fleet: { visible: true, facets: { movement_state: ["moving"] } } };
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), true);
assert.equal(mapFactMatchesFilters(inactiveMoving, profile, filters, "fleet"), false);
assert.deepEqual(mapPresentationFacetCounts([
onlineMoving,
activeStopped,
inactiveMoving,
inactiveStopped,
], profile), {
signal_state: { active: 2, inactive: 2 },
movement_state: { moving: 1, stopped: 1 },
});
});
test("explicitly disabled All hides only the selected binding", () => {
const filters = {
fleet: { visible: false, facets: {} },
service: { visible: true, facets: {} },
};
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false);
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "service"), true);
});

View File

@ -323,10 +323,12 @@ try {
return payload.result; return payload.result;
}; };
const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } }); const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } });
assert.equal(initialized.serverInfo.version, "0.2.0"); assert.equal(initialized.serverInfo.version, "0.5.0");
const listedTools = await mcpRequest("tools/list"); const listedTools = await mcpRequest("tools/list");
assert.equal(listedTools.tools.length, 13); assert.equal(listedTools.tools.length, 17);
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer")); assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer"));
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_save_map_page_view_state"));
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding"));
const workloadHeaders = { const workloadHeaders = {
authorization: `Bearer ${bindingGrantToken}`, authorization: `Bearer ${bindingGrantToken}`,

View File

@ -15,6 +15,8 @@ const applicationManifestSchema = await parse("registry/schemas/application-mani
const designProfileSchema = await parse("registry/schemas/design-profile-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 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 mapLodPolicySchema = await parse("registry/schemas/map-lod-policy-v0.1.schema.json");
const mapPresentationProfiles = await parse("registry/map-presentation-profiles.json");
const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json");
const failures = []; const failures = [];
const requireValue = (condition, message) => { const requireValue = (condition, message) => {
@ -26,7 +28,13 @@ requireValue(registry.libraryVersion, "registry libraryVersion is required");
requireValue(Array.isArray(registry.packages) && registry.packages.length >= 4, "registry must list all packages"); requireValue(Array.isArray(registry.packages) && registry.packages.length >= 4, "registry must list all packages");
requireValue(Array.isArray(sources.sources) && sources.sources.length >= 6, "source audit is incomplete"); requireValue(Array.isArray(sources.sources) && sources.sources.length >= 6, "source audit is incomplete");
requireValue(Array.isArray(components.components) && components.components.length >= 10, "component registry is incomplete"); requireValue(Array.isArray(components.components) && components.components.length >= 10, "component registry is incomplete");
requireValue(Array.isArray(candidates.candidates) && candidates.candidates.length >= 10, "candidate registry is incomplete"); requireValue(Array.isArray(candidates.candidates) && candidates.candidates.length >= 1, "candidate registry is incomplete");
requireValue(
Array.isArray(components.components)
&& Array.isArray(candidates.candidates)
&& components.components.length + candidates.candidates.length >= 20,
"component inventory is incomplete",
);
requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete"); requireValue(Array.isArray(icons.groups) && icons.groups.length >= 5, "icon registry is incomplete");
requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0"); requireValue(pages.schemaVersion === "0.1.0", "page registry schemaVersion must be 0.1.0");
requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template"); requireValue(Array.isArray(pages.templates) && pages.templates.length >= 1, "page registry must contain at least one template");
@ -34,10 +42,48 @@ requireValue(registry.applicationManifestSchema === "schemas/application-manifes
requireValue(registry.designProfileSchema === "schemas/design-profile-v0.1.schema.json", "design profile 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.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(registry.mapLodPolicySchema === "schemas/map-lod-policy-v0.1.schema.json", "map LOD policy schema must be registered");
requireValue(registry.mapPresentationProfileRegistry === "map-presentation-profiles.json", "map presentation profile registry must be registered");
requireValue(applicationManifestSchema.properties?.schemaVersion?.const === "0.1.0", "application manifest schema version must be 0.1.0"); 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(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(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"); requireValue(mapLodPolicySchema.properties?.schemaVersion?.const === "0.1.0", "map LOD policy schema version must be 0.1.0");
requireValue(mapPresentationProfiles.schemaVersion === "nodedc.map-presentation-profiles/v1", "map presentation profile registry schema is unsupported");
requireValue(Array.isArray(mapPresentationProfiles.profiles) && mapPresentationProfiles.profiles.length >= 1, "map presentation profiles are missing");
requireValue(dataProductConsumerPolicies.schemaVersion === "nodedc.foundry.data-product-consumer-policies/v1", "data product consumer policy registry schema is unsupported");
requireValue(Array.isArray(dataProductConsumerPolicies.policies) && dataProductConsumerPolicies.policies.length >= 1, "data product consumer policies are missing");
const consumerPolicies = dataProductConsumerPolicies.policies ?? [];
const consumerPolicyIds = consumerPolicies.map((policy) => `${policy.id}@${policy.version}`);
const consumerPolicyProducts = consumerPolicies.map((policy) => `${policy.dataProductId}@${policy.productVersion}`);
requireValue(new Set(consumerPolicyIds).size === consumerPolicyIds.length, "data product consumer policy id/version pairs must be unique");
requireValue(new Set(consumerPolicyProducts).size === consumerPolicyProducts.length, "data product consumer policy product/version pairs must be unique");
for (const policy of consumerPolicies) {
const statusContract = policy.statusContract;
requireValue(typeof policy.id === "string" && /^[a-z0-9._-]{1,160}$/.test(policy.id), `invalid data product consumer policy id: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.version === "string" && /^\d+\.\d+\.\d+$/.test(policy.version), `invalid data product consumer policy version: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.dataProductId === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(policy.dataProductId), `invalid data product consumer policy product: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.productVersion === "string" && /^\d+\.\d+\.\d+$/.test(policy.productVersion), `invalid data product consumer product version: ${policy.id ?? "unknown"}`);
requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`);
requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`);
if (statusContract === undefined) {
requireValue(Number.isInteger(policy.staleAfterMs) && policy.staleAfterMs >= 1_000 && policy.staleAfterMs <= 7 * 24 * 60 * 60 * 1000, `invalid stale window: ${policy.id ?? "unknown"}`);
continue;
}
requireValue(statusContract && typeof statusContract === "object" && !Array.isArray(statusContract), `invalid status contract: ${policy.id ?? "unknown"}`);
requireValue(typeof statusContract?.attribute === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(statusContract.attribute), `invalid status attribute: ${policy.id ?? "unknown"}`);
requireValue(Array.isArray(statusContract?.allowedValues) && statusContract.allowedValues.length >= 1 && new Set(statusContract.allowedValues).size === statusContract.allowedValues.length, `invalid allowed status values: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.allowedValues?.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid allowed status value: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.missing === "reject", `status contract must reject missing values: ${policy.id ?? "unknown"}`);
requireValue(["none", "observed-at"].includes(statusContract?.freshness), `invalid status freshness mode: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.freshness === "none" ? policy.staleAfterMs === null : Number.isInteger(policy.staleAfterMs), `status freshness/stale window mismatch: ${policy.id ?? "unknown"}`);
requireValue(policy.terminalStatuses.every((value) => statusContract.allowedValues.includes(value)), `terminal status is outside the closed value contract: ${policy.id ?? "unknown"}`);
}
const movingObjectV4Policy = consumerPolicies.find((policy) => policy.dataProductId === "fleet.positions.current.v4" && policy.productVersion === "4.0.0");
requireValue(movingObjectV4Policy?.id === "map-moving-object-current-v4", "fleet.positions.current.v4 consumer policy is missing");
requireValue(movingObjectV4Policy?.statusContract?.attribute === "signal_state", "fleet.positions.current.v4 status source must be signal_state");
requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive");
requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states");
const ids = components.components.map((component) => component.id); const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique"); requireValue(new Set(ids).size === ids.length, "component ids must be unique");

View File

@ -17,6 +17,7 @@ import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs"; import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs"; import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs"; import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
import { createFoundryAuth } from "./nodedc-auth.mjs"; import { createFoundryAuth } from "./nodedc-auth.mjs";
const root = fileURLToPath(new URL("..", import.meta.url)); const root = fileURLToPath(new URL("..", import.meta.url));
@ -47,6 +48,11 @@ await mkdir(foundryDataProductConsumersDir, { recursive: true });
await mkdir(foundryAgentDataDir, { recursive: true }); await mkdir(foundryAgentDataDir, { recursive: true });
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8")); const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8")); const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8"));
const mapPresentationProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-presentation-profiles.json"), "utf8"));
if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-profiles/v1") {
throw new Error("map_presentation_profile_registry_invalid");
}
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
const foundryAuth = createFoundryAuth(); const foundryAuth = createFoundryAuth();
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout( const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS, process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
@ -315,8 +321,17 @@ function validateMapDataProductBinding(value) {
const id = requireNonEmptyString(value.id, "invalid_map_data_product_binding_id", 128); const id = requireNonEmptyString(value.id, "invalid_map_data_product_binding_id", 128);
const dataProductId = requireNonEmptyString(value.dataProductId, "invalid_map_data_product_id", 160); const dataProductId = requireNonEmptyString(value.dataProductId, "invalid_map_data_product_id", 160);
const slotId = requireNonEmptyString(value.slotId, "invalid_map_data_product_slot", 80); const slotId = requireNonEmptyString(value.slotId, "invalid_map_data_product_slot", 80);
const displayName = value.displayName === undefined
? null
: requireNonEmptyString(value.displayName, "invalid_map_data_product_display_name", 120).trim();
const order = value.order === undefined
? null
: requireInteger(value.order, 0, 10_000, "invalid_map_data_product_order");
const semanticTypes = value.semanticTypes === undefined ? [] : value.semanticTypes; const semanticTypes = value.semanticTypes === undefined ? [] : value.semanticTypes;
const fieldProjection = value.fieldProjection === undefined ? [] : value.fieldProjection; const fieldProjection = value.fieldProjection === undefined ? [] : value.fieldProjection;
const presentationProfileId = value.presentationProfileId === undefined
? null
: requireNonEmptyString(value.presentationProfileId, "invalid_map_data_product_presentation_profile_id", 160);
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id"); if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id");
if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id"); if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id");
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot"); if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot");
@ -326,6 +341,9 @@ function validateMapDataProductBinding(value) {
if (!Array.isArray(fieldProjection) || fieldProjection.length > 32 || fieldProjection.some((item) => typeof item !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))) { if (!Array.isArray(fieldProjection) || fieldProjection.length > 32 || fieldProjection.some((item) => typeof item !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))) {
throw applicationError("invalid_map_data_product_field_projection"); throw applicationError("invalid_map_data_product_field_projection");
} }
if (presentationProfileId && !/^[a-z][a-z0-9._:-]{1,159}$/.test(presentationProfileId)) {
throw applicationError("invalid_map_data_product_presentation_profile_id");
}
if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) { if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) {
throw applicationError("map_data_product_binding_contains_transport"); throw applicationError("map_data_product_binding_contains_transport");
} }
@ -336,6 +354,9 @@ function validateMapDataProductBinding(value) {
delivery: "snapshot+patch", delivery: "snapshot+patch",
semanticTypes: [...semanticTypes], semanticTypes: [...semanticTypes],
fieldProjection: [...fieldProjection], fieldProjection: [...fieldProjection],
...(displayName ? { displayName } : {}),
...(order !== null ? { order } : {}),
...(presentationProfileId ? { presentationProfileId } : {}),
}; };
} }
@ -350,12 +371,86 @@ function validateMapDataProductBindings(value) {
}); });
} }
function defaultMapSubjectState(bindingId, index) {
return {
bindingId,
visible: true,
filters: {},
window: {
open: false,
rect: { x: 24 + (index % 5) * 28, y: 56 + (index % 5) * 28, width: 280, height: 260 },
maximized: false,
zIndex: 20 + index,
},
};
}
function validateMapSubjectStates(value, dataProductBindings) {
if (!Array.isArray(value) || value.length > 64) throw applicationError("invalid_map_subject_states");
const bindingIds = new Set(dataProductBindings.map((binding) => binding.id));
const states = new Map();
for (const raw of value) {
if (!isObject(raw)) throw applicationError("invalid_map_subject_state");
const bindingId = requireNonEmptyString(raw.bindingId, "invalid_map_subject_state_binding_id", 128);
if (!bindingIds.has(bindingId)) continue;
if (states.has(bindingId)) throw applicationError("duplicate_map_subject_state_binding_id");
const filters = raw.filters === undefined ? {} : raw.filters;
if (!isObject(filters) || Object.keys(filters).length > 32) throw applicationError("invalid_map_subject_filters");
const normalizedFilters = {};
for (const [field, selected] of Object.entries(filters)) {
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(field)) throw applicationError("invalid_map_subject_filter_field");
if (!Array.isArray(selected) || selected.length > 64 || selected.some((item) => typeof item !== "string" || item.length > 160)) {
throw applicationError("invalid_map_subject_filter_values");
}
normalizedFilters[field] = [...new Set(selected)];
}
if (!isObject(raw.window) || !isObject(raw.window.rect)) throw applicationError("invalid_map_subject_window");
states.set(bindingId, {
bindingId,
visible: requireBoolean(raw.visible, "invalid_map_subject_visibility"),
filters: normalizedFilters,
window: {
open: requireBoolean(raw.window.open, "invalid_map_subject_window_open"),
rect: {
x: requireNumber(raw.window.rect.x, -100_000, 100_000, "invalid_map_subject_window_x"),
y: requireNumber(raw.window.rect.y, -100_000, 100_000, "invalid_map_subject_window_y"),
width: requireNumber(raw.window.rect.width, 200, 10_000, "invalid_map_subject_window_width"),
height: requireNumber(raw.window.rect.height, 160, 10_000, "invalid_map_subject_window_height"),
},
maximized: requireBoolean(raw.window.maximized, "invalid_map_subject_window_maximized"),
zIndex: requireInteger(raw.window.zIndex, 1, 100_000, "invalid_map_subject_window_z_index"),
},
});
}
return dataProductBindings.map((binding, index) => states.get(binding.id) ?? defaultMapSubjectState(binding.id, index));
}
const MAP_PAGE_SETTING_KEYS = new Set([
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
"imageryAlpha", "globeColor", "backgroundColor", "atmosphereEnabled", "atmosphereHue",
"atmosphereSaturation", "atmosphereBrightness", "fogEnabled", "fogDensity", "sunEnabled",
"sunHour", "sunIntensity", "shadowsEnabled", "buildingsVisible", "buildingsColor",
"buildingsOpacity", "buildingsDetail", "imageryBrightness", "imageryContrast",
"imagerySaturation", "gridVisible", "gridLodEnabled", "gridHeightMeters",
"gridLod1MaxHeightKm", "gridLod1StepKm", "gridLod2MaxHeightKm", "gridLod2StepKm",
"gridLod3StepKm", "gridRadiusKm", "gridLineWidth", "gridColor", "gridOpacity",
"gridDotsEnabled", "gridDotsSize", "gridDotsColor", "gridDotsOpacity",
]);
function validateMapPageSettingsPatch(value) {
if (!isObject(value) || Object.keys(value).length === 0) throw applicationError("invalid_map_page_settings_patch");
if (Object.keys(value).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_settings_patch_field");
return value;
}
function validateMapPageLayout(value) { function validateMapPageLayout(value) {
if (!isObject(value)) throw applicationError("invalid_map_page_layout"); if (!isObject(value)) throw applicationError("invalid_map_page_layout");
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_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.settings)) throw applicationError("invalid_map_page_settings");
if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera"); if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera");
const settings = value.settings; const settings = value.settings;
if (Object.keys(settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) throw applicationError("invalid_map_page_setting_field");
for (const key of ["imageryVisible", "cacheEnabled", "terrainEnabled", "monochrome", "atmosphereEnabled", "fogEnabled", "sunEnabled", "shadowsEnabled", "buildingsVisible", "gridVisible", "gridLodEnabled", "gridDotsEnabled"]) { for (const key of ["imageryVisible", "cacheEnabled", "terrainEnabled", "monochrome", "atmosphereEnabled", "fogEnabled", "sunEnabled", "shadowsEnabled", "buildingsVisible", "gridVisible", "gridLodEnabled", "gridDotsEnabled"]) {
requireBoolean(settings[key], `invalid_map_page_setting_${key}`); requireBoolean(settings[key], `invalid_map_page_setting_${key}`);
} }
@ -373,6 +468,13 @@ function validateMapPageLayout(value) {
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) { 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}`); 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 dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
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");
}
return { return {
schemaVersion: 1, schemaVersion: 1,
pageId: "map", pageId: "map",
@ -387,10 +489,35 @@ function validateMapPageLayout(value) {
roll: camera.roll, roll: camera.roll,
}, },
pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings), pinBindings: validateMapPinBindings(value.pinBindings === undefined ? [] : value.pinBindings),
dataProductBindings: validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings), presentationProfiles,
dataProductBindings,
subjectStates,
}; };
} }
function validateMapDesignOverrides(value) {
if (!isObject(value)) throw applicationError("invalid_map_design_overrides");
if (Object.keys(value).some((key) => !["settings", "presentationProfiles"].includes(key))) {
throw applicationError("invalid_map_design_override_field");
}
const result = {};
if (value.settings !== undefined) {
if (!isObject(value.settings) || Object.keys(value.settings).some((key) => !MAP_PAGE_SETTING_KEYS.has(key))) {
throw applicationError("invalid_map_design_override_settings");
}
const defaults = defaultMapPageLayout();
const normalized = validateMapPageLayout({
...defaults,
settings: { ...defaults.settings, ...value.settings },
}).settings;
result.settings = Object.fromEntries(Object.keys(value.settings).map((key) => [key, normalized[key]]));
}
if (value.presentationProfiles !== undefined) {
result.presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles);
}
return result;
}
function defaultMapPageLayout() { function defaultMapPageLayout() {
return { return {
schemaVersion: 1, schemaVersion: 1,
@ -404,9 +531,9 @@ function defaultMapPageLayout() {
terrainExaggeration: 1, terrainExaggeration: 1,
monochrome: false, monochrome: false,
monochromeColor: "#15151b", monochromeColor: "#15151b",
imageryGamma: 100, imageryGamma: 57,
imageryHue: 0, imageryHue: 13,
imageryAlpha: 100, imageryAlpha: 27,
globeColor: "#15151b", globeColor: "#15151b",
backgroundColor: "#08090d", backgroundColor: "#08090d",
atmosphereEnabled: false, atmosphereEnabled: false,
@ -421,11 +548,11 @@ function defaultMapPageLayout() {
shadowsEnabled: true, shadowsEnabled: true,
buildingsVisible: true, buildingsVisible: true,
buildingsColor: "#a27aff", buildingsColor: "#a27aff",
buildingsOpacity: 0.82, buildingsOpacity: 1,
buildingsDetail: 16, buildingsDetail: 4,
imageryBrightness: 100, imageryBrightness: 118,
imageryContrast: 100, imageryContrast: 102,
imagerySaturation: 100, imagerySaturation: 0,
gridVisible: true, gridVisible: true,
gridLodEnabled: true, gridLodEnabled: true,
gridHeightMeters: 500, gridHeightMeters: 500,
@ -453,7 +580,9 @@ function defaultMapPageLayout() {
roll: 0, roll: 0,
}, },
pinBindings: [], pinBindings: [],
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
dataProductBindings: [], dataProductBindings: [],
subjectStates: [],
}; };
} }
@ -540,6 +669,56 @@ function validateDesignProfileLayout(value) {
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"), lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"), autoHide: requireBoolean(toolbar.autoHide, "invalid_toolbar_auto_hide"),
}, },
pageTypes: validateDesignProfilePageTypes(value.pageTypes),
};
}
function validateDesignProfilePageTypes(value) {
// Profiles and immutable releases created before page-type fragments were
// introduced inherit the registered provider-neutral Map design default.
if (value === undefined) return defaultDesignProfilePageTypes();
if (!isObject(value) || Object.keys(value).length > 64) throw applicationError("invalid_design_profile_page_types");
const pageTypes = {};
for (const [key, fragment] of Object.entries(value)) {
if (!isObject(fragment) || fragment.schemaVersion !== 1) throw applicationError("invalid_design_profile_page_fragment");
if (Object.keys(fragment).some((field) => !["schemaVersion", "templateId", "templateVersion", "settings", "presentationProfiles"].includes(field))) {
throw applicationError("design_profile_page_fragment_contains_runtime");
}
const templateId = requireNonEmptyString(fragment.templateId, "invalid_design_profile_page_template_id", 80);
const templateVersion = requireNonEmptyString(fragment.templateVersion, "invalid_design_profile_page_template_version", 32);
if (key !== `${templateId}@${templateVersion}`) throw applicationError("invalid_design_profile_page_fragment_key");
const template = findPageTemplate(templateId, templateVersion);
if (!template) throw applicationError("unknown_design_profile_page_template");
if (templateId !== "map") throw applicationError("unsupported_design_profile_page_template");
const defaults = defaultMapPageLayout();
const normalized = validateMapPageLayout({
...defaults,
settings: fragment.settings,
presentationProfiles: fragment.presentationProfiles,
pinBindings: [],
dataProductBindings: [],
});
pageTypes[key] = {
schemaVersion: 1,
templateId: "map",
templateVersion,
settings: normalized.settings,
presentationProfiles: normalized.presentationProfiles,
};
}
return pageTypes;
}
function defaultDesignProfilePageTypes() {
const mapLayout = defaultMapPageLayout();
return {
"map@0.1.0": {
schemaVersion: 1,
templateId: "map",
templateVersion: "0.1.0",
settings: mapLayout.settings,
presentationProfiles: mapLayout.presentationProfiles,
},
}; };
} }
@ -614,6 +793,7 @@ function defaultDesignProfileLayout() {
lensCount: 3, lensCount: 3,
autoHide: true, autoHide: true,
}, },
pageTypes: defaultDesignProfilePageTypes(),
}; };
} }
@ -722,6 +902,7 @@ function validateApplicationManifest(value) {
if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme"); if (value.designProfile.theme !== "dark" && value.designProfile.theme !== "light") throw applicationError("invalid_application_theme");
if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages"); if (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
const pageIds = new Set(); const pageIds = new Set();
const normalizedPages = [];
for (const page of value.pages) { for (const page of value.pages) {
if (!page || typeof page !== "object") throw applicationError("invalid_application_page"); if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
const pageId = String(page.id || "").trim(); const pageId = String(page.id || "").trim();
@ -734,18 +915,38 @@ function validateApplicationManifest(value) {
if (!template) throw applicationError("unknown_application_page_template"); if (!template) throw applicationError("unknown_application_page_template");
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation"); 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.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
let normalizedPage = page;
if (page.layout !== undefined) { if (page.layout !== undefined) {
if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout"); if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout");
if (page.template.id === "map" && page.layout.map !== undefined) validateMapPageLayout(page.layout.map); if (page.template.id === "map" && page.layout.map !== undefined) {
normalizedPage = {
...page,
layout: { ...page.layout, map: validateMapPageLayout(page.layout.map) },
};
}
}
if (page.designOverrides !== undefined) {
if (!isObject(page.designOverrides) || Object.keys(page.designOverrides).some((key) => key !== "map")) {
throw applicationError("invalid_application_page_design_overrides");
}
if (page.designOverrides.map !== undefined) {
if (page.template.id !== "map") throw applicationError("unsupported_application_page_design_overrides");
normalizedPage = {
...normalizedPage,
designOverrides: { map: validateMapDesignOverrides(page.designOverrides.map) },
};
}
} }
const allowedFeatures = new Set(template.features.map((feature) => feature.id)); 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"); if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
for (const feature of template.features) { for (const feature of template.features) {
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled"); if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
} }
normalizedPages.push(normalizedPage);
} }
return { return {
...value, ...value,
pages: normalizedPages,
designProfile: { ...value.designProfile, status: designProfileStatus }, designProfile: { ...value.designProfile, status: designProfileStatus },
metadata: { metadata: {
...value.metadata, ...value.metadata,
@ -852,12 +1053,16 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
return { application, page, binding }; return { application, page, binding };
} }
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) { async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation = 1) {
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) { if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
throw applicationError("data_product_runtime_not_configured", 503); throw applicationError("data_product_runtime_not_configured", 503);
} }
const managedToken = await foundryReaderGrantProvisioner.readToken({ applicationId, pageId, bindingId }); const managedToken = await foundryReaderGrantProvisioner.readToken(
{ applicationId, pageId, bindingId },
{ generation },
);
if (managedToken) return managedToken; if (managedToken) return managedToken;
if (generation !== 1) throw applicationError("data_product_reader_grant_not_found", 403);
if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403); if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403);
// Compatibility fallback for grants issued before Foundry-owned managed // Compatibility fallback for grants issued before Foundry-owned managed
// provisioning. There is no fallback to one shared Platform token. // provisioning. There is no fallback to one shared Platform token.
@ -899,7 +1104,17 @@ async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindi
async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindingId, dataProductId }) { async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindingId, dataProductId }) {
let readerToken; let readerToken;
try { try {
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId); let generation = 1;
try {
const statePath = join(foundryDataProductConsumersDir, `${runtimeTargetGrantKey(applicationId, pageId, bindingId)}.json`);
const state = JSON.parse(await readFile(statePath, "utf8"));
if (Number.isSafeInteger(state?.readerGrantGeneration) && state.readerGrantGeneration > 0) {
generation = state.readerGrantGeneration;
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
readerToken = await resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation);
} catch (error) { } catch (error) {
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false; if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
throw error; throw error;
@ -931,11 +1146,16 @@ async function readRuntimeReaderProduct(readerToken, dataProductId) {
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null; return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
} }
async function inspectFoundryConsumerReaderGrant(target) { async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}) {
try { try {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id); const readerToken = await resolveRuntimeDataProductReaderToken(
target.application.id,
target.page.id,
target.binding.id,
generation,
);
const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId); const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId);
if (product) return { product, readerGrantAction: "reuse" }; if (product) return { product, readerGrantAction: "reuse", readerGrantGeneration: generation };
} catch (error) { } catch (error) {
if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error; if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error;
} }
@ -943,7 +1163,7 @@ async function inspectFoundryConsumerReaderGrant(target) {
throw applicationError("data_product_reader_grant_not_found", 403); throw applicationError("data_product_reader_grant_not_found", 403);
} }
const planned = await foundryReaderGrantProvisioner.plan(target); const planned = await foundryReaderGrantProvisioner.plan(target);
return { product: planned.product, readerGrantAction: "ensure" }; return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
} }
function runtimePointGeometry(value) { function runtimePointGeometry(value) {
@ -1201,7 +1421,8 @@ const dataProductConsumerManager = createFoundryDataProductConsumerManager({
resolveTarget: resolveRuntimeMapDataProductBinding, resolveTarget: resolveRuntimeMapDataProductBinding,
readReaderToken: resolveRuntimeDataProductReaderToken, readReaderToken: resolveRuntimeDataProductReaderToken,
inspectReaderGrant: inspectFoundryConsumerReaderGrant, inspectReaderGrant: inspectFoundryConsumerReaderGrant,
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target), ensureReaderGrant: (target, options) => foundryReaderGrantProvisioner.ensure(target, options),
revokeReaderGrant: (target, options) => foundryReaderGrantProvisioner.revoke(target, options),
resolvePolicy: resolveDataProductConsumerPolicy, resolvePolicy: resolveDataProductConsumerPolicy,
sanitizeSnapshot: sanitizeRuntimeSnapshot, sanitizeSnapshot: sanitizeRuntimeSnapshot,
sanitizePatch: sanitizeRuntimePatch, sanitizePatch: sanitizeRuntimePatch,
@ -1549,7 +1770,7 @@ const foundryMcpOperations = {
const config = foundryMcpConfig(); const config = foundryMcpConfig();
return { return {
module: "NDC Module Foundry", module: "NDC Module Foundry",
schemaVersion: "nodedc.module-foundry.mcp.v0.2", schemaVersion: "nodedc.module-foundry.mcp.v0.5",
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required", status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
canonicalPageLibrary: "read-only", canonicalPageLibrary: "read-only",
applicationInstances: "read-write", applicationInstances: "read-write",
@ -1560,6 +1781,10 @@ const foundryMcpOperations = {
"application.metadata.update", "application.metadata.update",
"page-instance.create", "page-instance.create",
"map-pin.upsert", "map-pin.upsert",
"map-pin.remove",
"map-presentation-profile.upsert",
"map-page-settings.update",
"map-page-view-state.save",
"map-data-product.upsert", "map-data-product.upsert",
"map-data-product-consumer.plan", "map-data-product-consumer.plan",
"map-data-product-consumer.apply", "map-data-product-consumer.apply",
@ -1663,6 +1888,144 @@ const foundryMcpOperations = {
}, },
}); });
}, },
async removeMapPinBinding(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_remove_map_pin_binding",
actor,
input,
action: async () => {
const bindingId = requireNonEmptyString(input.bindingId, "invalid_map_pin_id", 128);
if (!/^[A-Za-z0-9._:-]+$/.test(bindingId)) throw applicationError("invalid_map_pin_id");
let updatedPage = null;
let removed = false;
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
...current,
pages: current.pages.map((page) => {
if (page.id !== input.pageId) return page;
if (page.template?.id !== "map") throw applicationError("map_page_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
const pinBindings = layout.pinBindings.filter((item) => item.id !== bindingId);
removed = pinBindings.length !== layout.pinBindings.length;
updatedPage = {
...page,
layout: { ...page.layout, map: { ...layout, pinBindings, savedAt: new Date().toISOString() } },
};
return updatedPage;
}),
}));
if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, bindingId, removed };
},
});
},
async upsertMapPresentationProfile(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_upsert_map_presentation_profile",
actor,
input,
action: async () => {
const profile = normalizeMapPresentationProfile(input.profile);
let updatedPage = null;
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
...current,
pages: current.pages.map((page) => {
if (page.id !== input.pageId) return page;
if (page.template?.id !== "map") throw applicationError("map_page_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
const presentationProfiles = layout.presentationProfiles.filter((item) => item.id !== profile.id);
presentationProfiles.push(profile);
updatedPage = {
...page,
layout: { ...page.layout, map: validateMapPageLayout({
...layout,
presentationProfiles,
savedAt: new Date().toISOString(),
}) },
designOverrides: {
...page.designOverrides,
map: {
...page.designOverrides?.map,
presentationProfiles,
},
},
};
return updatedPage;
}),
}));
if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, profile };
},
});
},
async updateMapPageSettings(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_update_map_page_settings",
actor,
input,
action: async () => {
const settingsPatch = validateMapPageSettingsPatch(input.settings);
let updatedPage = null;
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
...current,
pages: current.pages.map((page) => {
if (page.id !== input.pageId) return page;
if (page.template?.id !== "map") throw applicationError("map_page_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
updatedPage = {
...page,
layout: { ...page.layout, map: validateMapPageLayout({
...layout,
settings: { ...layout.settings, ...settingsPatch },
}) },
};
return updatedPage;
}),
}));
if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, settings: updatedPage.layout.map.settings };
},
});
},
async saveMapPageViewState(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_save_map_page_view_state",
actor,
input,
action: async () => {
if (!isObject(input.viewState)) throw applicationError("invalid_map_page_view_state");
let updatedPage = null;
const application = await writeFoundryApplicationUpdate(input.applicationId, async (current) => ({
...current,
pages: current.pages.map((page) => {
if (page.id !== input.pageId) return page;
if (page.template?.id !== "map") throw applicationError("map_page_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
const settings = input.viewState.settings === undefined
? layout.settings
: { ...layout.settings, ...validateMapPageSettingsPatch(input.viewState.settings) };
updatedPage = {
...page,
layout: { ...page.layout, map: validateMapPageLayout({
...layout,
settings,
mapHeight: input.viewState.mapHeight,
camera: input.viewState.camera,
subjectStates: input.viewState.subjectStates,
}) },
};
return updatedPage;
}),
}));
if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, viewState: {
settings: updatedPage.layout.map.settings,
mapHeight: updatedPage.layout.map.mapHeight,
camera: updatedPage.layout.map.camera,
subjectStates: updatedPage.layout.map.subjectStates,
} };
},
});
},
async upsertMapDataProductBinding(input, actor) { async upsertMapDataProductBinding(input, actor) {
return executeFoundryMcpWrite({ return executeFoundryMcpWrite({
tool: "foundry_upsert_map_data_product_binding", tool: "foundry_upsert_map_data_product_binding",
@ -1680,17 +2043,36 @@ const foundryMcpOperations = {
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId); const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required"); if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required");
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout()); const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
const resolvedBinding = {
...existingBinding,
...binding,
...(!binding.presentationProfileId && existingBinding?.presentationProfileId
? { presentationProfileId: existingBinding.presentationProfileId }
: {}),
};
if (resolvedBinding.presentationProfileId && !layout.presentationProfiles.some((profile) => profile.id === resolvedBinding.presentationProfileId)) {
throw applicationError("map_data_product_presentation_profile_not_found");
}
const dataProductBindings = layout.dataProductBindings.filter((item) => item.id !== binding.id); const dataProductBindings = layout.dataProductBindings.filter((item) => item.id !== binding.id);
dataProductBindings.push(binding); dataProductBindings.push(resolvedBinding);
updatedPage = { updatedPage = {
...page, ...page,
layout: { ...page.layout, map: { ...layout, dataProductBindings, savedAt: new Date().toISOString() } }, layout: { ...page.layout, map: validateMapPageLayout({
...layout,
dataProductBindings,
savedAt: new Date().toISOString(),
}) },
}; };
return updatedPage; return updatedPage;
}), }),
})); }));
if (!updatedPage) throw applicationError("application_page_not_found", 404); if (!updatedPage) throw applicationError("application_page_not_found", 404);
return { application, page: updatedPage, binding }; return {
application,
page: updatedPage,
binding: updatedPage.layout.map.dataProductBindings.find((item) => item.id === binding.id),
};
}, },
}); });
}, },
@ -2181,16 +2563,7 @@ const server = createServer(async (request, response) => {
return; return;
} }
if (pageLayoutMatch && request.method === "PUT") { if (pageLayoutMatch && request.method === "PUT") {
const pageId = pageLayoutMatch[1]; throw applicationError("page_library_read_only", 405);
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") { if (url.pathname === "/api/map/runtime-config" && request.method === "GET") {

View File

@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
test("Design Profile owns design-only page fragments while Page Library stays read-only", async () => {
const runtimeDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-design-profile-"));
const port = await freePort();
const foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: foundryRoot,
env: {
...process.env,
NODE_ENV: "development",
HOST: "127.0.0.1",
PORT: String(port),
FOUNDRY_RUNTIME_DIR: runtimeDir,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForService(port, foundry);
const base = `http://127.0.0.1:${port}`;
const profileResponse = await fetch(`${base}/api/design-profiles/default`);
assert.equal(profileResponse.status, 200);
const profile = await profileResponse.json();
const fragment = profile.layout.pageTypes["map@0.1.0"];
assert.equal(fragment.templateId, "map");
assert.equal(fragment.templateVersion, "0.1.0");
assert.equal("camera" in fragment, false);
assert.equal("pinBindings" in fragment, false);
assert.equal("dataProductBindings" in fragment, false);
const pageLibraryMutation = await fetch(`${base}/api/page-layouts/map`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(pageLibraryMutation.status, 405);
assert.equal((await pageLibraryMutation.json()).error, "page_library_read_only");
const forbiddenFragment = await fetch(`${base}/api/design-profiles/default`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: profile.name,
layout: {
...profile.layout,
pageTypes: {
...profile.layout.pageTypes,
"map@0.1.0": { ...fragment, camera: { longitude: 0, latitude: 0 } },
},
},
}),
});
assert.equal(forbiddenFragment.status, 400);
assert.equal((await forbiddenFragment.json()).error, "design_profile_page_fragment_contains_runtime");
const savedProfileResponse = await fetch(`${base}/api/design-profiles/default`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: profile.name,
layout: {
...profile.layout,
pageTypes: {
...profile.layout.pageTypes,
"map@0.1.0": {
...fragment,
settings: { ...fragment.settings, buildingsOpacity: 0.74 },
},
},
},
}),
});
assert.equal(savedProfileResponse.status, 200);
const savedProfile = await savedProfileResponse.json();
assert.equal(savedProfile.version, "0.6.1");
assert.equal(savedProfile.layout.pageTypes["map@0.1.0"].settings.buildingsOpacity, 0.74);
const applicationResponse = await fetch(`${base}/api/applications`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Profile merge test", slug: "profile-merge-test", templateId: "map", templateVersion: "0.1.0" }),
});
assert.equal(applicationResponse.status, 201);
const application = await applicationResponse.json();
application.pages[0].designOverrides = { map: { settings: { buildingsOpacity: 0.5 } } };
const applicationSaveResponse = await fetch(`${base}/api/applications/${application.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(application),
});
assert.equal(applicationSaveResponse.status, 200);
const savedApplication = await applicationSaveResponse.json();
assert.deepEqual(savedApplication.pages[0].designOverrides, { map: { settings: { buildingsOpacity: 0.5 } } });
} finally {
foundry.kill("SIGTERM");
await Promise.race([once(foundry, "exit"), new Promise((resolveWait) => setTimeout(resolveWait, 2_000))]);
if (foundry.exitCode === null) foundry.kill("SIGKILL");
await rm(runtimeDir, { recursive: true, force: true });
}
});
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForService(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
throw new Error(`foundry_start_timeout:${output}`);
}

View File

@ -115,6 +115,30 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
assert.equal(foundryListResponse.status, 200); assert.equal(foundryListResponse.status, 200);
const foundryList = await json(foundryListResponse); const foundryList = await json(foundryListResponse);
assert.ok(foundryList.result.tools.some((tool) => tool.name === "foundry_create_application")); assert.ok(foundryList.result.tools.some((tool) => tool.name === "foundry_create_application"));
const removePinTool = foundryList.result.tools.find((tool) => tool.name === "foundry_remove_map_pin_binding");
assert.deepEqual(removePinTool.inputSchema.required, ["applicationId", "pageId", "bindingId", "idempotencyKey"]);
assert.equal(removePinTool.inputSchema.additionalProperties, false);
const presentationTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_presentation_profile");
assert.equal(presentationTool.inputSchema.properties.profile.additionalProperties, false);
assert.ok(presentationTool.inputSchema.properties.profile.required.includes("facets"));
assert.ok(presentationTool.inputSchema.properties.profile.required.includes("target"));
assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties, "pin"), false);
assert.equal(presentationTool.inputSchema.properties.profile.properties.label.properties.sizePx.minimum, 8);
assert.equal(Object.hasOwn(presentationTool.inputSchema.properties.profile.properties.label.properties, "fontSizePx"), false);
assert.deepEqual(presentationTool.inputSchema.properties.profile.properties.label.properties.mode.enum, ["subject_id", "attributes", "none"]);
const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
assert.ok(mapSettingsTool);
assert.equal(mapSettingsTool.inputSchema.properties.settings.additionalProperties, false);
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);
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");
assert.equal(subjectStateSchema.properties.window.properties.rect.additionalProperties, false);
const bindingTool = foundryList.result.tools.find((tool) => tool.name === "foundry_upsert_map_data_product_binding");
assert.equal(bindingTool.inputSchema.properties.binding.properties.displayName.maxLength, 120);
assert.equal(bindingTool.inputSchema.properties.binding.properties.order.maximum, 10000);
const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, { const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST", method: "POST",

View File

@ -43,6 +43,19 @@ function safeErrorCode(error) {
} }
function safeStatusFromFact(fact, policy, nowMs) { function safeStatusFromFact(fact, policy, nowMs) {
const statusContract = policy.statusContract || null;
if (statusContract) {
const sourceStatus = fact?.attributes?.[statusContract.attribute];
if (typeof sourceStatus !== "string" || !statusContract.allowedValues.includes(sourceStatus)) {
throw consumerError("data_product_consumer_fact_status_invalid", 502);
}
if (statusContract.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
return sourceStatus;
}
const observedAt = Date.parse(String(fact?.observedAt || ""));
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
return sourceStatus;
}
const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status] const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status]
.find((value) => typeof value === "string" && value.trim()); .find((value) => typeof value === "string" && value.trim());
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active"; const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
@ -112,20 +125,39 @@ function safeStateSummary(record) {
timestamps: state.timestamps, timestamps: state.timestamps,
lastError: state.lastError || null, lastError: state.lastError || null,
readerGrant: "target-scoped-server-only", readerGrant: "target-scoped-server-only",
readerGrantGeneration: Number.isSafeInteger(state.readerGrantGeneration) ? state.readerGrantGeneration : 1,
}; };
} }
function activeReaderGrantGeneration(state) {
const generation = Number(state?.readerGrantGeneration ?? 1);
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
throw consumerError("data_product_reader_grant_generation_invalid", 500);
}
return generation;
}
function validatePolicy(policy, product) { function validatePolicy(policy, product) {
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) { if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
throw consumerError("data_product_consumer_policy_not_found", 409); throw consumerError("data_product_consumer_policy_not_found", 409);
} }
if (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000) { const statusContract = policy.statusContract === undefined
? null
: validateStatusContract(policy.statusContract);
const staleDisabled = statusContract?.freshness === "none";
if (
(staleDisabled && policy.staleAfterMs !== null)
|| (!staleDisabled && (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000))
) {
throw consumerError("data_product_consumer_policy_invalid", 500); throw consumerError("data_product_consumer_policy_invalid", 500);
} }
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : []; const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) { if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500); throw consumerError("data_product_consumer_policy_invalid", 500);
} }
if (statusContract && terminalStatuses.some((value) => !statusContract.allowedValues.includes(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") { if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
throw consumerError("data_product_consumer_policy_invalid", 500); throw consumerError("data_product_consumer_policy_invalid", 500);
} }
@ -136,10 +168,39 @@ function validatePolicy(policy, product) {
productVersion: product.version, productVersion: product.version,
staleAfterMs: policy.staleAfterMs, staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses], terminalStatuses: [...terminalStatuses],
...(statusContract ? { statusContract } : {}),
removeMode: policy.removeMode, removeMode: policy.removeMode,
}; };
} }
function validateStatusContract(value) {
const keys = value && typeof value === "object" && !Array.isArray(value)
? Object.keys(value).sort()
: [];
if (JSON.stringify(keys) !== JSON.stringify(["allowedValues", "attribute", "freshness", "missing"])) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const attribute = String(value.attribute || "");
const allowedValues = Array.isArray(value.allowedValues) ? value.allowedValues : [];
if (
!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(attribute)
|| allowedValues.length === 0
|| allowedValues.length > 32
|| new Set(allowedValues).size !== allowedValues.length
|| allowedValues.some((item) => typeof item !== "string" || !/^[a-z0-9_-]{1,64}$/.test(item))
|| value.missing !== "reject"
|| !["none", "observed-at"].includes(value.freshness)
) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
return {
attribute,
allowedValues: [...allowedValues],
missing: "reject",
freshness: value.freshness,
};
}
function parseSseBlock(block) { function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] }; const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) { for (const line of block.split("\n")) {
@ -174,6 +235,7 @@ export function createFoundryDataProductConsumerManager({
readReaderToken, readReaderToken,
inspectReaderGrant, inspectReaderGrant,
ensureReaderGrant, ensureReaderGrant,
revokeReaderGrant,
resolvePolicy, resolvePolicy,
sanitizeSnapshot, sanitizeSnapshot,
sanitizePatch, sanitizePatch,
@ -240,16 +302,18 @@ export function createFoundryDataProductConsumerManager({
} }
} }
async function catalog(target) { async function catalog(target, { generation = 1, ensureGeneration = generation } = {}) {
if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503); if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503);
let product = null; let product = null;
let readerGrantAction = "reuse"; let readerGrantAction = "reuse";
let readerGrantGeneration = generation;
if (inspectReaderGrant) { if (inspectReaderGrant) {
const inspection = await inspectReaderGrant(target); const inspection = await inspectReaderGrant(target, { generation });
product = inspection?.product || null; product = inspection?.product || null;
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse"; readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
readerGrantGeneration = readerGrantAction === "ensure" ? ensureGeneration : generation;
} else { } else {
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id); const token = await readReaderToken(target.application.id, target.page.id, target.binding.id, generation);
let response; let response;
try { try {
response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), { response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), {
@ -273,13 +337,32 @@ export function createFoundryDataProductConsumerManager({
if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) { if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) {
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409); throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
} }
return { product, policy: validatePolicy(resolvePolicy(product), product), readerGrantAction }; const policy = validatePolicy(resolvePolicy(product), product);
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
throw consumerError("data_product_consumer_status_field_not_projected", 409);
}
return {
product,
policy,
readerGrantAction,
readerGrantGeneration,
};
} }
async function plan(input) { async function plan(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId); const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const { product, policy, readerGrantAction } = await catalog(target);
const record = await recordFor(target); const record = await recordFor(target);
const currentReaderGrantGeneration = activeReaderGrantGeneration(record.state);
const ensureReaderGrantGeneration = record.state
? Math.min(2_147_483_647, currentReaderGrantGeneration + 1)
: currentReaderGrantGeneration;
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
generation: currentReaderGrantGeneration,
ensureGeneration: ensureReaderGrantGeneration,
});
if (readerGrantAction === "ensure" && record.state && currentReaderGrantGeneration === 2_147_483_647) {
throw consumerError("data_product_reader_grant_generation_exhausted", 409);
}
const safe = safeTarget(target); const safe = safeTarget(target);
const configuration = { const configuration = {
target: safe, target: safe,
@ -287,6 +370,7 @@ export function createFoundryDataProductConsumerManager({
policy, policy,
readerGrant: "target-scoped-server-only", readerGrant: "target-scoped-server-only",
readerGrantAction, readerGrantAction,
readerGrantGeneration,
}; };
const existingMatches = record.state const existingMatches = record.state
&& JSON.stringify(record.state.target) === JSON.stringify(safe) && JSON.stringify(record.state.target) === JSON.stringify(safe)
@ -294,7 +378,16 @@ export function createFoundryDataProductConsumerManager({
&& record.state.product?.version === product.version && record.state.product?.version === product.version
&& record.state.policy?.id === policy.id && record.state.policy?.id === policy.id
&& record.state.policy?.version === policy.version; && record.state.policy?.version === policy.version;
const action = !record.state ? "create" : existingMatches ? (record.state.enabled ? "refresh" : "resume") : "replace"; const predecessorReaderGrantGeneration = Number.isSafeInteger(record.state?.predecessorReaderGrantGeneration)
? record.state.predecessorReaderGrantGeneration
: null;
const action = !record.state
? "create"
: existingMatches && predecessorReaderGrantGeneration
? "finalize-reader-grant-rotation"
: existingMatches
? (record.state.enabled ? "refresh" : "resume")
: "replace";
return { return {
schemaVersion: PLAN_SCHEMA_VERSION, schemaVersion: PLAN_SCHEMA_VERSION,
planId: planHash({ configuration, action }), planId: planHash({ configuration, action }),
@ -302,16 +395,19 @@ export function createFoundryDataProductConsumerManager({
configuration, configuration,
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null, current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
effects: [ effects: [
...(readerGrantAction === "ensure" ? ["ensure-target-scoped-reader-grant"] : []), ...(readerGrantAction === "ensure"
? [record.state ? "ensure-successor-target-scoped-reader-grant" : "ensure-target-scoped-reader-grant"]
: []),
"persist-consumer-state", "persist-consumer-state",
"bootstrap-scoped-snapshot", ...(action === "finalize-reader-grant-rotation" ? [] : ["bootstrap-scoped-snapshot"]),
...(predecessorReaderGrantGeneration ? ["revoke-predecessor-reader-grant"] : []),
"share-one-upstream-stream-per-active-binding", "share-one-upstream-stream-per-active-binding",
], ],
destructive: false, destructive: false,
}; };
} }
function initialState(target, product, policy) { function initialState(target, product, policy, readerGrantGeneration) {
const timestamp = new Date(now()).toISOString(); const timestamp = new Date(now()).toISOString();
return { return {
schemaVersion: STATE_SCHEMA_VERSION, schemaVersion: STATE_SCHEMA_VERSION,
@ -321,6 +417,8 @@ export function createFoundryDataProductConsumerManager({
runtimeState: "bootstrapping", runtimeState: "bootstrapping",
product: { id: product.id, version: product.version }, product: { id: product.id, version: product.version },
policy, policy,
readerGrantGeneration,
predecessorReaderGrantGeneration: null,
cursor: "0", cursor: "0",
snapshotGeneration: 0, snapshotGeneration: 0,
subjects: {}, subjects: {},
@ -337,7 +435,12 @@ export function createFoundryDataProductConsumerManager({
} }
async function fetchSnapshot(record) { async function fetchSnapshot(record) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId); const token = await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
let response; let response;
try { try {
response = await fetchImpl(upstreamUrl(record, "snapshot"), { response = await fetchImpl(upstreamUrl(record, "snapshot"), {
@ -410,26 +513,77 @@ export function createFoundryDataProductConsumerManager({
const planned = await plan(input); const planned = await plan(input);
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409); if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId); const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
const plannedGeneration = planned.configuration.readerGrantGeneration;
if (planned.action === "finalize-reader-grant-rotation") {
const predecessorGeneration = record.state?.predecessorReaderGrantGeneration;
if (!revokeReaderGrant || !Number.isSafeInteger(predecessorGeneration)) {
throw consumerError("data_product_reader_grant_rotation_invalid", 500);
}
await revokeReaderGrant(record.target, { generation: predecessorGeneration });
record.state.predecessorReaderGrantGeneration = null;
record.state.timestamps.updatedAt = new Date(now()).toISOString();
record.state.lastError = null;
await writeState(record);
return { plan: planned, consumer: safeStateSummary(record) };
}
if (planned.configuration.readerGrantAction === "ensure") { if (planned.configuration.readerGrantAction === "ensure") {
if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403); if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403);
await ensureReaderGrant(target); await ensureReaderGrant(target, { generation: plannedGeneration });
} }
const record = await recordFor(target); const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
const { product, policy, readerGrantAction } = await catalog(target); generation: plannedGeneration,
ensureGeneration: plannedGeneration,
});
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409); if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
if (record.stream) stopStream(record); if (readerGrantGeneration !== plannedGeneration) throw consumerError("data_product_reader_grant_generation_mismatch", 409);
if (!record.state || planned.action === "replace") record.state = initialState(target, product, policy);
const previousTarget = record.target;
const previousState = record.state ? structuredClone(record.state) : null;
const previousGeneration = previousState ? activeReaderGrantGeneration(previousState) : null;
if (record.stream) await stopStreamAndWait(record);
if (!record.state || planned.action === "replace") {
record.target = target;
record.state = initialState(target, product, policy, plannedGeneration);
if (previousGeneration && previousGeneration !== plannedGeneration) {
record.state.predecessorReaderGrantGeneration = previousGeneration;
}
}
else { else {
record.target = target; record.target = target;
record.state.target = safeTarget(target); record.state.target = safeTarget(target);
record.state.product = { id: product.id, version: product.version }; record.state.product = { id: product.id, version: product.version };
record.state.policy = policy; record.state.policy = policy;
record.state.readerGrantGeneration = plannedGeneration;
record.state.enabled = true; record.state.enabled = true;
record.state.runtimeState = "bootstrapping"; record.state.runtimeState = "bootstrapping";
record.state.lastError = null; record.state.lastError = null;
} }
try {
await writeState(record); await writeState(record);
await bootstrap(record, { notify: false }); await bootstrap(record, { notify: false });
} catch (error) {
if (previousState) {
record.target = previousTarget;
record.state = previousState;
record.state.runtimeState = record.listeners.size ? "connecting" : "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
if (record.listeners.size) startStream(record);
}
throw error;
}
const predecessorGeneration = record.state.predecessorReaderGrantGeneration;
if (Number.isSafeInteger(predecessorGeneration)) {
if (!revokeReaderGrant) throw consumerError("data_product_reader_grant_rotation_not_configured", 503);
await revokeReaderGrant(previousTarget, { generation: predecessorGeneration });
record.state.predecessorReaderGrantGeneration = null;
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
}
if (record.listeners.size) startStream(record); if (record.listeners.size) startStream(record);
return { plan: planned, consumer: safeStateSummary(record) }; return { plan: planned, consumer: safeStateSummary(record) };
} }
@ -526,7 +680,12 @@ export function createFoundryDataProductConsumerManager({
} }
async function consumeOnce(record, signal) { async function consumeOnce(record, signal) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId); const token = await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
const url = upstreamUrl(record, "stream", record.state.cursor); const url = upstreamUrl(record, "stream", record.state.cursor);
const response = openStream const response = openStream
? await openStream({ url, token, signal }) ? await openStream({ url, token, signal })
@ -625,12 +784,27 @@ export function createFoundryDataProductConsumerManager({
record.stream?.controller.abort(); record.stream?.controller.abort();
} }
async function stopStreamAndWait(record) {
const pending = record.stream?.promise;
stopStream(record);
if (!pending) return;
await Promise.race([
pending.catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, 750)),
]);
}
async function snapshot(input) { async function snapshot(input) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId); const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target); const record = await ensureProvisioned(target);
// A removed target grant revokes browser reads immediately even though the // A removed target grant revokes browser reads immediately even though the
// last safe snapshot remains persisted for rollback/diagnostics. // last safe snapshot remains persisted for rollback/diagnostics.
await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId); await readReaderToken(
record.state.target.applicationId,
record.state.target.pageId,
record.state.target.bindingId,
activeReaderGrantGeneration(record.state),
);
await refreshPresentation(record); await refreshPresentation(record);
return { return {
schemaVersion: "nodedc.data-product.snapshot/v1", schemaVersion: "nodedc.data-product.snapshot/v1",

View File

@ -334,3 +334,283 @@ test("exact apply can ensure a missing target-scoped reader grant without exposi
await rm(stateDir, { recursive: true, force: true }); await rm(stateDir, { recursive: true, force: true });
} }
}); });
test("Data Product replacement bootstraps a successor reader generation before revoking its predecessor", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-generation-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
let currentTarget = structuredClone(target);
let failV2Snapshot = true;
let failPredecessorRevoke = true;
const grants = new Map([[1, "fleet.positions.current.v1"]]);
const events = [];
const product = (id) => ({
id,
version: id.endsWith(".v2") ? "2.0.0" : "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
});
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(currentTarget),
readReaderToken: async (_applicationId, _pageId, _bindingId, generation = 1) => {
return grants.has(generation) ? `ndc_edprb_generation-${generation}` : null;
},
inspectReaderGrant: async (resolvedTarget, { generation = 1 } = {}) => {
const desiredProductId = resolvedTarget.binding.dataProductId;
return {
product: product(desiredProductId),
readerGrantAction: grants.get(generation) === desiredProductId ? "reuse" : "ensure",
readerGrantGeneration: generation,
};
},
ensureReaderGrant: async (resolvedTarget, { generation }) => {
grants.set(generation, resolvedTarget.binding.dataProductId);
events.push(`ensure-g${generation}`);
return { ensured: true, generation };
},
revokeReaderGrant: async (_resolvedTarget, { generation }) => {
if (generation === 1 && failPredecessorRevoke) {
failPredecessorRevoke = false;
events.push("revoke-g1-failed");
throw Object.assign(new Error("foundry_reader_grant_provisioner_unavailable"), { statusCode: 503 });
}
grants.delete(generation);
events.push(`revoke-g${generation}`);
return { revoked: true, generation };
},
resolvePolicy: (resolvedProduct) => ({
id: resolvedProduct.id.endsWith(".v2") ? "map-moving-object-current-v2" : "map-moving-object-current-v1",
version: resolvedProduct.version,
dataProductId: resolvedProduct.id,
productVersion: resolvedProduct.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
const dataProductId = decodeURIComponent(new URL(request).pathname.split("/").at(-2));
const generation = Number(String(options.headers.authorization).match(/generation-(\d+)/)?.[1]);
assert.equal(grants.get(generation), dataProductId);
events.push(`snapshot-${dataProductId}-g${generation}`);
if (dataProductId.endsWith(".v2") && failV2Snapshot) {
throw Object.assign(new Error("data_product_runtime_unavailable"), { statusCode: 503 });
}
return Response.json({
...snapshot(dataProductId.endsWith(".v2") ? "20" : "10"),
dataProduct: { id: dataProductId, version: dataProductId.endsWith(".v2") ? "2.0.0" : "1.0.0" },
});
},
idleStopMs: 10,
staleSweepMs: 10_000,
});
try {
const createPlan = await manager.plan(input);
assert.equal(createPlan.configuration.readerGrantGeneration, 1);
const created = await manager.apply({ ...input, planId: createPlan.planId });
assert.equal(created.consumer.product.id, "fleet.positions.current.v1");
assert.equal(created.consumer.readerGrantGeneration, 1);
currentTarget.binding.dataProductId = "fleet.positions.current.v2";
currentTarget.binding.fieldProjection = [
"display_name", "availability_state", "motion_state", "position_state", "freshness_state",
];
const replacePlan = await manager.plan(input);
assert.equal(replacePlan.action, "replace");
assert.equal(replacePlan.configuration.readerGrantAction, "ensure");
assert.equal(replacePlan.configuration.readerGrantGeneration, 2);
assert.ok(replacePlan.effects.includes("ensure-successor-target-scoped-reader-grant"));
await assert.rejects(
manager.apply({ ...input, planId: replacePlan.planId }),
/data_product_runtime_unavailable/,
);
const preserved = await manager.status(input);
assert.equal(preserved.consumer.product.id, "fleet.positions.current.v1");
assert.equal(preserved.consumer.readerGrantGeneration, 1);
assert.equal(grants.get(1), "fleet.positions.current.v1");
assert.equal(grants.get(2), "fleet.positions.current.v2");
assert.equal(events.includes("revoke-g1"), false);
failV2Snapshot = false;
const retryPlan = await manager.plan(input);
assert.equal(retryPlan.configuration.readerGrantGeneration, 2);
await assert.rejects(
manager.apply({ ...input, planId: retryPlan.planId }),
/foundry_reader_grant_provisioner_unavailable/,
);
const pendingFinalization = await manager.status(input);
assert.equal(pendingFinalization.consumer.product.id, "fleet.positions.current.v2");
assert.equal(pendingFinalization.consumer.readerGrantGeneration, 2);
assert.equal(grants.has(1), true);
const finalizePlan = await manager.plan(input);
assert.equal(finalizePlan.action, "finalize-reader-grant-rotation");
assert.equal(finalizePlan.effects.includes("bootstrap-scoped-snapshot"), false);
assert.ok(finalizePlan.effects.includes("revoke-predecessor-reader-grant"));
const replaced = await manager.apply({ ...input, planId: finalizePlan.planId });
assert.equal(replaced.consumer.product.id, "fleet.positions.current.v2");
assert.equal(replaced.consumer.readerGrantGeneration, 2);
assert.equal(grants.has(1), false);
assert.equal(grants.get(2), "fleet.positions.current.v2");
assert.ok(events.lastIndexOf("snapshot-fleet.positions.current.v2-g2") < events.indexOf("revoke-g1"));
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
const movingObjectV4Policy = {
id: "map-moving-object-current-v4",
version: "4.0.0",
dataProductId: "fleet.positions.current.v4",
productVersion: "4.0.0",
staleAfterMs: null,
terminalStatuses: ["inactive"],
statusContract: {
attribute: "signal_state",
allowedValues: ["active", "inactive"],
missing: "reject",
freshness: "none",
},
removeMode: "canonical-tombstone-or-snapshot-rebase",
};
const movingObjectV4Product = {
id: "fleet.positions.current.v4",
version: "4.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
};
function movingObjectV4Target(fieldProjection = ["display_name", "signal_state", "movement_state", "speed_kph"]) {
return {
application: { id: "44444444-4444-4444-8444-444444444444" },
page: { id: "map" },
binding: {
id: "fleet-current-v4",
dataProductId: movingObjectV4Product.id,
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection,
},
};
}
function movingObjectV4Snapshot(signalState = "active") {
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: movingObjectV4Product.id, version: movingObjectV4Product.version },
generatedAt: "2026-07-20T12:00:01.000Z",
cursor: "40",
facts: [{
sourceId: "gelios-unit-001",
semanticType: "map.moving_object",
observedAt: "2026-01-01T00:00:00.000Z",
receivedAt: "2026-07-20T12:00:00.000Z",
attributes: {
display_name: "Unit 001",
signal_state: signalState,
movement_state: "stopped",
speed_kph: 0,
},
geometry: { type: "Point", coordinates: [37.61, 55.75] },
}],
};
}
test("v4 consumer preserves the closed signal and movement states without inventing freshness", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-"));
const currentTarget = movingObjectV4Target();
let signalState = "active";
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(currentTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
assert.equal(options.headers.authorization, "Bearer ndc_edprb_v4-reader-capability");
assert.ok(new URL(request).pathname.endsWith("/snapshot"));
return Response.json(movingObjectV4Snapshot(signalState));
},
now: () => Date.parse("2026-07-20T12:00:10.000Z"),
});
try {
const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id };
const plan = await manager.plan(input);
assert.deepEqual(plan.configuration.policy.statusContract.allowedValues, ["active", "inactive"]);
assert.equal(plan.configuration.policy.staleAfterMs, null);
const active = await manager.apply({ ...input, planId: plan.planId });
assert.equal(active.consumer.subjects[0].status, "active");
assert.equal(active.consumer.metrics.staleTransitions, 0);
assert.equal((await manager.snapshot(currentTarget)).facts[0].attributes.movement_state, "stopped");
signalState = "inactive";
const refreshPlan = await manager.plan(input);
const inactive = await manager.apply({ ...input, planId: refreshPlan.planId });
assert.equal(inactive.consumer.subjects[0].status, "inactive");
assert.equal(inactive.consumer.metrics.staleTransitions, 0);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("v4 consumer rejects bindings that omit signal_state and snapshots outside the closed contract", async () => {
const omittedStateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-omitted-"));
const omittedTarget = movingObjectV4Target(["display_name", "movement_state"]);
const omittedManager = createFoundryDataProductConsumerManager({
stateDir: omittedStateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(omittedTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
});
try {
await assert.rejects(
omittedManager.plan({ applicationId: omittedTarget.application.id, pageId: omittedTarget.page.id, bindingId: omittedTarget.binding.id }),
/data_product_consumer_status_field_not_projected/,
);
} finally {
await omittedManager.shutdown();
await rm(omittedStateDir, { recursive: true, force: true });
}
const invalidStateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-v4-invalid-"));
const invalidTarget = movingObjectV4Target();
const invalidManager = createFoundryDataProductConsumerManager({
stateDir: invalidStateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => structuredClone(invalidTarget),
readReaderToken: async () => "ndc_edprb_v4-reader-capability",
inspectReaderGrant: async () => ({ product: movingObjectV4Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }),
resolvePolicy: () => movingObjectV4Policy,
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async () => Response.json(movingObjectV4Snapshot("unknown")),
});
try {
const input = { applicationId: invalidTarget.application.id, pageId: invalidTarget.page.id, bindingId: invalidTarget.binding.id };
const plan = await invalidManager.plan(input);
await assert.rejects(
invalidManager.apply({ ...input, planId: plan.planId }),
/data_product_consumer_fact_status_invalid/,
);
const failed = await invalidManager.status(input);
assert.equal(failed.consumer.runtimeState, "error");
assert.equal(failed.consumer.subjectCount, 0);
} finally {
await invalidManager.shutdown();
await rm(invalidStateDir, { recursive: true, force: true });
}
});

View File

@ -122,6 +122,261 @@ function normalizeMcpError(error) {
return { code: "foundry_operation_failed", status: 500 }; return { code: "foundry_operation_failed", status: 500 };
} }
const mapPresentationProfileInputSchema = {
type: "object",
additionalProperties: false,
required: [
"id", "version", "title", "semanticTypes", "label", "target", "facets",
"styles", "classes", "defaultClassId", "sort",
],
properties: {
id: { type: "string", description: "Stable provider-neutral map.style_profile id." },
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
title: { type: "string" },
semanticTypes: {
type: "array",
minItems: 1,
maxItems: 8,
uniqueItems: true,
items: { type: "string" },
},
label: {
type: "object",
additionalProperties: false,
required: [
"mode", "fields", "fontWeight", "sizePx", "color", "outlineColor",
"outlineWidthPx", "backgroundColor", "backgroundOpacity", "paddingX", "paddingY",
"maxLength", "offsetX", "offsetY", "hideCameraHeightMeters",
],
properties: {
mode: { type: "string", enum: ["subject_id", "attributes", "none"] },
fields: {
type: "array",
minItems: 1,
maxItems: 16,
uniqueItems: true,
description: "Provider-neutral fact attributes in fallback order, for example display_name, label, name, title.",
items: { type: "string" },
},
fontWeight: { type: "integer", minimum: 400, maximum: 700 },
sizePx: { type: "number", minimum: 8, maximum: 32 },
color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineWidthPx: { type: "number", minimum: 0, maximum: 6 },
backgroundColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
backgroundOpacity: { type: "number", minimum: 0, maximum: 1 },
paddingX: { type: "number", minimum: 0, maximum: 40 },
paddingY: { type: "number", minimum: 0, maximum: 40 },
maxLength: { type: "integer", minimum: 8, maximum: 240 },
offsetX: { type: "number", minimum: -100, maximum: 100 },
offsetY: { type: "number", minimum: -100, maximum: 100 },
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
target: {
type: "object",
additionalProperties: false,
required: [
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
],
properties: {
variant: {
type: "string",
enum: ["elevated-spike"],
description: "Renderer-neutral presentation preset interpreted by the active Map adapter.",
},
stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 },
headSizePx: { type: "number", minimum: 1, maximum: 64 },
stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 },
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
facets: {
type: "array",
minItems: 1,
maxItems: 16,
items: {
type: "object",
additionalProperties: false,
required: ["id", "field", "label", "filterable", "counter", "values"],
properties: {
id: { type: "string" },
field: { type: "string", description: "Normalized provider-neutral Data Product field." },
label: { type: "string" },
filterable: { type: "boolean" },
counter: { type: "boolean" },
values: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["value", "label", "order"],
properties: {
value: { type: "string" },
label: { type: "string" },
order: { type: "integer", minimum: 0, maximum: 1000 },
},
},
},
},
},
},
styles: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["id", "color", "opacity"],
properties: {
id: { type: "string" },
color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
opacity: { type: "number", minimum: 0, maximum: 1 },
},
},
},
classes: {
type: "array",
minItems: 1,
maxItems: 64,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "priority", "match", "styleId", "renderable"],
properties: {
id: { type: "string" },
label: { type: "string" },
priority: { type: "integer", minimum: -10000, maximum: 10000 },
match: {
type: "array",
maxItems: 8,
description: "All conditions use declared normalized facet fields; an empty list is the fallback class.",
items: {
type: "object",
additionalProperties: false,
required: ["field", "equals"],
properties: { field: { type: "string" }, equals: { type: "string" } },
},
},
styleId: { type: "string" },
renderable: { type: "boolean" },
},
},
},
defaultClassId: { type: "string" },
sort: {
type: "array",
maxItems: 16,
items: {
type: "object",
additionalProperties: false,
required: ["field", "order"],
properties: {
field: { type: "string" },
order: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string" } },
},
},
},
},
};
const mapPageSettingsPatchInputSchema = {
type: "object",
additionalProperties: false,
minProperties: 1,
properties: {
imagerySource: { type: "string" },
imageryVisible: { type: "boolean" },
cacheEnabled: { type: "boolean" },
cacheNoOverwrite: { type: "boolean" },
terrainEnabled: { type: "boolean" },
terrainExaggeration: { type: "number" },
monochrome: { type: "boolean" },
monochromeColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
imageryGamma: { type: "number" },
imageryHue: { type: "number" },
imageryAlpha: { type: "number" },
globeColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
backgroundColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
atmosphereEnabled: { type: "boolean" },
atmosphereHue: { type: "number" },
atmosphereSaturation: { type: "number" },
atmosphereBrightness: { type: "number" },
fogEnabled: { type: "boolean" },
fogDensity: { type: "number" },
sunEnabled: { type: "boolean" },
sunHour: { type: "number" },
sunIntensity: { type: "number" },
shadowsEnabled: { type: "boolean" },
buildingsVisible: { type: "boolean" },
buildingsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
buildingsOpacity: { type: "number" },
buildingsDetail: { type: "number" },
imageryBrightness: { type: "number" },
imageryContrast: { type: "number" },
imagerySaturation: { type: "number" },
gridVisible: { type: "boolean" },
gridLodEnabled: { type: "boolean" },
gridHeightMeters: { type: "number" },
gridLod1MaxHeightKm: { type: "number" },
gridLod1StepKm: { type: "number" },
gridLod2MaxHeightKm: { type: "number" },
gridLod2StepKm: { type: "number" },
gridLod3StepKm: { type: "number" },
gridRadiusKm: { type: "number" },
gridLineWidth: { type: "number" },
gridColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
gridOpacity: { type: "number" },
gridDotsEnabled: { type: "boolean" },
gridDotsSize: { type: "number" },
gridDotsColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
gridDotsOpacity: { type: "number" },
},
};
const mapSubjectStateInputSchema = {
type: "object",
additionalProperties: false,
required: ["bindingId", "visible", "filters", "window"],
properties: {
bindingId: { type: "string", description: "Stable Map Data Product binding id. Editable labels are never state keys." },
visible: { type: "boolean", description: "False is an explicit empty map state and must not be normalized to all." },
filters: {
type: "object",
description: "Facet selections keyed by normalized field. Missing field is unconstrained; an empty array matches nothing.",
additionalProperties: { type: "array", uniqueItems: true, items: { type: "string" } },
},
window: {
type: "object",
additionalProperties: false,
required: ["open", "rect", "maximized", "zIndex"],
properties: {
open: { type: "boolean" },
rect: {
type: "object",
additionalProperties: false,
required: ["x", "y", "width", "height"],
properties: {
x: { type: "number" },
y: { type: "number" },
width: { type: "number", minimum: 200 },
height: { type: "number", minimum: 160 },
},
},
maximized: { type: "boolean" },
zIndex: { type: "integer", minimum: 1 },
},
},
},
};
const tools = [ const tools = [
{ {
name: "foundry_status", name: "foundry_status",
@ -138,7 +393,7 @@ const tools = [
{ {
name: "foundry_get_application", name: "foundry_get_application",
title: "Get application instance", title: "Get application instance",
description: "Read one editable application instance, its page instances, features and map pin bindings.", description: "Read one editable application instance, its page instances, features, map presentation profiles and bindings.",
inputSchema: { inputSchema: {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
@ -245,6 +500,95 @@ const tools = [
}, },
}, },
}, },
{
name: "foundry_remove_map_pin_binding",
title: "Remove map pin binding",
description: "Remove one obsolete visual map-pin binding from a Map Page instance. The operation does not affect data-product consumers or the canonical Page Library.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
bindingId: { type: "string", description: "Stable id of the obsolete visual map-pin binding." },
idempotencyKey: { type: "string" },
},
},
},
{
name: "foundry_upsert_map_presentation_profile",
title: "Upsert Map presentation profile",
description: "Create or update a versioned provider-neutral Map presentation profile. The profile owns target geometry, state classes, filters, counters, sort order and labels; it contains no provider transport or credentials.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "profile"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
profile: {
...mapPresentationProfileInputSchema,
description: "Versioned provider-neutral map.style_profile. Classification uses declared normalized facets, never provider-specific raw status values.",
},
},
},
},
{
name: "foundry_update_map_page_settings",
title: "Update Application Map settings",
description: "Update visual environment settings on one Map page instance inside one Application. This operation never modifies the canonical Page Library template.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "settings"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map page instance id inside the editable Application." },
idempotencyKey: { type: "string" },
settings: mapPageSettingsPatchInputSchema,
},
},
},
{
name: "foundry_save_map_page_view_state",
title: "Save Application Map view state",
description: "Persist one complete current Map view through the Application Save path: camera, map geometry, base settings, exact subject visibility/facets and every binding window geometry/z-order. Stable identity is bindingId.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "viewState"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the editable Application." },
idempotencyKey: { type: "string" },
viewState: {
type: "object",
additionalProperties: false,
required: ["mapHeight", "camera", "subjectStates"],
properties: {
settings: mapPageSettingsPatchInputSchema,
mapHeight: { type: "integer", minimum: 360, maximum: 5000 },
camera: {
type: "object",
additionalProperties: false,
required: ["longitude", "latitude", "height", "heading", "pitch", "roll"],
properties: {
longitude: { type: "number" },
latitude: { type: "number" },
height: { type: "number" },
heading: { type: "number" },
pitch: { type: "number" },
roll: { type: "number" },
},
},
subjectStates: { type: "array", maxItems: 64, items: mapSubjectStateInputSchema },
},
},
},
},
},
{ {
name: "foundry_upsert_map_data_product_binding", name: "foundry_upsert_map_data_product_binding",
title: "Upsert Map data product binding", title: "Upsert Map data product binding",
@ -262,10 +606,13 @@ const tools = [
required: ["id", "dataProductId", "slotId", "semanticTypes"], required: ["id", "dataProductId", "slotId", "semanticTypes"],
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
displayName: { type: "string", minLength: 1, maxLength: 120, description: "User-defined text label shown in the Objects menu and window header." },
order: { type: "integer", minimum: 0, maximum: 10000, description: "Application composition order inside the Objects menu." },
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." }, dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
slotId: { type: "string", description: "Approved Map Page entity-stream slot." }, slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
semanticTypes: { type: "array", items: { type: "string" } }, semanticTypes: { type: "array", items: { type: "string" } },
fieldProjection: { type: "array", items: { type: "string" } }, fieldProjection: { type: "array", items: { type: "string" } },
presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." },
}, },
}, },
}, },
@ -363,6 +710,10 @@ function toolMap(operations) {
foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor), foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor),
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor), foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor), foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
foundry_remove_map_pin_binding: (input, actor) => operations.removeMapPinBinding(input, actor),
foundry_upsert_map_presentation_profile: (input, actor) => operations.upsertMapPresentationProfile(input, actor),
foundry_update_map_page_settings: (input, actor) => operations.updateMapPageSettings(input, actor),
foundry_save_map_page_view_state: (input, actor) => operations.saveMapPageViewState(input, actor),
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor), foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
foundry_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input), foundry_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input),
foundry_apply_map_data_product_consumer: (input, actor) => operations.applyMapDataProductConsumer(input, actor), foundry_apply_map_data_product_consumer: (input, actor) => operations.applyMapDataProductConsumer(input, actor),
@ -405,8 +756,8 @@ export async function handleFoundryMcpRequest(request, response, options) {
return sendJson(response, 200, mcpResult(id, { return sendJson(response, 200, mcpResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION, protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } }, capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc_module_foundry", version: "0.2.0" }, serverInfo: { name: "nodedc_module_foundry", version: "0.5.0" },
instructions: "NDC Module Foundry edits application instances and controls approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.", instructions: "NDC Module Foundry edits application instances, provider-neutral map presentation profiles and approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
})); }));
} }
if (message.method === "notifications/initialized") return sendJson(response, 202, {}); if (message.method === "notifications/initialized") return sendJson(response, 202, {});
@ -477,6 +828,10 @@ export async function handleFoundryEntitlementRequest(request, response, options
"foundry.application.update", "foundry.application.update",
"foundry.page-instance.create", "foundry.page-instance.create",
"foundry.map-pin.upsert", "foundry.map-pin.upsert",
"foundry.map-pin.remove",
"foundry.map-presentation-profile.upsert",
"foundry.map-page-settings.update",
"foundry.map-page-view-state.save",
"foundry.map-data-product.upsert", "foundry.map-data-product.upsert",
"foundry.map-data-product-consumer.read", "foundry.map-data-product-consumer.read",
"foundry.map-data-product-consumer.lifecycle", "foundry.map-data-product-consumer.lifecycle",

View File

@ -38,6 +38,21 @@ function targetDigest(target) {
.digest("hex"); .digest("hex");
} }
function grantGeneration(value) {
const generation = Number(value ?? 1);
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
throw provisionerError("foundry_reader_grant_generation_invalid", 400);
}
return generation;
}
function readerTokenPath(tokenRoot, digest, generation) {
// Generation one keeps the deployed path for a zero-copy migration. Every
// successor gets a separate immutable capability file so a new snapshot can
// be accepted before the predecessor is revoked.
return join(resolve(tokenRoot), generation === 1 ? digest : `${digest}.g${generation}`);
}
function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) { function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) {
return JSON.stringify({ return JSON.stringify({
schemaVersion: SIGNATURE_SCHEMA, schemaVersion: SIGNATURE_SCHEMA,
@ -137,10 +152,11 @@ export function createFoundryReaderGrantProvisioner({
return { product, sourceScope: "resolved-server-side" }; return { product, sourceScope: "resolved-server-side" };
} }
async function ensure(target) { async function ensure(target, options = {}) {
const identity = targetIdentity(target); const identity = targetIdentity(target);
const digest = targetDigest(identity); const digest = targetDigest(identity);
const token = await ensureReaderToken(join(resolve(tokenRoot), digest), { const generation = grantGeneration(options.generation);
const token = await ensureReaderToken(readerTokenPath(tokenRoot, digest, generation), {
production, production,
randomBytesImpl, randomBytesImpl,
}); });
@ -151,26 +167,44 @@ export function createFoundryReaderGrantProvisioner({
{ {
allowedDataProductIds: [identity.dataProductId], allowedDataProductIds: [identity.dataProductId],
expiresAt: null, expiresAt: null,
generation: 1, generation,
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"), capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
}, },
); );
const binding = payload?.readerBinding; const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== 1 || binding?.active !== true if (binding?.bindingKey !== bindingKey || binding?.generation !== generation || binding?.active !== true
|| binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side" || binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side"
|| !Array.isArray(binding?.allowedDataProductIds) || !Array.isArray(binding?.allowedDataProductIds)
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) { || !binding.allowedDataProductIds.includes(identity.dataProductId)) {
throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502); throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502);
} }
return { ensured: true, idempotent: payload?.idempotent === true, generation: 1, sourceScope: "resolved-server-side" }; return { ensured: true, idempotent: payload?.idempotent === true, generation, sourceScope: "resolved-server-side" };
} }
async function readToken(target) { async function revoke(target, options = {}) {
const digest = targetDigest(target);
const generation = grantGeneration(options.generation);
const bindingKey = `fndrc-${digest}`;
const payload = await signedRequest(
"POST",
`/internal/data-plane/v1/consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}/generations/${generation}/revoke`,
{},
);
const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== generation || binding?.active !== false
|| binding?.sourceScope !== "resolved-server-side") {
throw provisionerError("foundry_reader_grant_revoke_response_invalid", 502);
}
return { revoked: true, idempotent: payload?.idempotent === true, generation, sourceScope: "resolved-server-side" };
}
async function readToken(target, options = {}) {
if (!tokenRoot) return null; if (!tokenRoot) return null;
return readReaderToken(join(resolve(tokenRoot), targetDigest(target)), { production, missing: null }); const generation = grantGeneration(options.generation);
return readReaderToken(readerTokenPath(tokenRoot, targetDigest(target), generation), { production, missing: null });
} }
return { configured, plan, ensure, readToken }; return { configured, plan, ensure, revoke, readToken };
} }
async function readPrivateKeySecurely(path, { production }) { async function readPrivateKeySecurely(path, { production }) {

View File

@ -125,3 +125,94 @@ test("managed Foundry grant provisioning signs digest-only requests and persists
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("managed Foundry grant provisioning keeps successor capabilities separate and revokes an exact predecessor generation", async () => {
const root = await mkdtemp(join(tmpdir(), "foundry-reader-grant-generation-"));
const privateKeyFile = join(root, "private-key.pem");
const grantsDir = join(root, "grants");
const { privateKey } = generateKeyPairSync("ed25519");
await writeFile(privateKeyFile, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o400 });
await chmod(privateKeyFile, 0o400);
const requests = [];
const fetchImpl = async (input, options) => {
const url = new URL(input);
const parsed = JSON.parse(String(options.body));
requests.push({ method: options.method, path: url.pathname, body: parsed });
const bindingKey = url.pathname.match(/by-key\/([^/]+)/)?.[1];
if (url.pathname.endsWith("/plan")) {
const id = parsed.allowedDataProductIds[0];
return Response.json({
ok: true,
sourceScope: "resolved-server-side",
dataProducts: [{
id,
version: id.endsWith(".v2") ? "2.0.0" : "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
if (url.pathname.endsWith("/revoke")) {
const generation = Number(url.pathname.split("/").at(-2));
return Response.json({
ok: true,
idempotent: false,
readerBinding: {
bindingKey: decodeURIComponent(bindingKey),
generation,
active: false,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: ["fleet.positions.current.v1"],
},
});
}
return Response.json({
ok: true,
idempotent: false,
readerBinding: {
bindingKey: decodeURIComponent(bindingKey),
generation: parsed.generation,
active: true,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: parsed.allowedDataProductIds,
},
});
};
const provisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: "http://edp.test",
privateKeyFile,
grantsDir,
serviceId,
keyId,
audience,
fetchImpl,
randomBytesImpl: (size) => Buffer.alloc(size, requests.length + 1),
randomUUIDImpl: () => "33333333-3333-4333-8333-333333333333",
production: false,
});
const successorTarget = structuredClone(target);
successorTarget.binding.dataProductId = "fleet.positions.current.v2";
try {
const first = await provisioner.ensure(target, { generation: 1 });
const second = await provisioner.ensure(successorTarget, { generation: 2 });
assert.equal(first.generation, 1);
assert.equal(second.generation, 2);
const firstToken = await provisioner.readToken(target, { generation: 1 });
const secondToken = await provisioner.readToken(successorTarget, { generation: 2 });
assert.notEqual(firstToken, secondToken);
const files = (await readdir(grantsDir)).sort();
const baseFile = files.find((entry) => !entry.includes(".g"));
assert.ok(baseFile);
assert.deepEqual(files, [baseFile, `${baseFile}.g2`]);
const revoked = await provisioner.revoke(target, { generation: 1 });
assert.equal(revoked.generation, 1);
assert.ok(requests.some((request) => request.method === "POST" && request.path.endsWith("/generations/1/revoke")));
assert.equal(requests.some((request) => JSON.stringify(request.body).includes(firstToken)), false);
assert.equal(requests.some((request) => JSON.stringify(request.body).includes(secondToken)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View File

@ -0,0 +1,275 @@
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
const SEMVER = /^\d+\.\d+\.\d+$/;
const PROFILE_KEYS = new Set([
"id", "version", "title", "semanticTypes", "label", "target", "pin", "facets",
"styles", "classes", "defaultClassId", "sort",
]);
const LABEL_KEYS = new Set([
"mode", "fields", "fontWeight", "sizePx", "color", "outlineColor", "outlineWidthPx",
"backgroundColor", "backgroundOpacity", "paddingX", "paddingY", "maxLength",
"offsetX", "offsetY", "hideCameraHeightMeters",
]);
const TARGET_KEYS = new Set([
"variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor",
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
]);
const FACET_KEYS = new Set(["id", "field", "label", "filterable", "counter", "values"]);
const FACET_VALUE_KEYS = new Set(["value", "label", "order"]);
const STYLE_KEYS = new Set(["id", "color", "opacity"]);
const CLASS_KEYS = new Set(["id", "label", "priority", "match", "styleId", "renderable"]);
const MATCH_KEYS = new Set(["field", "equals"]);
const SORT_KEYS = new Set(["field", "order"]);
export function normalizeMapPresentationProfile(value) {
object(value, "invalid_map_presentation_profile");
onlyKeys(value, PROFILE_KEYS, "invalid_map_presentation_profile_fields");
const id = identifier(value.id, "invalid_map_presentation_profile_id");
const version = text(value.version, 32, "invalid_map_presentation_profile_version");
if (!SEMVER.test(version)) fail("invalid_map_presentation_profile_version");
const title = text(value.title, 120, "invalid_map_presentation_profile_title");
const semanticTypes = identifiers(value.semanticTypes, 1, 8, "invalid_map_presentation_profile_semantic_types");
const label = normalizeLabel(value.label);
if (value.target !== undefined && value.pin !== undefined) fail("duplicate_map_presentation_profile_target");
const target = normalizeTarget(value.target ?? value.pin);
const facets = normalizeFacets(value.facets);
const facetByField = new Map(facets.map((facet) => [facet.field, facet]));
const styles = normalizeStyles(value.styles);
const styleIds = new Set(styles.map((style) => style.id));
const classes = normalizeClasses(value.classes, facetByField, styleIds);
const classIds = new Set(classes.map((item) => item.id));
const defaultClassId = identifier(value.defaultClassId, "invalid_map_presentation_profile_default_class");
if (!classIds.has(defaultClassId)) fail("map_presentation_profile_default_class_not_found");
const sort = normalizeSort(value.sort, facetByField);
return {
id,
version,
title,
semanticTypes,
label,
target,
facets,
styles,
classes: [...classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)),
defaultClassId,
sort,
};
}
export function normalizeMapPresentationProfiles(value) {
if (!Array.isArray(value) || value.length > 32) fail("invalid_map_presentation_profiles");
const profiles = value.map(normalizeMapPresentationProfile);
unique(profiles.map((profile) => profile.id), "duplicate_map_presentation_profile_id");
return profiles;
}
function normalizeLabel(value) {
object(value, "invalid_map_presentation_profile_label");
onlyKeys(value, LABEL_KEYS, "invalid_map_presentation_profile_label_fields");
const mode = value.mode === undefined ? "attributes" : value.mode;
if (!["subject_id", "attributes", "none"].includes(mode)) fail("invalid_map_presentation_profile_label_mode");
return {
mode,
fields: fields(value.fields, 1, 16, "invalid_map_presentation_profile_label_fields"),
fontWeight: integer(value.fontWeight, 400, 700, "invalid_map_presentation_profile_label_weight"),
sizePx: number(value.sizePx, 8, 32, "invalid_map_presentation_profile_label_size"),
color: hex(value.color, "invalid_map_presentation_profile_label_color"),
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_label_outline"),
outlineWidthPx: number(value.outlineWidthPx, 0, 6, "invalid_map_presentation_profile_label_outline_width"),
backgroundColor: hex(value.backgroundColor ?? "#0c0d12", "invalid_map_presentation_profile_label_background"),
backgroundOpacity: number(value.backgroundOpacity ?? 0.86, 0, 1, "invalid_map_presentation_profile_label_background_opacity"),
paddingX: number(value.paddingX ?? 8, 0, 40, "invalid_map_presentation_profile_label_padding"),
paddingY: number(value.paddingY ?? 5, 0, 40, "invalid_map_presentation_profile_label_padding"),
maxLength: integer(value.maxLength, 8, 240, "invalid_map_presentation_profile_label_max_length"),
offsetX: number(value.offsetX, -100, 100, "invalid_map_presentation_profile_label_offset"),
offsetY: number(value.offsetY, -100, 100, "invalid_map_presentation_profile_label_offset"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_label_lod"),
};
}
function normalizeTarget(value) {
object(value, "invalid_map_presentation_profile_target");
onlyKeys(value, TARGET_KEYS, "invalid_map_presentation_profile_target_fields");
if (value.variant !== "elevated-spike") fail("invalid_map_presentation_profile_target_variant");
return {
variant: "elevated-spike",
stemHeightMeters: number(value.stemHeightMeters, 1, 100_000, "invalid_map_presentation_profile_target_stem_height"),
headSizePx: number(value.headSizePx, 1, 64, "invalid_map_presentation_profile_target_head_size"),
stemWidthPx: number(value.stemWidthPx, 0.25, 16, "invalid_map_presentation_profile_target_stem_width"),
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
};
}
function normalizeFacets(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 16) fail("invalid_map_presentation_profile_facets");
const facets = value.map((facet) => {
object(facet, "invalid_map_presentation_profile_facet");
onlyKeys(facet, FACET_KEYS, "invalid_map_presentation_profile_facet_fields");
const values = Array.isArray(facet.values) ? facet.values.map((item) => {
object(item, "invalid_map_presentation_profile_facet_value");
onlyKeys(item, FACET_VALUE_KEYS, "invalid_map_presentation_profile_facet_value_fields");
return {
value: identifier(item.value, "invalid_map_presentation_profile_facet_value"),
label: text(item.label, 80, "invalid_map_presentation_profile_facet_value_label"),
order: integer(item.order, 0, 1000, "invalid_map_presentation_profile_facet_value_order"),
};
}) : fail("invalid_map_presentation_profile_facet_values");
if (values.length < 1 || values.length > 32) fail("invalid_map_presentation_profile_facet_values");
unique(values.map((item) => item.value), "duplicate_map_presentation_profile_facet_value");
return {
id: identifier(facet.id, "invalid_map_presentation_profile_facet_id"),
field: field(facet.field, "invalid_map_presentation_profile_facet_field"),
label: text(facet.label, 80, "invalid_map_presentation_profile_facet_label"),
filterable: boolean(facet.filterable, "invalid_map_presentation_profile_facet_filterable"),
counter: boolean(facet.counter, "invalid_map_presentation_profile_facet_counter"),
values: [...values].sort((left, right) => left.order - right.order || left.value.localeCompare(right.value)),
};
});
unique(facets.map((facet) => facet.id), "duplicate_map_presentation_profile_facet_id");
unique(facets.map((facet) => facet.field), "duplicate_map_presentation_profile_facet_field");
return facets;
}
function normalizeStyles(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 32) fail("invalid_map_presentation_profile_styles");
const styles = value.map((style) => {
object(style, "invalid_map_presentation_profile_style");
onlyKeys(style, STYLE_KEYS, "invalid_map_presentation_profile_style_fields");
return {
id: identifier(style.id, "invalid_map_presentation_profile_style_id"),
color: hex(style.color, "invalid_map_presentation_profile_style_color"),
opacity: number(style.opacity, 0, 1, "invalid_map_presentation_profile_style_opacity"),
};
});
unique(styles.map((style) => style.id), "duplicate_map_presentation_profile_style_id");
return styles;
}
function normalizeClasses(value, facetByField, styleIds) {
if (!Array.isArray(value) || value.length < 1 || value.length > 64) fail("invalid_map_presentation_profile_classes");
const classes = value.map((item) => {
object(item, "invalid_map_presentation_profile_class");
onlyKeys(item, CLASS_KEYS, "invalid_map_presentation_profile_class_fields");
if (!Array.isArray(item.match) || item.match.length > 8) fail("invalid_map_presentation_profile_class_match");
const match = item.match.map((condition) => {
object(condition, "invalid_map_presentation_profile_class_condition");
onlyKeys(condition, MATCH_KEYS, "invalid_map_presentation_profile_class_condition_fields");
const matchField = field(condition.field, "invalid_map_presentation_profile_class_condition_field");
const equals = identifier(condition.equals, "invalid_map_presentation_profile_class_condition_value");
const facet = facetByField.get(matchField);
if (!facet || !facet.values.some((item) => item.value === equals)) {
fail("map_presentation_profile_class_condition_not_declared");
}
return { field: matchField, equals };
});
const styleId = identifier(item.styleId, "invalid_map_presentation_profile_class_style");
if (!styleIds.has(styleId)) fail("map_presentation_profile_class_style_not_found");
return {
id: identifier(item.id, "invalid_map_presentation_profile_class_id"),
label: text(item.label, 80, "invalid_map_presentation_profile_class_label"),
priority: integer(item.priority, -10000, 10000, "invalid_map_presentation_profile_class_priority"),
match,
styleId,
renderable: boolean(item.renderable, "invalid_map_presentation_profile_class_renderable"),
};
});
unique(classes.map((item) => item.id), "duplicate_map_presentation_profile_class_id");
return classes;
}
function normalizeSort(value, facetByField) {
if (!Array.isArray(value) || value.length > 16) fail("invalid_map_presentation_profile_sort");
const sort = value.map((item) => {
object(item, "invalid_map_presentation_profile_sort_item");
onlyKeys(item, SORT_KEYS, "invalid_map_presentation_profile_sort_fields");
const sortField = field(item.field, "invalid_map_presentation_profile_sort_field");
const facet = facetByField.get(sortField);
if (!facet || !Array.isArray(item.order) || item.order.length !== facet.values.length) {
fail("map_presentation_profile_sort_not_declared");
}
const order = item.order.map((entry) => identifier(entry, "invalid_map_presentation_profile_sort_order"));
unique(order, "duplicate_map_presentation_profile_sort_order");
if (order.some((entry) => !facet.values.some((item) => item.value === entry))) {
fail("map_presentation_profile_sort_not_declared");
}
return { field: sortField, order };
});
unique(sort.map((item) => item.field), "duplicate_map_presentation_profile_sort_field");
return sort;
}
function onlyKeys(value, allowed, code) {
if (Object.keys(value).some((key) => !allowed.has(key))) fail(code);
}
function object(value, code) {
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
}
function identifier(value, code) {
const normalized = text(value, 160, code);
if (!IDENTIFIER.test(normalized)) fail(code);
return normalized;
}
function field(value, code) {
const normalized = text(value, 128, code);
if (!FIELD.test(normalized)) fail(code);
return normalized;
}
function identifiers(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => identifier(item, code));
unique(normalized, code);
return normalized;
}
function fields(value, min, max, code) {
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
const normalized = value.map((item) => field(item, code));
unique(normalized, code);
return normalized;
}
function text(value, max, code) {
if (typeof value !== "string") fail(code);
const normalized = value.trim();
if (!normalized || normalized.length > max) fail(code);
return normalized;
}
function hex(value, code) {
const normalized = text(value, 7, code).toLowerCase();
if (!/^#[0-9a-f]{6}$/.test(normalized)) fail(code);
return normalized;
}
function number(value, min, max, code) {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) fail(code);
return value;
}
function integer(value, min, max, code) {
const normalized = number(value, min, max, code);
if (!Number.isInteger(normalized)) fail(code);
return normalized;
}
function boolean(value, code) {
if (typeof value !== "boolean") fail(code);
return value;
}
function unique(values, code) {
if (new Set(values).size !== values.length) fail(code);
}
function fail(code) {
throw Object.assign(new Error(code), { statusCode: 400 });
}

View File

@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
const registry = JSON.parse(await readFile(new URL("../registry/map-presentation-profiles.json", import.meta.url), "utf8"));
const mapFixtureSource = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
const catalogStyles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
test("canonical moving-object profile is provider-neutral and internally consistent", () => {
assert.equal(registry.schemaVersion, "nodedc.map-presentation-profiles/v1");
const profiles = normalizeMapPresentationProfiles(registry.profiles);
assert.equal(profiles.length, 1);
const profile = profiles[0];
assert.equal(profile.id, "map.moving-object.operational.default");
assert.deepEqual(profile.semanticTypes, ["map.moving_object"]);
assert.equal(profile.target.variant, "elevated-spike");
assert.equal(profile.target.stemHeightMeters, 1500);
assert.equal(profile.target.headSizePx, 9);
assert.equal(profile.target.stemWidthPx, 5);
assert.equal(profile.label.mode, "subject_id");
assert.equal(profile.label.sizePx, 18);
assert.equal(profile.label.backgroundOpacity, 0.86);
assert.equal(profile.styles.find((style) => style.id === "online")?.color, "#86fdb8");
assert.equal(profile.styles.find((style) => style.id === "moving")?.color, "#6fb5fb");
assert.equal(JSON.stringify(profile).toLowerCase().includes("gelios"), false);
assert.deepEqual(profile.facets.map((facet) => facet.field), [
"availability_state",
"motion_state",
]);
assert.deepEqual(profile.facets.flatMap((facet) => facet.values.map((value) => value.label)), [
"Онлайн",
"Офлайн",
"В движении",
"Стоят",
]);
assert.equal(JSON.stringify(profile).includes("Неизвестно"), false);
});
test("fleet filter window is one compact vertical list without group rows or All", () => {
assert.match(mapFixtureSource, /catalog-map-fixture__target-filter-list/);
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-profile/);
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-facet/);
assert.doesNotMatch(mapFixtureSource, />Все <span>/);
assert.match(catalogStyles, /\.catalog-map-fixture__target-filter-list\s*\{[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(catalogStyles, /\.catalog-map-fixture__target-filter-list button\s*\{[\s\S]*width:\s*100%/);
});
test("profile rejects undeclared state matches and renderer transport", () => {
const source = registry.profiles[0];
const undeclared = structuredClone(source);
undeclared.classes[0].match[0].equals = "provider_magic";
assert.throws(() => normalizeMapPresentationProfile(undeclared), /map_presentation_profile_class_condition_not_declared/);
const transport = structuredClone(source);
transport.providerUrl = "https://example.test";
assert.throws(() => normalizeMapPresentationProfile(transport), /invalid_map_presentation_profile_fields/);
});
test("legacy pin profiles normalize into the provider-neutral target contract", () => {
const legacy = structuredClone(registry.profiles[0]);
legacy.pin = legacy.target;
delete legacy.target;
const normalized = normalizeMapPresentationProfile(legacy);
assert.equal(normalized.target.stemHeightMeters, 1500);
assert.equal(Object.hasOwn(normalized, "pin"), false);
});