feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+1 -1
View File
@@ -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>
+320 -144
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { 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,45 +1123,88 @@ 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);
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 }),
});
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 } };
const previous = designProfiles.find((profile) => profile.id === saved.id);
const summary: DesignProfileSummary = {
id: saved.id,
name: saved.name,
version: saved.version,
status: saved.status,
theme: saved.layout.theme === "light" ? "light" : "dark",
updatedAt: saved.timestamps.updatedAt,
versions: previous?.versions ?? [],
latestPublishedVersion: previous?.latestPublishedVersion ?? null,
};
setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name)));
setActiveDesignProfileId(summary.id);
setDesignProfileSaveOpen(false);
setDesignProfileName("");
setLayoutSaveState("saved");
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 }),
});
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,
name: saved.name,
version: saved.version,
status: saved.status,
theme: saved.layout.theme === "light" ? "light" : "dark",
updatedAt: saved.timestamps.updatedAt,
versions: previous?.versions ?? [],
latestPublishedVersion: previous?.latestPublishedVersion ?? null,
};
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} />
</>
);
}
+198 -216
View File
@@ -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,35 +349,98 @@ 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;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
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,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
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);
}
+443 -56
View File
@@ -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);
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,
kind: fact.semanticType,
status,
};
})),
], [fixtureSelectable, runtimeBindings]);
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: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus,
};
});
})
), [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,43 +1110,160 @@ 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>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
<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>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</GlassSurface>
</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 />
+12 -1
View File
@@ -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 {
+125
View File
@@ -0,0 +1,125 @@
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
import type { ToolbarPlacement } from "@nodedc/ui-react";
import type { FaviconAssetUrls } from "./favicon.js";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export type MaterialDraft = {
panelHex: string;
panelOpacity: number;
fieldHex: string;
fieldOpacity: number;
nestedHex: string;
};
export type MapDesignProfileFragment = {
schemaVersion: 1;
templateId: "map";
templateVersion: string;
settings: MapPageSettings;
presentationProfiles: MapPresentationProfile[];
};
export type DesignProfilePageFragment = MapDesignProfileFragment;
export interface StoredLayout {
theme?: NodedcTheme;
accentHex?: string;
materialByTheme?: Record<NodedcTheme, MaterialDraft>;
environment?: {
lightColor?: string;
brightness?: number;
glowDistance?: number;
connectionType?: string;
connectionColor?: string;
usePortColors?: boolean;
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeOpacity?: number;
};
media?: {
source?: "file" | "url";
url?: string;
fileName?: string;
fileSrc?: string;
visible?: boolean;
logoSource?: "file" | "url";
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
border?: string;
outline?: string;
minSize?: number;
maxSize?: number;
lensCount?: number;
autoHide?: boolean;
};
/** Design fragments are keyed by a registered Page Library type/version. */
pageTypes?: Record<string, DesignProfilePageFragment>;
}
export type MapDesignOverrides = {
settings?: Partial<MapPageSettings>;
presentationProfiles?: MapPresentationProfile[];
};
export const mapDesignProfileKey = (templateVersion: string) => `map@${templateVersion}`;
export function mapDesignFragmentFromLayout(layout: MapPageLayout, templateVersion: string): MapDesignProfileFragment {
return {
schemaVersion: 1,
templateId: "map",
templateVersion,
settings: structuredClone(layout.settings),
presentationProfiles: structuredClone(layout.presentationProfiles),
};
}
export function mapDesignFragmentForLayout(layout: StoredLayout | null | undefined, templateVersion: string) {
const fragment = layout?.pageTypes?.[mapDesignProfileKey(templateVersion)];
return fragment?.templateId === "map" ? fragment : null;
}
export function resolveMapDesignLayout(
base: MapPageLayout | null | undefined,
fragment: MapDesignProfileFragment | null | undefined,
overrides?: MapDesignOverrides,
) {
if (!base) return null;
if (!fragment) return base;
return {
...base,
settings: {
...base.settings,
...fragment.settings,
...overrides?.settings,
},
presentationProfiles: structuredClone(overrides?.presentationProfiles ?? fragment.presentationProfiles),
} satisfies MapPageLayout;
}
export function mapDesignOverridesFromResolved(
resolved: MapPageLayout,
fragment: MapDesignProfileFragment | null | undefined,
): MapDesignOverrides | undefined {
if (!fragment) return undefined;
const settings = Object.fromEntries(Object.entries(resolved.settings).filter(([key, value]) => (
fragment.settings[key as keyof MapPageSettings] !== value
))) as Partial<MapPageSettings>;
const presentationProfiles = JSON.stringify(resolved.presentationProfiles) === JSON.stringify(fragment.presentationProfiles)
? undefined
: structuredClone(resolved.presentationProfiles);
if (Object.keys(settings).length === 0 && !presentationProfiles) return undefined;
return {
...(Object.keys(settings).length > 0 ? { settings } : {}),
...(presentationProfiles ? { presentationProfiles } : {}),
};
}
+255
View File
@@ -0,0 +1,255 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapPresentationFacetValue = {
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never be normalized to all. */
visible: boolean;
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
facets: Record<string, string[]>;
};
/** Application view state is keyed by stable binding id, never by editable labels. */
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
/**
* Application manifests persisted before profile v1.1 used the internal key
* `pin`. Normalize that storage shape before the first React render so an old
* application cannot crash while it is being upgraded to the public `target`
* contract through MCP.
*/
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
return profiles.flatMap((profile) => {
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* Apply one interactive facet-chip transition without collapsing the storage
* contract. A missing field means unconstrained, while an explicitly persisted
* empty array remains available to represent an intentional match-nothing view.
*/
export function toggleMapPresentationFacetSelection(
facets: Record<string, string[]>,
field: string,
value: string,
) {
const selected = facets[field];
if (!selected?.includes(value)) {
return { ...facets, [field]: [...(selected ?? []), value] };
}
const nextSelected = selected.filter((item) => item !== value);
if (nextSelected.length > 0) return { ...facets, [field]: nextSelected };
const { [field]: _removed, ...unconstrained } = facets;
return unconstrained;
}
export function mapPresentationProfileForFact(
profiles: MapPresentationProfile[],
presentationProfileId: string | undefined,
semanticType: string,
) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
if (selected === undefined) return [];
return [{ facet, selected }];
});
// Persisted empty arrays are an explicit match-nothing state. Interactive
// deselection removes the field instead, so this branch is only reached for
// a deliberately saved empty view.
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
// Chips form one global union. This mirrors the objects window: choosing
// values from two categories expands the visible set instead of requiring a
// fact to satisfy both categories simultaneously.
return selectedFacets.some(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(
facts: MapRuntimeFact[],
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
})) as Record<string, Record<string, number>>;
}
/**
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
* The operational Map, however, must not present a stale last speed as a
* current movement state. When both canonical facets exist, the movement
* facet is therefore scoped to currently active subjects. Other profiles and
* fields keep their ordinary independent-facet behaviour.
*/
function mapFactParticipatesInFacet(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
facet: MapPresentationFacet,
) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order: string[], value: string) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
+186 -137
View File
@@ -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,