feat(foundry): close the operational map data loop
This commit is contained in:
parent
aac44d057f
commit
a02c3ff3dd
|
|
@ -7,7 +7,7 @@
|
|||
<link rel="icon" href="/favicon/favicon.ico" sizes="any" />
|
||||
<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="manifest" href="/favicon/manifest.webmanifest.json" />
|
||||
<link rel="manifest" href="/favicon/manifest.webmanifest.json" crossorigin="use-credentials" />
|
||||
<title>NODE.DC UI Catalog</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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 { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||
import {
|
||||
|
|
@ -37,6 +37,8 @@ import {
|
|||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
ToastCard,
|
||||
ToastStack,
|
||||
Toolbar,
|
||||
UserProfileMenu,
|
||||
useApplicationWorkspace,
|
||||
|
|
@ -45,6 +47,8 @@ import {
|
|||
WorkspaceWindow,
|
||||
type IconName,
|
||||
type ShareAccessMember,
|
||||
type ToastItem,
|
||||
type ToastTone,
|
||||
type ToolbarPlacement,
|
||||
type WorkspaceWindowRect,
|
||||
} from "@nodedc/ui-react";
|
||||
|
|
@ -60,15 +64,38 @@ import {
|
|||
type DesignProfileSummary,
|
||||
type DesignProfileStatus,
|
||||
} 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";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
|
||||
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
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 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 }> = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
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 {
|
||||
user: {
|
||||
id: string;
|
||||
|
|
@ -198,14 +175,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
|
|||
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
|
||||
icon: "apps",
|
||||
},
|
||||
status: {
|
||||
eyebrow: "04 / STATUS NOTIFICATIONS",
|
||||
title: "Статусы",
|
||||
description: "Канонические стеклянные уведомления о сохранении, обновлении и ошибках — стек снизу справа.",
|
||||
icon: "activity",
|
||||
},
|
||||
modals: {
|
||||
eyebrow: "04 / MODALS",
|
||||
eyebrow: "05 / MODALS",
|
||||
title: "Модалки",
|
||||
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
|
||||
icon: "clipboard",
|
||||
},
|
||||
icons: {
|
||||
eyebrow: "05 / ICONS",
|
||||
eyebrow: "06 / ICONS",
|
||||
title: "Иконки",
|
||||
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
|
||||
icon: "grid",
|
||||
|
|
@ -236,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||
{
|
||||
title: "Сущности",
|
||||
note: "Общий словарь",
|
||||
icons: ["profile", "users", "building", "globe", "database", "network", "inbox", "mail"],
|
||||
icons: ["profile", "users", "building", "globe", "target", "database", "network", "inbox", "mail"],
|
||||
},
|
||||
{
|
||||
title: "Контент",
|
||||
|
|
@ -276,6 +259,7 @@ const iconLabels: Record<IconName, string> = {
|
|||
minimize: "Свернуть",
|
||||
network: "Связи",
|
||||
panel: "Панель",
|
||||
target: "Таргеты",
|
||||
plus: "Добавить",
|
||||
profile: "Профиль",
|
||||
refresh: "Обновить",
|
||||
|
|
@ -328,9 +312,13 @@ export function CatalogApp() {
|
|||
const [studioContext, setStudioContext] = useState<StudioContext>("visual");
|
||||
const [applicationSummaries, setApplicationSummaries] = useState<ApplicationSummary[]>([]);
|
||||
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 [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
|
||||
const [applicationError, setApplicationError] = useState("");
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const toastSequenceRef = useRef(0);
|
||||
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
|
||||
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
|
||||
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
|
||||
|
|
@ -351,6 +339,7 @@ export function CatalogApp() {
|
|||
const [applicationMode, setApplicationMode] = useState<"edit" | "preview">("edit");
|
||||
const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false);
|
||||
const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save");
|
||||
const [designProfileSaveScope, setDesignProfileSaveScope] = useState<DesignProfileSaveScope>({ kind: "global" });
|
||||
const [designProfileName, setDesignProfileName] = useState("");
|
||||
const [designProfilePublishState, setDesignProfilePublishState] = useState<"idle" | "publishing" | "published" | "error">("idle");
|
||||
const [activeDesignProfileId, setActiveDesignProfileId] = useState("default");
|
||||
|
|
@ -433,6 +422,64 @@ export function CatalogApp() {
|
|||
{ 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)
|
||||
? mediaUrl
|
||||
: null;
|
||||
|
|
@ -640,6 +687,26 @@ export function CatalogApp() {
|
|||
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(() => {
|
||||
let active = true;
|
||||
fetch("/api/applications", { cache: "no-store" })
|
||||
|
|
@ -734,21 +801,8 @@ export function CatalogApp() {
|
|||
workspace.openView(next as CatalogSection);
|
||||
};
|
||||
|
||||
const openDesignProfile = async (id: string) => {
|
||||
const openDesignProfile = (id: string) => {
|
||||
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) => {
|
||||
|
|
@ -760,16 +814,47 @@ export function CatalogApp() {
|
|||
const response = await fetch(`/api/applications/${id}`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("application_load_failed");
|
||||
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);
|
||||
setTheme(manifest.designProfile.theme);
|
||||
setApplicationDesignProfileLayout(profile.layout);
|
||||
applyDesignProfileLayout(profile.layout);
|
||||
setApplicationSaveState("saved");
|
||||
} catch {
|
||||
setApplicationDraft(null);
|
||||
setApplicationDesignProfileLayout(null);
|
||||
setApplicationSaveState("error");
|
||||
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) => {
|
||||
setStudioContext("pages");
|
||||
workspace.openView(pageTemplateViewId(id));
|
||||
|
|
@ -794,7 +879,12 @@ export function CatalogApp() {
|
|||
});
|
||||
if (!response.ok) throw new Error("application_create_failed");
|
||||
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);
|
||||
setApplicationDesignProfileLayout(profile.layout);
|
||||
applyDesignProfileLayout(profile.layout);
|
||||
setApplicationSummaries((current) => [
|
||||
{
|
||||
id: manifest.id,
|
||||
|
|
@ -838,12 +928,27 @@ export function CatalogApp() {
|
|||
|
||||
const saveApplicationDraft = async () => {
|
||||
if (!applicationDraft) return;
|
||||
const toastId = pushToast("loading", "Сохраняем Application", applicationDraft.metadata.name, 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 ? {
|
||||
...applicationDraft,
|
||||
pages: applicationDraft.pages.map((page) => page.id === activeApplicationPageId ? {
|
||||
...page,
|
||||
layout: { ...page.layout, map: activeMapLayout },
|
||||
...(activeMapFragment ? {
|
||||
designOverrides: activeMapOverrides
|
||||
? { ...page.designOverrides, map: activeMapOverrides }
|
||||
: undefined,
|
||||
} : {}),
|
||||
} : page),
|
||||
} : applicationDraft;
|
||||
setApplicationSaveState("saving");
|
||||
|
|
@ -871,31 +976,20 @@ export function CatalogApp() {
|
|||
...current.filter((item) => item.id !== saved.id),
|
||||
]);
|
||||
setApplicationSaveState("saved");
|
||||
updateToast(toastId, { tone: "success", title: "Application сохранён", description: saved.metadata.name, durationMs: 4200 });
|
||||
} catch {
|
||||
setApplicationSaveState("error");
|
||||
setApplicationError("Draft не сохранён. Проверьте название и slug.");
|
||||
updateToast(toastId, { tone: "error", title: "Application не сохранён", description: "Проверьте manifest и повторите попытку.", durationMs: 6500 });
|
||||
}
|
||||
};
|
||||
|
||||
const saveMapPageTemplate = async () => {
|
||||
const layout = mapTemplatePreviewRef.current?.getLayout();
|
||||
if (!layout) {
|
||||
setMapTemplateSaveState("error");
|
||||
return;
|
||||
}
|
||||
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 openMapPageDesignSave = (templateVersion: string) => {
|
||||
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
|
||||
setDesignProfileName(currentProfile?.name ?? "");
|
||||
setDesignProfileSaveScope({ kind: "page", templateId: "map", templateVersion });
|
||||
setDesignProfileSaveMode("save");
|
||||
setDesignProfileSaveOpen(true);
|
||||
};
|
||||
|
||||
const changeStudioContext = (next: StudioContext) => {
|
||||
|
|
@ -1029,20 +1123,48 @@ export function CatalogApp() {
|
|||
};
|
||||
|
||||
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);
|
||||
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}`, {
|
||||
method: creating ? "POST" : "PUT",
|
||||
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");
|
||||
const saved = await response.json() as { id: string; name: string; version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { updatedAt: string } };
|
||||
if (!response.ok) throw new Error("design_profile_save_failed");
|
||||
const saved = await response.json() as DesignProfileDocument;
|
||||
const previous = designProfiles.find((profile) => profile.id === saved.id);
|
||||
const summary: DesignProfileSummary = {
|
||||
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)));
|
||||
setActiveDesignProfileId(summary.id);
|
||||
setActiveDesignProfileLayout(saved.layout);
|
||||
setDesignProfileSaveOpen(false);
|
||||
setDesignProfileName("");
|
||||
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 current = designProfiles.find((profile) => profile.id === activeDesignProfileId);
|
||||
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");
|
||||
const response = await fetch(`/api/design-profiles/${current.id}/publish`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
setDesignProfilePublishState("error");
|
||||
updateToast(toastId, { tone: "error", title: "Публикация не выполнена", description: "Draft остался доступен без изменений.", durationMs: 6500 });
|
||||
return;
|
||||
}
|
||||
const release = await response.json() as { version: string; status: DesignProfileStatus; layout: StoredLayout; timestamps: { publishedAt?: string } };
|
||||
|
|
@ -1078,6 +1215,7 @@ export function CatalogApp() {
|
|||
}));
|
||||
setDesignProfilePublishState("published");
|
||||
setDesignProfileSaveOpen(false);
|
||||
updateToast(toastId, { tone: "success", title: "Design Profile опубликован", description: `${current.name} · v${release.version}`, durationMs: 4200 });
|
||||
};
|
||||
|
||||
const addPageTemplateToApplication = (template: PageTemplateDefinition) => {
|
||||
|
|
@ -1448,6 +1586,33 @@ export function CatalogApp() {
|
|||
</SettingsCard>
|
||||
</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":
|
||||
return (
|
||||
<div className="catalog-modal-groups">
|
||||
|
|
@ -1468,7 +1633,7 @@ export function CatalogApp() {
|
|||
</Preview>
|
||||
<Preview title="Сохранить / Сохранить как" note="DESIGN PROFILE" className="catalog-modal-preview">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1606,16 +1771,7 @@ export function CatalogApp() {
|
|||
label="Design Profile"
|
||||
value={designProfileOptions.some((option) => option.value === selectedProfileValue) ? selectedProfileValue : `${selectedProfile?.id ?? "default"}@${selectedProfile?.version ?? "0.6.0"}@draft`}
|
||||
options={designProfileOptions}
|
||||
onChange={(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 } }));
|
||||
}}
|
||||
onChange={(profileReference) => { void selectApplicationDesignProfile(profileReference); }}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
|
|
@ -1682,7 +1838,6 @@ export function CatalogApp() {
|
|||
</DragDropRoot>
|
||||
<div className="catalog-page-composer__footer">
|
||||
<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>
|
||||
</SettingsCard>
|
||||
{applicationError ? <p className="nodedc-media-field__error" role="alert">{applicationError}</p> : null}
|
||||
|
|
@ -1690,14 +1845,20 @@ export function CatalogApp() {
|
|||
);
|
||||
};
|
||||
|
||||
const renderPageTemplate = (template: PageTemplateDefinition) => (
|
||||
template.id === "map" ? (
|
||||
const renderPageTemplate = (template: PageTemplateDefinition) => {
|
||||
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">
|
||||
<MapFixturePreview
|
||||
key={`map-template-${mapTemplateLayout?.savedAt ?? "default"}`}
|
||||
key={`map-template-${activeDesignProfileId}-${mapDesignProfileKey(template.version)}-${designProfiles.find((profile) => profile.id === activeDesignProfileId)?.version ?? "draft"}`}
|
||||
ref={mapTemplatePreviewRef}
|
||||
expanded
|
||||
initialLayout={mapTemplateLayout}
|
||||
initialLayout={mapDesignLayout}
|
||||
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
|
||||
/>
|
||||
<div className="catalog-page-template__actions">
|
||||
|
|
@ -1736,8 +1897,8 @@ export function CatalogApp() {
|
|||
</SettingsCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderApplicationPage = () => {
|
||||
if (!applicationDraft || !activeApplicationPageId) return null;
|
||||
|
|
@ -1745,6 +1906,12 @@ export function CatalogApp() {
|
|||
if (!page) return null;
|
||||
const template = getPageTemplate(page.template.id, page.template.version);
|
||||
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) => ({
|
||||
...current,
|
||||
pages: current.pages.map((item) => item.id === page.id ? { ...item, features: { ...item.features, [featureId]: value } } : item),
|
||||
|
|
@ -1752,11 +1919,11 @@ export function CatalogApp() {
|
|||
return (
|
||||
<div className="catalog-application-page">
|
||||
<MapFixturePreview
|
||||
key={page.id}
|
||||
key={`${page.id}-${applicationDraft.designProfile.id}-${applicationDraft.designProfile.version}-${applicationDraft.designProfile.status}`}
|
||||
ref={applicationMapPreviewRef}
|
||||
applicationId={applicationDraft.id}
|
||||
pageId={page.id}
|
||||
initialLayout={page.layout?.map ?? null}
|
||||
initialLayout={resolvedMapLayout}
|
||||
features={page.features}
|
||||
expanded
|
||||
/>
|
||||
|
|
@ -1866,7 +2033,7 @@ export function CatalogApp() {
|
|||
emptyLabel="Модули не созданы"
|
||||
onChange={(id) => { void openApplicationDraft(id); }}
|
||||
/>
|
||||
) : studioContext === "visual" ? (
|
||||
) : studioContext === "visual" || studioContext === "pages" ? (
|
||||
<Select
|
||||
className="catalog-module-switcher"
|
||||
label="Design Profile"
|
||||
|
|
@ -1918,6 +2085,7 @@ export function CatalogApp() {
|
|||
onClick: () => {
|
||||
const currentProfile = designProfiles.find((profile) => profile.id === activeDesignProfileId);
|
||||
setDesignProfileName(currentProfile?.name ?? "");
|
||||
setDesignProfileSaveScope({ kind: "global" });
|
||||
setDesignProfileSaveMode("save");
|
||||
setDesignProfileSaveOpen(true);
|
||||
},
|
||||
|
|
@ -1936,9 +2104,9 @@ export function CatalogApp() {
|
|||
expanded={panelExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
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",
|
||||
onClick: () => { void saveMapPageTemplate(); },
|
||||
onClick: () => openMapPageDesignSave(activePageTemplate.version),
|
||||
disabled: mapTemplateSaveState === "saving" || mapTemplateSaveState === "loading",
|
||||
}] : undefined}
|
||||
onClose={workspace.closeView}
|
||||
|
|
@ -1954,7 +2122,7 @@ export function CatalogApp() {
|
|||
expanded={panelExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
utilityActions={[{
|
||||
label: applicationSaveState === "saving" ? "Draft сохраняется" : applicationSaveState === "saved" ? "Draft сохранён" : applicationSaveState === "error" ? "Повторить сохранение draft" : "Сохранить Application Draft",
|
||||
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
|
||||
icon: "save",
|
||||
onClick: () => { void saveApplicationDraft(); },
|
||||
disabled: applicationSaveState === "saving" || applicationSaveState === "loading",
|
||||
|
|
@ -2042,7 +2210,7 @@ export function CatalogApp() {
|
|||
<Window
|
||||
open={designProfileSaveOpen}
|
||||
title="Сохранить Design Profile"
|
||||
subtitle="VISUAL LIBRARY / PRESET"
|
||||
subtitle={designProfileSaveScope.kind === "page" ? `PAGE LIBRARY / ${designProfileSaveScope.templateId.toUpperCase()}@${designProfileSaveScope.templateVersion}` : "VISUAL LIBRARY / GLOBAL PRESET"}
|
||||
size="sm"
|
||||
onClose={() => setDesignProfileSaveOpen(false)}
|
||||
footer={
|
||||
|
|
@ -2066,6 +2234,13 @@ export function CatalogApp() {
|
|||
}
|
||||
>
|
||||
<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} />
|
||||
{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>
|
||||
|
|
@ -2275,6 +2450,7 @@ export function CatalogApp() {
|
|||
onClose={() => setConfirmOpen(false)}
|
||||
onConfirm={() => setConfirmOpen(false)}
|
||||
/>
|
||||
<ToastStack items={toasts} onDismiss={dismissToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import {
|
|||
Cesium3DTileStyle,
|
||||
CesiumTerrainProvider,
|
||||
CallbackProperty,
|
||||
CallbackPositionProperty,
|
||||
Cartographic,
|
||||
ConstantPositionProperty,
|
||||
CustomDataSource,
|
||||
DefaultProxy,
|
||||
DistanceDisplayCondition,
|
||||
EllipsoidTerrainProvider,
|
||||
Entity,
|
||||
HeightReference,
|
||||
|
|
@ -25,8 +25,8 @@ import {
|
|||
LabelGraphics,
|
||||
Matrix4,
|
||||
Math as CesiumMath,
|
||||
PolygonHierarchy,
|
||||
PointGraphics,
|
||||
PolylineGraphics,
|
||||
Resource,
|
||||
sampleTerrainMostDetailed,
|
||||
ScreenSpaceEventHandler,
|
||||
|
|
@ -36,36 +36,22 @@ import {
|
|||
Viewer,
|
||||
} from "cesium";
|
||||
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 { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.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 TERRAIN_SAMPLE_TIMEOUT_MS = 12_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 = {
|
||||
cesiumVersion: string;
|
||||
provider: string;
|
||||
|
|
@ -202,6 +188,8 @@ export type CesiumMapRendererHandle = {
|
|||
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
|
||||
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
|
||||
getCameraView: () => MapCameraView | null;
|
||||
fitRuntimeEntities: (entityIds?: string[]) => boolean;
|
||||
focusRuntimeEntity: (entityId: string) => boolean;
|
||||
};
|
||||
|
||||
type TerrainRouteSample = {
|
||||
|
|
@ -237,28 +225,50 @@ type SpiralSession = {
|
|||
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 violet = Color.fromCssColorString("#8f72dc");
|
||||
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) {
|
||||
if (!limit) return true;
|
||||
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 {
|
||||
const position = viewer.camera.positionCartographic;
|
||||
return {
|
||||
|
|
@ -302,148 +312,6 @@ function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMe
|
|||
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) {
|
||||
// This is a semantic default for the generic Map entity-stream adapter,
|
||||
// not a provider style. A renderer-neutral style profile can refine it
|
||||
|
|
@ -459,6 +327,8 @@ function syncRuntimeDataSources(
|
|||
viewer: Viewer,
|
||||
dataSources: Map<string, CustomDataSource>,
|
||||
bindings: MapRuntimeBinding[],
|
||||
presentationProfiles: MapPresentationProfile[],
|
||||
presentationFilters: MapPresentationFilters,
|
||||
) {
|
||||
const activeBindings = new Map(bindings
|
||||
.filter((binding) => binding.slotId === "points")
|
||||
|
|
@ -479,15 +349,77 @@ function syncRuntimeDataSources(
|
|||
}
|
||||
const wanted = new Set<string>();
|
||||
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);
|
||||
wanted.add(entityId);
|
||||
const [longitude, latitude] = fact.geometry.coordinates;
|
||||
const color = runtimePointColor(fact);
|
||||
const label = runtimeDisplayLabel(fact);
|
||||
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
|
||||
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 });
|
||||
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.polyline = undefined;
|
||||
entity.point = new PointGraphics({
|
||||
pixelSize: 10,
|
||||
color,
|
||||
|
|
@ -509,6 +441,7 @@ function syncRuntimeDataSources(
|
|||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const entity of [...dataSource.entities.values]) {
|
||||
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 safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
|
||||
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
|
||||
const center = sceneFixture.viewport.center as Position;
|
||||
const latitude = center[1];
|
||||
const longitude = center[0];
|
||||
const cameraPosition = viewer.camera.positionCartographic;
|
||||
const latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244;
|
||||
const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423;
|
||||
const metersPerLatitudeDegree = 110_574;
|
||||
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
|
||||
const stepMeters = safeStepKm * 1000;
|
||||
|
|
@ -646,6 +579,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
initialCamera?: MapCameraView;
|
||||
presentation: MapPresentation;
|
||||
runtimeBindings?: MapRuntimeBinding[];
|
||||
presentationProfiles?: MapPresentationProfile[];
|
||||
presentationFilters?: MapPresentationFilters;
|
||||
}>(function CesiumMapRenderer({
|
||||
onSelect,
|
||||
onGatewayHealth,
|
||||
|
|
@ -657,6 +592,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
initialCamera,
|
||||
presentation,
|
||||
runtimeBindings = [],
|
||||
presentationProfiles = [],
|
||||
presentationFilters = {},
|
||||
}, ref) {
|
||||
const containerRef = 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 presentationRef = useRef(presentation);
|
||||
const runtimeBindingsRef = useRef(runtimeBindings);
|
||||
const presentationProfilesRef = useRef(presentationProfiles);
|
||||
const presentationFiltersRef = useRef(presentationFilters);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
const onCameraChangeRef = useRef(onCameraChange);
|
||||
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
|
||||
|
|
@ -1020,6 +959,34 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
return true;
|
||||
}, [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, () => ({
|
||||
startSpiralAnimation,
|
||||
stopSpiralAnimation,
|
||||
|
|
@ -1027,7 +994,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
const viewer = viewerRef.current;
|
||||
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
|
||||
},
|
||||
}), [startSpiralAnimation, stopSpiralAnimation]);
|
||||
fitRuntimeEntities,
|
||||
focusRuntimeEntity,
|
||||
}), [fitRuntimeEntities, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
|
||||
|
||||
useEffect(() => {
|
||||
const stopForPageLeave = () => stopSpiralAnimation("stopped");
|
||||
|
|
@ -1066,10 +1035,18 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
|
||||
useEffect(() => {
|
||||
runtimeBindingsRef.current = runtimeBindings;
|
||||
presentationProfilesRef.current = presentationProfiles;
|
||||
presentationFiltersRef.current = presentationFilters;
|
||||
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(() => {
|
||||
let viewer: Viewer | undefined;
|
||||
|
|
@ -1146,8 +1123,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
|
||||
viewer.terrainProvider = terrain.ellipsoid;
|
||||
viewer.scene.globe.depthTestAgainstTerrain = true;
|
||||
addFixtureEntities(viewer);
|
||||
syncRuntimeDataSources(viewer, runtimeDataSourcesRef.current, runtimeBindingsRef.current);
|
||||
syncRuntimeDataSources(
|
||||
viewer,
|
||||
runtimeDataSourcesRef.current,
|
||||
runtimeBindingsRef.current,
|
||||
presentationProfilesRef.current,
|
||||
presentationFiltersRef.current,
|
||||
);
|
||||
viewerRef.current = viewer;
|
||||
terrainRef.current = terrain;
|
||||
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
|
||||
|
|
@ -1274,11 +1256,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
},
|
||||
});
|
||||
} else {
|
||||
const center = toCartesian(sceneFixture.viewport.center as Position);
|
||||
const center = Cartesian3.fromDegrees(37.618423, 55.751244, 0);
|
||||
viewer.camera.lookAt(center, new HeadingPitchRange(
|
||||
CesiumMath.toRadians(sceneFixture.viewport.heading),
|
||||
CesiumMath.toRadians(sceneFixture.viewport.pitch),
|
||||
sceneFixture.viewport.range,
|
||||
0,
|
||||
-0.9,
|
||||
40_000,
|
||||
));
|
||||
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 type { SelectOption } from "@nodedc/ui-react";
|
||||
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||
import type {
|
||||
CameraSpiralState,
|
||||
CesiumMapRendererHandle,
|
||||
|
|
@ -10,6 +10,19 @@ import type {
|
|||
MapProviderStatus,
|
||||
} from "./CesiumMapRenderer.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 {
|
||||
CAMERA_SURVEY_PRESETS,
|
||||
DEFAULT_CAMERA_SURVEY_PRESET,
|
||||
|
|
@ -18,8 +31,6 @@ import {
|
|||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} 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 })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
|
|
@ -92,11 +103,29 @@ export type MapPinBinding = {
|
|||
*/
|
||||
export type MapDataProductBinding = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
order?: number;
|
||||
dataProductId: string;
|
||||
slotId: string;
|
||||
delivery: "snapshot+patch";
|
||||
semanticTypes: 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 = {
|
||||
|
|
@ -106,7 +135,9 @@ export type MapPageLayout = {
|
|||
mapHeight: number;
|
||||
camera: MapCameraView;
|
||||
pinBindings: MapPinBinding[];
|
||||
presentationProfiles: MapPresentationProfile[];
|
||||
dataProductBindings: MapDataProductBinding[];
|
||||
subjectStates: MapSubjectState[];
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
|
|
@ -123,9 +154,9 @@ const initialMapSettings: MapPageSettings = {
|
|||
terrainExaggeration: 1,
|
||||
monochrome: false,
|
||||
monochromeColor: "#15151b",
|
||||
imageryGamma: 100,
|
||||
imageryHue: 0,
|
||||
imageryAlpha: 100,
|
||||
imageryGamma: 57,
|
||||
imageryHue: 13,
|
||||
imageryAlpha: 27,
|
||||
globeColor: "#15151b",
|
||||
backgroundColor: "#08090d",
|
||||
atmosphereEnabled: false,
|
||||
|
|
@ -140,11 +171,11 @@ const initialMapSettings: MapPageSettings = {
|
|||
shadowsEnabled: true,
|
||||
buildingsVisible: true,
|
||||
buildingsColor: "#a27aff",
|
||||
buildingsOpacity: 0.82,
|
||||
buildingsDetail: 16,
|
||||
imageryBrightness: 100,
|
||||
imageryContrast: 100,
|
||||
imagerySaturation: 100,
|
||||
buildingsOpacity: 1,
|
||||
buildingsDetail: 4,
|
||||
imageryBrightness: 118,
|
||||
imageryContrast: 102,
|
||||
imagerySaturation: 0,
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
gridHeightMeters: 500,
|
||||
|
|
@ -167,14 +198,28 @@ const initialMapSettings: MapPageSettings = {
|
|||
// 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.
|
||||
const fallbackMapCamera: MapCameraView = {
|
||||
longitude: sceneFixture.viewport.center[0],
|
||||
latitude: sceneFixture.viewport.center[1],
|
||||
height: sceneFixture.viewport.range,
|
||||
heading: (sceneFixture.viewport.heading * Math.PI) / 180,
|
||||
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180,
|
||||
longitude: 37.618423,
|
||||
latitude: 55.751244,
|
||||
height: 40_000,
|
||||
heading: 0,
|
||||
pitch: -0.9,
|
||||
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 = {
|
||||
imagery: "loading",
|
||||
terrain: "loading",
|
||||
|
|
@ -189,6 +234,40 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
|||
"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 valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
|
||||
const formatMetricDistance = (value: number) => value >= 1000
|
||||
|
|
@ -222,13 +301,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
applicationId?: string;
|
||||
pageId?: string;
|
||||
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
|
||||
const fixtureSelectable = useMemo(() => [
|
||||
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
|
||||
...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 workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [inspectorOpen, setInspectorOpen] = 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 [assistantOpen, setAssistantOpen] = useState(false);
|
||||
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
|
||||
// intact when a human changes camera or visual settings and presses Save.
|
||||
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
|
||||
// not belong to the visual inspector. Preserve them verbatim when a human
|
||||
// edits camera or presentation settings and saves the page layout.
|
||||
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({
|
||||
applicationId,
|
||||
pageId,
|
||||
bindings: dataProductBindings,
|
||||
enabled: Boolean(applicationId && pageId),
|
||||
});
|
||||
const selectable = useMemo(() => [
|
||||
...fixtureSelectable,
|
||||
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
|
||||
const attributes = fact.attributes;
|
||||
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
|
||||
const selectable = useMemo(() => (
|
||||
runtimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
const facts = [...binding.facts];
|
||||
const primaryProfile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
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 {
|
||||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||
title: typeof label === "string" ? label : fact.sourceId,
|
||||
title: mapRuntimeDisplayLabel(fact, profile),
|
||||
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
|
||||
// constructing the scene. Keep the last camera synchronously as well as in
|
||||
// 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),
|
||||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||
pinBindings,
|
||||
presentationProfiles,
|
||||
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) => {
|
||||
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 })} />
|
||||
</>,
|
||||
},
|
||||
...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",
|
||||
label: "Сетка и LOD",
|
||||
|
|
@ -823,6 +1091,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
|
||||
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
|
||||
aria-label="Map Page Cesium adapter"
|
||||
|
|
@ -841,19 +1110,39 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
initialCamera={mapCamera ?? undefined}
|
||||
presentation={presentation}
|
||||
runtimeBindings={runtimeBindings}
|
||||
presentationProfiles={presentationProfiles}
|
||||
presentationFilters={presentationFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<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={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.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
|
||||
</div>
|
||||
|
||||
{layersOpen ? (
|
||||
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
|
||||
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
|
||||
<WorkspaceWindow
|
||||
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">
|
||||
<strong>Cesium World Imagery</strong>
|
||||
<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}
|
||||
<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} />
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</WorkspaceWindow>
|
||||
) : null}
|
||||
|
||||
{toolbarOpen ? (
|
||||
<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>
|
||||
</div>
|
||||
) : 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}
|
||||
<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}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="catalog-map-fixture__map-settings-window"
|
||||
onClose={() => setInspectorOpen(false)}
|
||||
>
|
||||
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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;
|
||||
|
||||
|
|
@ -24,6 +25,16 @@ export interface ApplicationPageManifest {
|
|||
layout?: {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -603,126 +603,6 @@ textarea {
|
|||
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 {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
|
|
@ -734,15 +614,16 @@ textarea {
|
|||
|
||||
.catalog-map-fixture__actions .nodedc-icon-button,
|
||||
.catalog-map-fixture__toolbar .nodedc-icon-button {
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 76%, transparent);
|
||||
border: 0;
|
||||
background: var(--nodedc-map-glass-bg);
|
||||
color: var(--nodedc-map-glass-text);
|
||||
box-shadow: var(--nodedc-glass-control-shadow);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.catalog-map-fixture__actions .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],
|
||||
|
|
@ -753,21 +634,46 @@ textarea {
|
|||
}
|
||||
|
||||
.catalog-map-fixture__layers {
|
||||
position: absolute;
|
||||
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);
|
||||
--nodedc-radius-modal: 1.45rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__layers-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
.catalog-map-fixture__map-glass-window,
|
||||
.catalog-map-fixture__map-settings-window {
|
||||
--nodedc-canvas: #111216;
|
||||
--nodedc-text-primary: rgba(13, 14, 17, 0.96);
|
||||
--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 {
|
||||
|
|
@ -799,13 +705,140 @@ textarea {
|
|||
display: flex;
|
||||
gap: 0.35rem;
|
||||
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;
|
||||
translate: -50% 0;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
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 {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
|
|
@ -1192,6 +1225,22 @@ textarea {
|
|||
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 {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export type MapRuntimeBinding = {
|
|||
bindingId: string;
|
||||
dataProductId: string;
|
||||
slotId: string;
|
||||
presentationProfileId?: string;
|
||||
facts: MapRuntimeFact[];
|
||||
cursor: string | null;
|
||||
state: "idle" | "loading" | "ready" | "reconnecting" | "error";
|
||||
|
|
@ -249,6 +250,7 @@ export function useMapDataProductRuntime({
|
|||
slotId: binding.slotId,
|
||||
semanticTypes: binding.semanticTypes,
|
||||
fieldProjection: binding.fieldProjection,
|
||||
presentationProfileId: binding.presentationProfileId,
|
||||
}))),
|
||||
[bindings],
|
||||
);
|
||||
|
|
@ -363,6 +365,7 @@ export function useMapDataProductRuntime({
|
|||
bindingId: binding.id,
|
||||
dataProductId: binding.dataProductId,
|
||||
slotId: binding.slotId,
|
||||
presentationProfileId: binding.presentationProfileId,
|
||||
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
|
||||
cursor: record.cursor,
|
||||
state: record.state,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ Dropdown владеет floating-layer поведением:
|
|||
|
||||
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
|
||||
|
||||
Поверх Cesium/map imagery dropdown получает класс `nodedc-map-glass` (`MapGlassSurface`): это отдельный светлый полупрозрачный материал, совпадающий с Map Toolbar. Он не заменяет обычный theme-dependent floating surface вне карты.
|
||||
|
||||
## Select
|
||||
|
||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
||||
|
|
@ -210,6 +212,10 @@ Sortable-строки ограничены вертикальной осью: г
|
|||
|
||||
Статус использует 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
|
||||
|
||||
Единая accordion-панель для settings и definition controls. Источник — только новый Environment Settings и NDC Agent Inspector.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
## Границы ответственности
|
||||
|
||||
- Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями.
|
||||
- Map Scene Fixture задаёт минимальный проверочный набор пространственных сущностей и состояний.
|
||||
- Empty Map Scene Fixture проверяет shell без каких-либо shipped domain-объектов.
|
||||
- Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility.
|
||||
- Platform capability binding связывает слоты шаблона с scoped data products через Foundry runtime BFF.
|
||||
- Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer.
|
||||
|
|
@ -26,14 +26,30 @@ Engine/NDC остаётся средой создания и исполнени
|
|||
- selection;
|
||||
- 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
|
||||
|
||||
- `registry/fixtures/map/map-operational-v0.1.json` — минимальная рабочая сцена с сеткой, транспортом, станциями, маршрутом, железнодорожным путём, зоной и selection.
|
||||
- `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
|
||||
|
||||
|
|
@ -41,7 +57,7 @@ Fixtures малы и детерминированы. Большие геодан
|
|||
|
||||
## 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:
|
||||
|
||||
|
|
@ -82,7 +98,8 @@ Runtime tile cache не является исходным кодом и не х
|
|||
## Live data-product runtime
|
||||
|
||||
`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:
|
||||
|
||||
```text
|
||||
|
|
@ -94,6 +111,9 @@ runtime. При первом exact consumer apply Foundry генерирует t
|
|||
передаёт EDP только его digest в запросе с отдельной service-подписью; EDP сам
|
||||
разрешает unique active writer scope и fail-closed отклоняет ambiguity. Legacy
|
||||
root-owned deployment directory используется только как совместимый fallback.
|
||||
При смене versioned Data Product Foundry создаёт successor reader generation,
|
||||
bootstrap-ит новый snapshot и только затем отзывает predecessor; существующий
|
||||
grant никогда не переписывается другим request hash внутри одного поколения.
|
||||
Browser, manifest и Cesium adapter не получают provider
|
||||
endpoint, tenant/connection scope, reader token или raw provider payload.
|
||||
Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch
|
||||
|
|
@ -104,3 +124,13 @@ history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF
|
|||
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
|
||||
на binding и обновляет stable entity id без пересоздания viewer. Sampling и
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ MCP не вводит новую оркестрацию. Доступ выдаё
|
|||
| Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс |
|
||||
| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, provider API или произвольный внешний endpoint из Foundry MCP |
|
||||
| Создавать/обновлять 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 |
|
||||
| Управлять server-owned consumer только для persisted approved binding | Передавать reader capability, EDP URL или raw provider payload через MCP |
|
||||
|
||||
|
|
@ -55,6 +57,10 @@ Endpoint поддерживает два существующих контура
|
|||
- `foundry_update_application_metadata`
|
||||
- `foundry_add_page_instance`
|
||||
- `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_plan_map_data_product_consumer`
|
||||
- `foundry_apply_map_data_product_consumer`
|
||||
|
|
@ -62,11 +68,43 @@ Endpoint поддерживает два существующих контура
|
|||
- `foundry_accept_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` сохраняет только декларацию
|
||||
`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 получает
|
||||
scoped snapshot/patch поток через Platform, но не через Foundry MCP.
|
||||
|
||||
|
|
@ -82,6 +120,15 @@ target-scoped token, коммитит snapshot и включает consumer; `st
|
|||
сохраняет последний safe snapshot. Capability value, grant path, internal URL и
|
||||
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
|
||||
|
||||
Для 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 находит
|
||||
opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)`. Нормальный
|
||||
grant создаётся внутри persistent private runtime Foundry; runner монтирует
|
||||
opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)` и persisted
|
||||
active generation. Generation 1 сохраняет совместимый legacy filename, а
|
||||
successor capabilities хранятся отдельными root-owned immutable files.
|
||||
Нормальный grant создаётся внутри persistent private runtime Foundry; runner монтирует
|
||||
отдельный Ed25519 private key только для подписи digest-only provisioner
|
||||
request, а EDP получает только public trust. Старый root-owned read-only grant
|
||||
directory остаётся fallback для уже выданных grant. Сам token передаётся
|
||||
|
|
@ -121,7 +170,9 @@ Freshness и remove принадлежат versioned provider-neutral policy и
|
|||
ошибка не удаляет subject. Удаление допустимо только при отсутствии в
|
||||
авторитетном snapshot rebase или по canonical `tombstone`/`revoked` operation.
|
||||
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
|
||||
|
|
@ -246,7 +297,7 @@ curl http://127.0.0.1:9920/healthz
|
|||
## Следующие слои
|
||||
|
||||
1. Онтологический contract приложения, страницы, page instance, Map Page и visual entities.
|
||||
2. Runtime adapter между ontology/gateway потоками и сохранёнными visual bindings.
|
||||
3. Реальные `elevated-spike`, targets, zones, routes и toolbar commands на instance Map Page.
|
||||
2. Расширение runtime adapter с points на targets, zones, routes и tracks.
|
||||
3. Управляемая публикация и повторное использование presentation profiles между Applications.
|
||||
4. Access/Hub registration и публикация module route.
|
||||
5. Отдельная пустая программируемая страница — только после того, как готовые page templates и их MCP-контуры отработаны.
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@
|
|||
|
||||
Каноническая 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-operational-v0.1.json` проверяет содержательную сцену и переиспользуемый язык подписей/маркеров. `map-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Registry validation дополнительно проверяет уникальность id, ссылки на style profiles, selection и непрерывность LOD-диапазонов.
|
||||
`map-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Operational demo fixture удалён: Visual Library, Page Library и Applications не поставляют города, движущиеся объекты, станции, маршруты, пути, зоны или selection. Domain entities появляются только из declared Application bindings.
|
||||
|
||||
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;
|
||||
- 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
|
||||
|
||||
|
|
@ -72,6 +70,10 @@ Catalog server предоставляет минимальный временн
|
|||
|
||||
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 или публикация следующей версии не меняют уже закреплённое приложение.
|
||||
|
||||
Временный 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` без изменения публичного контракта.
|
||||
|
||||
Все save/update/publish операции показывают канонический `ToastStack` снизу справа. Loading-state обновляется в success/error в том же toast; blocking confirmation остаётся отдельной modal-механикой.
|
||||
|
||||
## Baseline MCP
|
||||
|
||||
Foundry предоставляет базовый MCP-контур для уже существующего сквозного AI Workspace Assistant. Он не создаёт отдельную оркестрацию: штатный entitlement adapter выдаёт доступ к Foundry только авторизованному пользователю, после чего ассистент получает ограниченный набор инструментов.
|
||||
|
||||
- `Page Library` и канонические шаблоны доступны только на чтение;
|
||||
- изменяются только экземпляры в `Applications`;
|
||||
- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы и upsert provider-neutral map pin bindings;
|
||||
- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles и data-product bindings;
|
||||
- удаление модуля через MCP намеренно отсутствует;
|
||||
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"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: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:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -86,10 +86,7 @@ export const mapPageTemplate = {
|
|||
],
|
||||
contracts: {
|
||||
fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json",
|
||||
acceptanceFixtures: [
|
||||
"registry/fixtures/map/map-operational-v0.1.json",
|
||||
"registry/fixtures/map/map-empty-offline-v0.1.json",
|
||||
],
|
||||
acceptanceFixtures: ["registry/fixtures/map/map-empty-offline-v0.1.json"],
|
||||
lodPolicySchema: "registry/schemas/map-lod-policy-v0.1.schema.json",
|
||||
lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@
|
|||
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
|
||||
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
|
||||
--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-overlay-bg: rgba(0, 0, 0, 0.46);
|
||||
--nodedc-glass-rim: transparent;
|
||||
|
|
@ -96,6 +102,12 @@
|
|||
--nodedc-glass-control-hover: #f8f8f8;
|
||||
--nodedc-glass-control-active: rgba(32, 32, 34, 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-overlay-bg: rgba(24, 32, 29, 0.22);
|
||||
--nodedc-glass-rim: transparent;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,24 @@
|
|||
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-bg: var(--nodedc-glass-control-bg);
|
||||
--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);
|
||||
}
|
||||
|
||||
.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"] {
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,16 +52,17 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
|
|||
shape?: "circle" | "rounded";
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
|
||||
label,
|
||||
shape = "circle",
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
}, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn("nodedc-icon-button", className)}
|
||||
data-shape={shape === "circle" ? undefined : shape}
|
||||
|
|
@ -72,4 +73,4 @@ export function IconButton({
|
|||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ export interface GlassMaterialSurfaceProps extends HTMLAttributes<HTMLDivElement
|
|||
export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) {
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
KeyRound,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LocateFixed,
|
||||
LockKeyhole,
|
||||
MailPlus,
|
||||
Maximize2,
|
||||
|
|
@ -71,6 +72,7 @@ const icons = {
|
|||
inbox: Inbox,
|
||||
key: KeyRound,
|
||||
list: ListChecks,
|
||||
target: LocateFixed,
|
||||
lock: LockKeyhole,
|
||||
mail: MailPlus,
|
||||
minimize: Minimize2,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export * from "./StatusBadge.js";
|
|||
export * from "./Settings.js";
|
||||
export * from "./SharingModals.js";
|
||||
export * from "./Toolbar.js";
|
||||
export * from "./Toast.js";
|
||||
export * from "./UserProfileMenu.js";
|
||||
export * from "./Window.js";
|
||||
export * from "./WorkspaceWindow.js";
|
||||
|
|
|
|||
|
|
@ -72,16 +72,6 @@
|
|||
"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."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"classification": "missing-primitive",
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@
|
|||
"id": "glass-surface",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["GlassSurface", "GlassMaterialSurface"],
|
||||
"domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-material-rim"],
|
||||
"summary": "Matte application surfaces plus the canonical Engine V4 glass material for modal and Inspector shells.",
|
||||
"anatomy": ["theme-provided surface", "modal tint", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
||||
"variants": ["default", "strong", "soft"],
|
||||
"exports": ["GlassSurface", "GlassMaterialSurface", "MapGlassSurface"],
|
||||
"domContract": ["nodedc-glass", "nodedc-glass-material", "nodedc-map-glass", "nodedc-material-rim"],
|
||||
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
||||
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
||||
"variants": ["default", "strong", "soft", "map"],
|
||||
"rules": [
|
||||
"Geometry is invariant across application themes.",
|
||||
"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.",
|
||||
"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"],
|
||||
"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",
|
||||
"status": "baseline",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,39 @@
|
|||
"no_position"
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."] }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -36,7 +36,6 @@
|
|||
"contracts": {
|
||||
"fixtureSchema": "registry/schemas/map-scene-fixture-v0.1.schema.json",
|
||||
"acceptanceFixtures": [
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -54,5 +54,6 @@
|
|||
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
|
||||
"mapSceneFixtureSchema": "schemas/map-scene-fixture-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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,91 @@
|
|||
"features": {
|
||||
"type": "object",
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@
|
|||
"lensCount": { "type": "integer", "minimum": 1, "maximum": 12 },
|
||||
"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" },
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -323,10 +323,12 @@ try {
|
|||
return payload.result;
|
||||
};
|
||||
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");
|
||||
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_save_map_page_view_state"));
|
||||
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_remove_map_pin_binding"));
|
||||
|
||||
const workloadHeaders = {
|
||||
authorization: `Bearer ${bindingGrantToken}`,
|
||||
|
|
|
|||
|
|
@ -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 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 mapPresentationProfiles = await parse("registry/map-presentation-profiles.json");
|
||||
const dataProductConsumerPolicies = await parse("registry/data-product-consumer-policies.json");
|
||||
|
||||
const failures = [];
|
||||
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(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(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(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");
|
||||
|
|
@ -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.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.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(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(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);
|
||||
requireValue(new Set(ids).size === ids.length, "component ids must be unique");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
|
|||
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
||||
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
||||
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
||||
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
||||
import { createFoundryAuth } from "./nodedc-auth.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
|
@ -47,6 +48,11 @@ await mkdir(foundryDataProductConsumersDir, { recursive: true });
|
|||
await mkdir(foundryAgentDataDir, { recursive: true });
|
||||
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "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 mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
||||
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 dataProductId = requireNonEmptyString(value.dataProductId, "invalid_map_data_product_id", 160);
|
||||
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 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(dataProductId)) throw applicationError("invalid_map_data_product_id");
|
||||
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))) {
|
||||
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))) {
|
||||
throw applicationError("map_data_product_binding_contains_transport");
|
||||
}
|
||||
|
|
@ -336,6 +354,9 @@ function validateMapDataProductBinding(value) {
|
|||
delivery: "snapshot+patch",
|
||||
semanticTypes: [...semanticTypes],
|
||||
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) {
|
||||
if (!isObject(value)) throw applicationError("invalid_map_page_layout");
|
||||
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_map_page_layout");
|
||||
if (!isObject(value.settings)) throw applicationError("invalid_map_page_settings");
|
||||
if (!isObject(value.camera)) throw applicationError("invalid_map_page_camera");
|
||||
const settings = value.settings;
|
||||
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"]) {
|
||||
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"]) {
|
||||
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 {
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
|
|
@ -387,10 +489,35 @@ function validateMapPageLayout(value) {
|
|||
roll: camera.roll,
|
||||
},
|
||||
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() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
|
|
@ -404,9 +531,9 @@ function defaultMapPageLayout() {
|
|||
terrainExaggeration: 1,
|
||||
monochrome: false,
|
||||
monochromeColor: "#15151b",
|
||||
imageryGamma: 100,
|
||||
imageryHue: 0,
|
||||
imageryAlpha: 100,
|
||||
imageryGamma: 57,
|
||||
imageryHue: 13,
|
||||
imageryAlpha: 27,
|
||||
globeColor: "#15151b",
|
||||
backgroundColor: "#08090d",
|
||||
atmosphereEnabled: false,
|
||||
|
|
@ -421,11 +548,11 @@ function defaultMapPageLayout() {
|
|||
shadowsEnabled: true,
|
||||
buildingsVisible: true,
|
||||
buildingsColor: "#a27aff",
|
||||
buildingsOpacity: 0.82,
|
||||
buildingsDetail: 16,
|
||||
imageryBrightness: 100,
|
||||
imageryContrast: 100,
|
||||
imagerySaturation: 100,
|
||||
buildingsOpacity: 1,
|
||||
buildingsDetail: 4,
|
||||
imageryBrightness: 118,
|
||||
imageryContrast: 102,
|
||||
imagerySaturation: 0,
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
gridHeightMeters: 500,
|
||||
|
|
@ -453,7 +580,9 @@ function defaultMapPageLayout() {
|
|||
roll: 0,
|
||||
},
|
||||
pinBindings: [],
|
||||
presentationProfiles: structuredClone(canonicalMapPresentationProfiles),
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -540,6 +669,56 @@ function validateDesignProfileLayout(value) {
|
|||
lensCount: requireInteger(toolbar.lensCount, 1, 12, "invalid_toolbar_lens_count"),
|
||||
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,
|
||||
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 (!Array.isArray(value.pages)) throw applicationError("invalid_application_pages");
|
||||
const pageIds = new Set();
|
||||
const normalizedPages = [];
|
||||
for (const page of value.pages) {
|
||||
if (!page || typeof page !== "object") throw applicationError("invalid_application_page");
|
||||
const pageId = String(page.id || "").trim();
|
||||
|
|
@ -734,18 +915,38 @@ function validateApplicationManifest(value) {
|
|||
if (!template) throw applicationError("unknown_application_page_template");
|
||||
if (!page.navigation || typeof page.navigation.visible !== "boolean") throw applicationError("invalid_application_navigation");
|
||||
if (!page.features || typeof page.features !== "object" || Array.isArray(page.features)) throw applicationError("invalid_application_features");
|
||||
let normalizedPage = page;
|
||||
if (page.layout !== undefined) {
|
||||
if (!isObject(page.layout)) throw applicationError("invalid_application_page_layout");
|
||||
if (page.template.id === "map" && page.layout.map !== undefined) validateMapPageLayout(page.layout.map);
|
||||
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));
|
||||
if (Object.keys(page.features).some((feature) => !allowedFeatures.has(feature))) throw applicationError("unsupported_application_feature");
|
||||
for (const feature of template.features) {
|
||||
if (feature.required && page.features[feature.id] !== true) throw applicationError("required_application_feature_disabled");
|
||||
}
|
||||
normalizedPages.push(normalizedPage);
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
pages: normalizedPages,
|
||||
designProfile: { ...value.designProfile, status: designProfileStatus },
|
||||
metadata: {
|
||||
...value.metadata,
|
||||
|
|
@ -852,12 +1053,16 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
|
|||
return { application, page, binding };
|
||||
}
|
||||
|
||||
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
|
||||
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId, generation = 1) {
|
||||
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
|
||||
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 (generation !== 1) 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
|
||||
// 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 }) {
|
||||
let readerToken;
|
||||
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) {
|
||||
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
|
||||
throw error;
|
||||
|
|
@ -931,11 +1146,16 @@ async function readRuntimeReaderProduct(readerToken, dataProductId) {
|
|||
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
|
||||
}
|
||||
|
||||
async function inspectFoundryConsumerReaderGrant(target) {
|
||||
async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}) {
|
||||
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);
|
||||
if (product) return { product, readerGrantAction: "reuse" };
|
||||
if (product) return { product, readerGrantAction: "reuse", readerGrantGeneration: generation };
|
||||
} catch (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);
|
||||
}
|
||||
const planned = await foundryReaderGrantProvisioner.plan(target);
|
||||
return { product: planned.product, readerGrantAction: "ensure" };
|
||||
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
|
||||
}
|
||||
|
||||
function runtimePointGeometry(value) {
|
||||
|
|
@ -1201,7 +1421,8 @@ const dataProductConsumerManager = createFoundryDataProductConsumerManager({
|
|||
resolveTarget: resolveRuntimeMapDataProductBinding,
|
||||
readReaderToken: resolveRuntimeDataProductReaderToken,
|
||||
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
|
||||
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target),
|
||||
ensureReaderGrant: (target, options) => foundryReaderGrantProvisioner.ensure(target, options),
|
||||
revokeReaderGrant: (target, options) => foundryReaderGrantProvisioner.revoke(target, options),
|
||||
resolvePolicy: resolveDataProductConsumerPolicy,
|
||||
sanitizeSnapshot: sanitizeRuntimeSnapshot,
|
||||
sanitizePatch: sanitizeRuntimePatch,
|
||||
|
|
@ -1549,7 +1770,7 @@ const foundryMcpOperations = {
|
|||
const config = foundryMcpConfig();
|
||||
return {
|
||||
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",
|
||||
canonicalPageLibrary: "read-only",
|
||||
applicationInstances: "read-write",
|
||||
|
|
@ -1560,6 +1781,10 @@ const foundryMcpOperations = {
|
|||
"application.metadata.update",
|
||||
"page-instance.create",
|
||||
"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-consumer.plan",
|
||||
"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) {
|
||||
return executeFoundryMcpWrite({
|
||||
tool: "foundry_upsert_map_data_product_binding",
|
||||
|
|
@ -1680,17 +2043,36 @@ const foundryMcpOperations = {
|
|||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
||||
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required");
|
||||
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);
|
||||
dataProductBindings.push(binding);
|
||||
dataProductBindings.push(resolvedBinding);
|
||||
updatedPage = {
|
||||
...page,
|
||||
layout: { ...page.layout, map: { ...layout, dataProductBindings, savedAt: new Date().toISOString() } },
|
||||
layout: { ...page.layout, map: validateMapPageLayout({
|
||||
...layout,
|
||||
dataProductBindings,
|
||||
savedAt: new Date().toISOString(),
|
||||
}) },
|
||||
};
|
||||
return updatedPage;
|
||||
}),
|
||||
}));
|
||||
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;
|
||||
}
|
||||
if (pageLayoutMatch && request.method === "PUT") {
|
||||
const pageId = pageLayoutMatch[1];
|
||||
if (pageId !== "map") throw applicationError("unsupported_page_layout");
|
||||
const layout = validateMapPageLayout(await readJsonBody(request));
|
||||
const next = { ...layout, savedAt: new Date().toISOString() };
|
||||
const targetPath = pageLayoutPath(pageId);
|
||||
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
await rename(tempPath, targetPath);
|
||||
json(response, 200, next);
|
||||
return;
|
||||
throw applicationError("page_library_read_only", 405);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/map/runtime-config" && request.method === "GET") {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -115,6 +115,30 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
|
|||
assert.equal(foundryListResponse.status, 200);
|
||||
const foundryList = await json(foundryListResponse);
|
||||
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, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ function safeErrorCode(error) {
|
|||
}
|
||||
|
||||
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]
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
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,
|
||||
lastError: state.lastError || null,
|
||||
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) {
|
||||
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
|
||||
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);
|
||||
}
|
||||
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
|
||||
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
|
||||
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") {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
|
|
@ -136,10 +168,39 @@ function validatePolicy(policy, product) {
|
|||
productVersion: product.version,
|
||||
staleAfterMs: policy.staleAfterMs,
|
||||
terminalStatuses: [...terminalStatuses],
|
||||
...(statusContract ? { statusContract } : {}),
|
||||
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) {
|
||||
const fields = { event: "message", id: "", data: [] };
|
||||
for (const line of block.split("\n")) {
|
||||
|
|
@ -174,6 +235,7 @@ export function createFoundryDataProductConsumerManager({
|
|||
readReaderToken,
|
||||
inspectReaderGrant,
|
||||
ensureReaderGrant,
|
||||
revokeReaderGrant,
|
||||
resolvePolicy,
|
||||
sanitizeSnapshot,
|
||||
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);
|
||||
let product = null;
|
||||
let readerGrantAction = "reuse";
|
||||
let readerGrantGeneration = generation;
|
||||
if (inspectReaderGrant) {
|
||||
const inspection = await inspectReaderGrant(target);
|
||||
const inspection = await inspectReaderGrant(target, { generation });
|
||||
product = inspection?.product || null;
|
||||
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
|
||||
readerGrantGeneration = readerGrantAction === "ensure" ? ensureGeneration : generation;
|
||||
} 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;
|
||||
try {
|
||||
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))) {
|
||||
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) {
|
||||
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
||||
const { product, policy, readerGrantAction } = await catalog(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 configuration = {
|
||||
target: safe,
|
||||
|
|
@ -287,6 +370,7 @@ export function createFoundryDataProductConsumerManager({
|
|||
policy,
|
||||
readerGrant: "target-scoped-server-only",
|
||||
readerGrantAction,
|
||||
readerGrantGeneration,
|
||||
};
|
||||
const existingMatches = record.state
|
||||
&& JSON.stringify(record.state.target) === JSON.stringify(safe)
|
||||
|
|
@ -294,7 +378,16 @@ export function createFoundryDataProductConsumerManager({
|
|||
&& record.state.product?.version === product.version
|
||||
&& record.state.policy?.id === policy.id
|
||||
&& 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 {
|
||||
schemaVersion: PLAN_SCHEMA_VERSION,
|
||||
planId: planHash({ configuration, action }),
|
||||
|
|
@ -302,16 +395,19 @@ export function createFoundryDataProductConsumerManager({
|
|||
configuration,
|
||||
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
|
||||
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",
|
||||
"bootstrap-scoped-snapshot",
|
||||
...(action === "finalize-reader-grant-rotation" ? [] : ["bootstrap-scoped-snapshot"]),
|
||||
...(predecessorReaderGrantGeneration ? ["revoke-predecessor-reader-grant"] : []),
|
||||
"share-one-upstream-stream-per-active-binding",
|
||||
],
|
||||
destructive: false,
|
||||
};
|
||||
}
|
||||
|
||||
function initialState(target, product, policy) {
|
||||
function initialState(target, product, policy, readerGrantGeneration) {
|
||||
const timestamp = new Date(now()).toISOString();
|
||||
return {
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
|
|
@ -321,6 +417,8 @@ export function createFoundryDataProductConsumerManager({
|
|||
runtimeState: "bootstrapping",
|
||||
product: { id: product.id, version: product.version },
|
||||
policy,
|
||||
readerGrantGeneration,
|
||||
predecessorReaderGrantGeneration: null,
|
||||
cursor: "0",
|
||||
snapshotGeneration: 0,
|
||||
subjects: {},
|
||||
|
|
@ -337,7 +435,12 @@ export function createFoundryDataProductConsumerManager({
|
|||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
response = await fetchImpl(upstreamUrl(record, "snapshot"), {
|
||||
|
|
@ -410,26 +513,77 @@ export function createFoundryDataProductConsumerManager({
|
|||
const planned = await plan(input);
|
||||
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
|
||||
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 (!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 } = await catalog(target);
|
||||
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
|
||||
generation: plannedGeneration,
|
||||
ensureGeneration: plannedGeneration,
|
||||
});
|
||||
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
|
||||
if (record.stream) stopStream(record);
|
||||
if (!record.state || planned.action === "replace") record.state = initialState(target, product, policy);
|
||||
if (readerGrantGeneration !== plannedGeneration) throw consumerError("data_product_reader_grant_generation_mismatch", 409);
|
||||
|
||||
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 {
|
||||
record.target = target;
|
||||
record.state.target = safeTarget(target);
|
||||
record.state.product = { id: product.id, version: product.version };
|
||||
record.state.policy = policy;
|
||||
record.state.readerGrantGeneration = plannedGeneration;
|
||||
record.state.enabled = true;
|
||||
record.state.runtimeState = "bootstrapping";
|
||||
record.state.lastError = null;
|
||||
}
|
||||
try {
|
||||
await writeState(record);
|
||||
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);
|
||||
return { plan: planned, consumer: safeStateSummary(record) };
|
||||
}
|
||||
|
|
@ -526,7 +680,12 @@ export function createFoundryDataProductConsumerManager({
|
|||
}
|
||||
|
||||
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 response = openStream
|
||||
? await openStream({ url, token, signal })
|
||||
|
|
@ -625,12 +784,27 @@ export function createFoundryDataProductConsumerManager({
|
|||
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) {
|
||||
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
||||
const record = await ensureProvisioned(target);
|
||||
// A removed target grant revokes browser reads immediately even though the
|
||||
// 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);
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.snapshot/v1",
|
||||
|
|
|
|||
|
|
@ -334,3 +334,283 @@ test("exact apply can ensure a missing target-scoped reader grant without exposi
|
|||
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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -122,6 +122,261 @@ function normalizeMcpError(error) {
|
|||
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 = [
|
||||
{
|
||||
name: "foundry_status",
|
||||
|
|
@ -138,7 +393,7 @@ const tools = [
|
|||
{
|
||||
name: "foundry_get_application",
|
||||
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: {
|
||||
type: "object",
|
||||
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",
|
||||
title: "Upsert Map data product binding",
|
||||
|
|
@ -262,10 +606,13 @@ const tools = [
|
|||
required: ["id", "dataProductId", "slotId", "semanticTypes"],
|
||||
properties: {
|
||||
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." },
|
||||
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
|
||||
semanticTypes: { 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_add_page_instance: (input, actor) => operations.addPageInstance(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_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input),
|
||||
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, {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "nodedc_module_foundry", version: "0.2.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.",
|
||||
serverInfo: { name: "nodedc_module_foundry", version: "0.5.0" },
|
||||
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, {});
|
||||
|
|
@ -477,6 +828,10 @@ export async function handleFoundryEntitlementRequest(request, response, options
|
|||
"foundry.application.update",
|
||||
"foundry.page-instance.create",
|
||||
"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-consumer.read",
|
||||
"foundry.map-data-product-consumer.lifecycle",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,21 @@ function targetDigest(target) {
|
|||
.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 }) {
|
||||
return JSON.stringify({
|
||||
schemaVersion: SIGNATURE_SCHEMA,
|
||||
|
|
@ -137,10 +152,11 @@ export function createFoundryReaderGrantProvisioner({
|
|||
return { product, sourceScope: "resolved-server-side" };
|
||||
}
|
||||
|
||||
async function ensure(target) {
|
||||
async function ensure(target, options = {}) {
|
||||
const identity = targetIdentity(target);
|
||||
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,
|
||||
randomBytesImpl,
|
||||
});
|
||||
|
|
@ -151,26 +167,44 @@ export function createFoundryReaderGrantProvisioner({
|
|||
{
|
||||
allowedDataProductIds: [identity.dataProductId],
|
||||
expiresAt: null,
|
||||
generation: 1,
|
||||
generation,
|
||||
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
|
||||
},
|
||||
);
|
||||
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"
|
||||
|| !Array.isArray(binding?.allowedDataProductIds)
|
||||
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) {
|
||||
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;
|
||||
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 }) {
|
||||
|
|
|
|||
|
|
@ -125,3 +125,94 @@ test("managed Foundry grant provisioning signs digest-only requests and persists
|
|||
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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue