-
Cesium World Imagery
-
официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}
+
{
+ 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="Настройки слоёв карты"
+ >
+
+
+ Cesium World Imagery
+ официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}
+
+ {Object.entries(providerStatus.errors).map(([provider, error]) =>
{provider}: {error})}
+
updateMapSettings({ terrainEnabled })} />
+ updateMapSettings({ buildingsVisible })} />
+ updateMapSettings({ gridVisible })} />
+ Live Cache: {liveCacheSummary}
+ {transportDiagnostic ? {transportDiagnostic} : null}
+ {gatewayHealthAge ? {gatewayHealthAge} : null}
+ {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null}
+
+
- {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})}
- updateMapSettings({ terrainEnabled })} />
- updateMapSettings({ buildingsVisible })} />
- updateMapSettings({ gridVisible })} />
- Live Cache: {liveCacheSummary}
- {transportDiagnostic ? {transportDiagnostic} : null}
- {gatewayHealthAge ? {gatewayHealthAge} : null}
- {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null}
-
-
-
+
) : null}
{toolbarOpen ? (
-
+
(
+
+ )}
+ >
+ {({ close }) => (
+
+
+ Объекты
+ {presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}
+
+ {presentationSummaries.map((summary) => {
+ const state = subjectStates[summary.bindingId];
+ const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
+ return (
+
+ );
+ })}
+ {!presentationSummaries.length ?
Нет подключённых объектов. : null}
+
+ )}
+
+
mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}>
) : 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 (
+
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"
+ >
+
+
+
+ {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 (
+
+ );
+ })
+ ))}
+
+
+
+
+ );
+ })}
+
{assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null}
@@ -890,6 +1276,7 @@ export const MapFixturePreview = forwardRef
setInspectorOpen(false)}
>
diff --git a/apps/catalog/src/applicationManifest.ts b/apps/catalog/src/applicationManifest.ts
index 6c989a2..d7052b3 100644
--- a/apps/catalog/src/applicationManifest.ts
+++ b/apps/catalog/src/applicationManifest.ts
@@ -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;
+ presentationProfiles?: MapPresentationProfile[];
+ };
+ };
}
export interface ApplicationManifestV01 {
diff --git a/apps/catalog/src/designProfile.ts b/apps/catalog/src/designProfile.ts
new file mode 100644
index 0000000..a29b215
--- /dev/null
+++ b/apps/catalog/src/designProfile.ts
@@ -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;
+ 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;
+}
+
+export type MapDesignOverrides = {
+ settings?: Partial;
+ 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;
+ 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 } : {}),
+ };
+}
diff --git a/apps/catalog/src/mapPresentationProfile.ts b/apps/catalog/src/mapPresentationProfile.ts
new file mode 100644
index 0000000..53f8c71
--- /dev/null
+++ b/apps/catalog/src/mapPresentationProfile.ts
@@ -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;
+};
+
+/** Application view state is keyed by stable binding id, never by editable labels. */
+export type MapPresentationFilters = Record;
+
+/**
+ * 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,
+ 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>;
+}
+
+/**
+ * `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;
+}
diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css
index 0ca631a..bf33e85 100644
--- a/apps/catalog/src/styles.css
+++ b/apps/catalog/src/styles.css
@@ -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;
diff --git a/apps/catalog/src/useMapDataProductRuntime.ts b/apps/catalog/src/useMapDataProductRuntime.ts
index 743dcb1..dbff84e 100644
--- a/apps/catalog/src/useMapDataProductRuntime.ts
+++ b/apps/catalog/src/useMapDataProductRuntime.ts
@@ -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,
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 0d949f5..4fbdf49 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -62,6 +62,8 @@ Dropdown владеет floating-layer поведением:
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
+Поверх Cesium/map imagery dropdown получает класс `nodedc-map-glass` (`MapGlassSurface`): это отдельный светлый полупрозрачный материал, совпадающий с Map Toolbar. Он не заменяет обычный theme-dependent floating surface вне карты.
+
## Select
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
@@ -210,6 +212,10 @@ Sortable-строки ограничены вертикальной осью: г
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
+## ToastStack
+
+`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation.
+
## Environment Controls: Inspector и ControlRow
Единая accordion-панель для settings и definition controls. Источник — только новый Environment Settings и NDC Agent Inspector.
diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md
index c2710ff..7aa078c 100644
--- a/docs/MAP_TEMPLATE.md
+++ b/docs/MAP_TEMPLATE.md
@@ -5,7 +5,7 @@
## Границы ответственности
- Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями.
-- Map Scene Fixture задаёт минимальный проверочный набор пространственных сущностей и состояний.
+- Empty Map Scene Fixture проверяет shell без каких-либо shipped domain-объектов.
- Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility.
- Platform capability binding связывает слоты шаблона с scoped data products через Foundry runtime BFF.
- Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer.
@@ -26,14 +26,30 @@ Engine/NDC остаётся средой создания и исполнени
- selection;
- ready/loading/empty/stale/error/offline states.
-Размеры и внешний вид разновидностей сущностей задаются ссылками на `styleProfiles`. Это позволяет одному доменному типу иметь разные подтверждённые варианты, не создавая новый renderer-specific тип.
+Размеры и внешний вид разновидностей сущностей задаются ссылками на
+`styleProfiles`. Для live entity-stream Application page хранит отдельные
+versioned `presentationProfiles`, а binding выбирает один из них через
+`presentationProfileId`. Это позволяет одному доменному типу иметь разные
+подтверждённые варианты, не создавая новый renderer-specific тип.
+
+Канонический moving-object профиль находится в
+`registry/map-presentation-profiles.json`. Он задаёт универсальную геометрию
+таргета, плашку подписи, LOD и два пользовательских status facets: связь и
+движение. Для текущего Gelios binding значения и подписи повторяют официальный
+статусный набор: `Онлайн`, `Офлайн`, `В движении`, `Стоят`. Position
+quality и freshness остаются внутренними фактами качества и не дублируют
+пользовательские фильтры. Классы, фильтры, счётчики и sort order читают один
+profile; renderer не содержит provider-specific условий.
+
+Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout.
+
+Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
## Acceptance fixtures
-- `registry/fixtures/map/map-operational-v0.1.json` — минимальная рабочая сцена с сеткой, транспортом, станциями, маршрутом, железнодорожным путём, зоной и selection.
- `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий.
-Fixtures малы и детерминированы. Большие геоданные, tile cache, credentials и реальные streaming snapshots в репозиторий гайдлайнов не входят.
+Page/Visual/Application previews не содержат shipped domain fixtures. Объекты появляются только из declared Application bindings. Большие геоданные, tile cache, credentials и реальные streaming snapshots в репозиторий гайдлайнов не входят.
## Sandbox credit overlay
@@ -41,7 +57,7 @@ Fixtures малы и детерминированы. Большие геодан
## Cesium adapter 1.143
-Текущий reference adapter использует CesiumJS `1.143.0`. Он загружается отдельным lazy chunk только при открытии Map Page и преобразует fixture в реальные Cesium entities: points, labels, routes, tracks, zones и selection. Без настроенного ion gateway используется development fallback `WGS84 globe + OpenStreetMap imagery`.
+Текущий reference adapter использует CesiumJS `1.143.0`. Он загружается отдельным lazy chunk только при открытии Map Page и преобразует только declared Application bindings в реальные Cesium entities. Пустая Map Page не содержит domain entities.
Server-side runtime contract:
@@ -82,7 +98,8 @@ Runtime tile cache не является исходным кодом и не х
## Live data-product runtime
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
-semantic types, field projection и `slotId` (`points` для live point entities).
+semantic types, field projection, `presentationProfileId` и `slotId` (`points`
+для live point entities).
При открытии Application page browser делает same-origin запрос к Foundry:
```text
@@ -94,6 +111,9 @@ runtime. При первом exact consumer apply Foundry генерирует t
передаёт EDP только его digest в запросе с отдельной service-подписью; EDP сам
разрешает unique active writer scope и fail-closed отклоняет ambiguity. Legacy
root-owned deployment directory используется только как совместимый fallback.
+При смене versioned Data Product Foundry создаёт successor reader generation,
+bootstrap-ит новый snapshot и только затем отзывает predecessor; существующий
+grant никогда не переписывается другим request hash внутри одного поколения.
Browser, manifest и Cesium adapter не получают provider
endpoint, tenant/connection scope, reader token или raw provider payload.
Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch
@@ -104,3 +124,13 @@ history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
на binding и обновляет stable entity id без пересоздания viewer. Sampling и
retention остаются политикой Data Plane, а не Map Template.
+
+`fleet.positions.current.v2@2.0.0` является первым state-aware moving-object
+контрактом. Пользовательский status contract ограничен
+`availability_state=online|offline` и `motion_state=moving|stationary`;
+границы берутся из настроек объекта Gelios (`lostConnectionTimeValue` и
+`minimumMovementSpeed`), а не из придуманных Foundry thresholds. Технические
+`position_state`, `freshness_state` и `state_policy_version` могут оставаться в
+факте для quality/diagnostics, но не образуют дополнительные UI-фильтры. Цвет,
+геометрия таргета, label и порядок фильтров остаются в Foundry presentation
+profile.
diff --git a/docs/MODULE_FOUNDRY_MCP.md b/docs/MODULE_FOUNDRY_MCP.md
index b190ba7..c4163ef 100644
--- a/docs/MODULE_FOUNDRY_MCP.md
+++ b/docs/MODULE_FOUNDRY_MCP.md
@@ -15,6 +15,8 @@ MCP не вводит новую оркестрацию. Доступ выдаё
| Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс |
| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, provider API или произвольный внешний endpoint из Foundry MCP |
| Создавать/обновлять provider-neutral map pin bindings | Хранить provider tokens, transport payload или credentials в manifest |
+| Создавать/обновлять versioned provider-neutral `map.style_profile` | Хардкодить provider-specific statuses, цвета или размеры в renderer |
+| Задавать text label/order binding и сохранять полный Map view/window layout по `bindingId` | Использовать редактируемое имя как machine identity или превращать empty selection в all |
| Связывать versioned data product с approved Map entity-stream slot | Хранить provider ID, tenant/connection, endpoint или credential в data binding |
| Управлять server-owned consumer только для persisted approved binding | Передавать reader capability, EDP URL или raw provider payload через MCP |
@@ -55,6 +57,10 @@ Endpoint поддерживает два существующих контура
- `foundry_update_application_metadata`
- `foundry_add_page_instance`
- `foundry_upsert_map_pin_binding`
+- `foundry_remove_map_pin_binding`
+- `foundry_upsert_map_presentation_profile`
+- `foundry_update_map_page_settings`
+- `foundry_save_map_page_view_state`
- `foundry_upsert_map_data_product_binding`
- `foundry_plan_map_data_product_consumer`
- `foundry_apply_map_data_product_consumer`
@@ -62,11 +68,43 @@ Endpoint поддерживает два существующих контура
- `foundry_accept_map_data_product_consumer`
- `foundry_rollback_map_data_product_consumer`
-`foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции: визуальная привязка и поток данных будут связываться следующими contract/ontology слоями.
+`foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции.
+
+`foundry_remove_map_pin_binding` удаляет одну устаревшую визуальную привязку по
+стабильному `bindingId`. Операция не затрагивает data-product consumer, его
+последний безопасный snapshot или канонический шаблон `Page Library`.
+
+`foundry_upsert_map_presentation_profile` управляет отдельным версионированным
+`map.style_profile` страницы. Профиль содержит renderer-neutral геометрию
+таргета, label contract, semantic styles, правила классов, facet filters,
+counters и sort order. Условия классов могут ссылаться только на объявленные
+provider-neutral facet fields. Data Product binding выбирает профиль через
+`presentationProfileId`; binding также может задавать пользовательский
+`displayName` и порядок строки в dropdown `Объекты`. Provider raw status,
+endpoint и credential в профиль не допускаются. Полная machine-readable JSON Schema профиля публикуется прямо
+в `tools/list`: label использует `mode`, `fields`, плашку, `fontWeight` и
+`sizePx`, а target,
+facets, styles, classes и sort имеют закрытые наборы полей. Поэтому новый MCP
+клиент может создать профиль для другого spatial domain без чтения исходников
+Foundry и без неописанного generic JSON.
+
+`foundry_update_map_page_settings` меняет подложку, terrain, здания, атмосферу,
+освещение и сетку только у указанного экземпляра Map внутри `Application`.
+Операция не пишет в `Page Library`, принимает закрытый typed patch и поэтому
+может безопасно воспроизводить визуальное окружение через MCP.
+
+`foundry_save_map_page_view_state` является MCP-эквивалентом одной Application
+кнопки `Сохранить`. Операция атомарно сохраняет camera/map height, typed patch
+base settings, exact visibility/facet selections и все canonical binding
+windows (`open`, `rect`, `maximized`, `zIndex`). Состояние keyed только по
+`bindingId`: editable `displayName` не участвует в identity. Missing facet не
+ограничивает выборку, явно пустой список остаётся empty после reopen и не
+мигрирует обратно в `Все`.
`foundry_upsert_map_data_product_binding` сохраняет только декларацию
`data product → Map entity-stream slot`: versioned data product id, semantic
-types и допустимую field projection. Endpoint, provider, tenant, connection,
+types, допустимую field projection и необязательную ссылку на существующий
+presentation profile. Endpoint, provider, tenant, connection,
token, credential и raw payload валидатор отклоняет. Page runtime получает
scoped snapshot/patch поток через Platform, но не через Foundry MCP.
@@ -82,6 +120,15 @@ target-scoped token, коммитит snapshot и включает consumer; `st
сохраняет последний safe snapshot. Capability value, grant path, internal URL и
fact attributes в MCP diagnostics не возвращаются.
+Reader grant версионируется поколениями. При замене Data Product Foundry не
+перезаписывает immutable request generation и не ослабляет EDP conflict fence:
+он выпускает отдельный successor token/generation, проверяет им новый catalog и
+коммитит новый snapshot. Только после успешного snapshot predecessor generation
+отзывается exact revoke. Если bootstrap не удался, persisted predecessor
+snapshot и его grant остаются рабочими. Если временно не удался только revoke,
+следующий exact plan получает action `finalize-reader-grant-rotation` и завершает
+отзыв без повторного bootstrap.
+
## Runtime data-product boundary
Для Map Page Foundry предоставляет только same-origin runtime routes:
@@ -93,8 +140,10 @@ GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/stre
```
Маршрут разрешает persisted binding, а затем server-owned consumer находит
-opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)`. Нормальный
-grant создаётся внутри persistent private runtime Foundry; runner монтирует
+opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)` и persisted
+active generation. Generation 1 сохраняет совместимый legacy filename, а
+successor capabilities хранятся отдельными root-owned immutable files.
+Нормальный grant создаётся внутри persistent private runtime Foundry; runner монтирует
отдельный Ed25519 private key только для подписи digest-only provisioner
request, а EDP получает только public trust. Старый root-owned read-only grant
directory остаётся fallback для уже выданных grant. Сам token передаётся
@@ -121,7 +170,9 @@ Freshness и remove принадлежат versioned provider-neutral policy и
ошибка не удаляет subject. Удаление допустимо только при отсутствии в
авторитетном snapshot rebase или по canonical `tombstone`/`revoked` operation.
Renderer получает стабильный `sourceId + semanticType`, persisted coordinates
-и Foundry-owned `presentationStatus`; provider identity на стиль и lifecycle не
+и Foundry-owned `presentationStatus`. Операционные visual classes, фильтры,
+счётчики и сортировка разрешаются отдельно из orthogonal state facets и
+page-owned `map.style_profile`; provider identity на стиль и lifecycle не
влияет.
## Внешний Codex: Foundry + отдельная Ontology MCP
@@ -246,7 +297,7 @@ curl http://127.0.0.1:9920/healthz
## Следующие слои
1. Онтологический contract приложения, страницы, page instance, Map Page и visual entities.
-2. Runtime adapter между ontology/gateway потоками и сохранёнными visual bindings.
-3. Реальные `elevated-spike`, targets, zones, routes и toolbar commands на instance Map Page.
+2. Расширение runtime adapter с points на targets, zones, routes и tracks.
+3. Управляемая публикация и повторное использование presentation profiles между Applications.
4. Access/Hub registration и публикация module route.
5. Отдельная пустая программируемая страница — только после того, как готовые page templates и их MCP-контуры отработаны.
diff --git a/docs/MODULE_STUDIO.md b/docs/MODULE_STUDIO.md
index 749cc46..d1b8c81 100644
--- a/docs/MODULE_STUDIO.md
+++ b/docs/MODULE_STUDIO.md
@@ -20,9 +20,7 @@
Каноническая JSON Schema: `registry/schemas/map-scene-fixture-v0.1.schema.json`. Проверочные сцены перечислены прямо в контракте `map@0.1.0` и лежат в `registry/fixtures/map`.
-Fixture фиксирует provider-neutral viewport, capabilities, общие style profiles, слои, города, движущиеся объекты, станции, маршруты, пути, зоны, selection и ожидаемое поведение. Он не хранит Cesium types, токены, renderer instances, Engine workflow или transport payload конкретного источника.
-
-`map-operational-v0.1.json` проверяет содержательную сцену и переиспользуемый язык подписей/маркеров. `map-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Registry validation дополнительно проверяет уникальность id, ссылки на style profiles, selection и непрерывность LOD-диапазонов.
+`map-empty-offline-v0.1.json` проверяет, что shell и системные действия остаются работоспособными без live-данных. Operational demo fixture удалён: Visual Library, Page Library и Applications не поставляют города, движущиеся объекты, станции, маршруты, пути, зоны или selection. Domain entities появляются только из declared Application bindings.
LOD вынесен в отдельный контракт `registry/schemas/map-lod-policy-v0.1.schema.json` и policy set `registry/fixtures/map/map-lod-policies-v0.1.json`. Он использует расстояние камеры в метрах, полуоткрытые диапазоны и hysteresis, поэтому не зависит от Cesium `DistanceDisplayCondition`. Дальнейшая калибровка bands выполняется на живом Map shell, а не в отрыве от визуального результата.
@@ -40,7 +38,7 @@ Manifest фиксирует:
- favicon source;
- server-controlled timestamps.
-Manifest не хранит runtime credentials, capability bindings, arbitrary component tree, свободные координаты элементов или deployment secrets.
+Manifest не хранит runtime credentials, arbitrary component tree или deployment secrets. Map page instance хранит provider-neutral capability bindings и контролируемый view state: camera, base settings, exact facet selection и геометрию canonical binding windows, keyed by stable `bindingId`.
## Draft lifecycle
@@ -72,6 +70,10 @@ Catalog server предоставляет минимальный временн
Visual Library использует отдельный Hub-style selector профилей. Общая кнопка Save открывает каноническую модалку `Сохранить / Сохранить как новый / Опубликовать`. Приложение хранит pinned `profile id + version + status + theme`; media, favicon и material settings принадлежат Design Profile.
+Design Profile также хранит независимые design-only фрагменты по зарегистрированному типу страницы (`pageTypes["map@0.1.0"]`). Для Map в такой фрагмент входят только `settings` и provider-neutral `presentationProfiles`. Camera, высота рабочего окна, pin/data-product bindings, subjects, endpoints и credentials остаются состоянием Application/runtime и никогда не попадают в профиль.
+
+Кнопка Save в Page Library не изменяет канонический Page Template: она открывает ту же модалку Design Profile и обновляет только фрагмент текущего типа страницы. Сохранение следующего типа страницы сливает его фрагмент в тот же профиль, не перезаписывая глобальные поля и ранее сохранённые типы. Application разрешает визуальное состояние в порядке `canonical page defaults → pinned Design Profile page fragment → Application designOverrides`; runtime bindings при этом сохраняются отдельно.
+
`Draft` — изменяемая рабочая голова профиля. Каждое сохранение увеличивает patch-версию. `Published` — неизменяемый снимок конкретной версии: повторная публикация той же версии запрещена. Application Manifest может ссылаться на draft для внутренней разработки, но стабильный модуль должен фиксировать published release. Изменение будущего draft или публикация следующей версии не меняют уже закреплённое приложение.
Временный catalog server предоставляет:
@@ -87,13 +89,15 @@ Visual Library использует отдельный Hub-style selector про
Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта.
+Все save/update/publish операции показывают канонический `ToastStack` снизу справа. Loading-state обновляется в success/error в том же toast; blocking confirmation остаётся отдельной modal-механикой.
+
## Baseline MCP
Foundry предоставляет базовый MCP-контур для уже существующего сквозного AI Workspace Assistant. Он не создаёт отдельную оркестрацию: штатный entitlement adapter выдаёт доступ к Foundry только авторизованному пользователю, после чего ассистент получает ограниченный набор инструментов.
- `Page Library` и канонические шаблоны доступны только на чтение;
- изменяются только экземпляры в `Applications`;
-- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы и upsert provider-neutral map pin bindings;
+- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы, upsert provider-neutral map pin bindings, versioned Map presentation profiles и data-product bindings;
- удаление модуля через MCP намеренно отсутствует;
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.
diff --git a/package.json b/package.json
index 2ef052c..f185aee 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"test:reader-grant-provisioner": "node --test server/foundry-reader-grant-provisioner.test.mjs",
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
+ "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
},
diff --git a/packages/page-patterns/src/index.ts b/packages/page-patterns/src/index.ts
index 2182402..d70694b 100644
--- a/packages/page-patterns/src/index.ts
+++ b/packages/page-patterns/src/index.ts
@@ -86,10 +86,7 @@ export const mapPageTemplate = {
],
contracts: {
fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json",
- acceptanceFixtures: [
- "registry/fixtures/map/map-operational-v0.1.json",
- "registry/fixtures/map/map-empty-offline-v0.1.json",
- ],
+ acceptanceFixtures: ["registry/fixtures/map/map-empty-offline-v0.1.json"],
lodPolicySchema: "registry/schemas/map-lod-policy-v0.1.schema.json",
lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json",
},
diff --git a/packages/tokens/themes.css b/packages/tokens/themes.css
index 1f5c999..80e0a9b 100644
--- a/packages/tokens/themes.css
+++ b/packages/tokens/themes.css
@@ -31,6 +31,12 @@
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
+ /* Map overlays stay light over imagery in every application theme. */
+ --nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
+ --nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
+ --nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
+ --nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
+ --nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
--nodedc-glass-rim: transparent;
@@ -96,6 +102,12 @@
--nodedc-glass-control-hover: #f8f8f8;
--nodedc-glass-control-active: rgba(32, 32, 34, 0.96);
--nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96);
+ /* Map overlays are theme-independent because they sit on live imagery. */
+ --nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
+ --nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
+ --nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
+ --nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
+ --nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
--nodedc-glass-rim: transparent;
diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css
index f701267..760a2cd 100644
--- a/packages/ui-core/styles.css
+++ b/packages/ui-core/styles.css
@@ -85,6 +85,24 @@
padding: var(--nodedc-space-6);
}
+.nodedc-map-glass,
+.nodedc-glass.nodedc-map-glass {
+ border: 0;
+ background: var(--nodedc-map-glass-bg);
+ color: var(--nodedc-map-glass-text);
+ box-shadow: var(--nodedc-glass-dropdown-shadow);
+ backdrop-filter: blur(var(--nodedc-blur-control));
+ -webkit-backdrop-filter: blur(var(--nodedc-blur-control));
+}
+
+.nodedc-dropdown-surface.nodedc-map-glass {
+ background: var(--nodedc-map-glass-bg);
+ color: var(--nodedc-map-glass-text);
+ box-shadow: var(--nodedc-glass-dropdown-shadow);
+ backdrop-filter: blur(var(--nodedc-blur-control));
+ -webkit-backdrop-filter: blur(var(--nodedc-blur-control));
+}
+
.nodedc-button {
--nodedc-button-bg: var(--nodedc-glass-control-bg);
--nodedc-button-color: var(--nodedc-text-primary);
@@ -2112,6 +2130,83 @@ textarea.nodedc-field__control {
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
+.nodedc-toast-viewport {
+ position: fixed;
+ z-index: 1600;
+ right: max(1rem, env(safe-area-inset-right));
+ bottom: max(1rem, env(safe-area-inset-bottom));
+ display: grid;
+ width: min(24rem, calc(100vw - 2rem));
+ gap: 0.55rem;
+ pointer-events: none;
+}
+
+.nodedc-toast {
+ position: relative;
+ isolation: isolate;
+ display: grid;
+ min-height: 4.4rem;
+ grid-template-columns: 2.45rem minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.72rem;
+ overflow: hidden;
+ border-radius: var(--nodedc-radius-control);
+ padding: 0.72rem;
+ pointer-events: auto;
+ animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
+}
+
+.nodedc-toast__icon {
+ display: grid;
+ width: 2.45rem;
+ height: 2.45rem;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--nodedc-glass-control-bg);
+ color: var(--nodedc-text-secondary);
+ box-shadow: var(--nodedc-glass-control-shadow);
+}
+
+.nodedc-toast[data-tone="success"] .nodedc-toast__icon { color: rgb(var(--nodedc-success-rgb)); }
+.nodedc-toast[data-tone="error"] .nodedc-toast__icon { color: rgb(var(--nodedc-danger-rgb)); }
+.nodedc-toast[data-tone="warning"] .nodedc-toast__icon { color: rgb(var(--nodedc-warning-rgb)); }
+.nodedc-toast[data-tone="info"] .nodedc-toast__icon { color: var(--nodedc-accent); }
+.nodedc-toast[data-tone="loading"] .nodedc-toast__icon svg { animation: nodedc-toast-spin 900ms linear infinite; }
+
+.nodedc-toast__copy {
+ display: grid;
+ min-width: 0;
+ gap: 0.18rem;
+}
+
+.nodedc-toast__copy strong {
+ color: var(--nodedc-text-primary);
+ font-size: var(--nodedc-font-size-sm);
+ line-height: 1.25;
+}
+
+.nodedc-toast__copy small {
+ color: var(--nodedc-text-muted);
+ font-size: var(--nodedc-font-size-xs);
+ line-height: 1.35;
+}
+
+.nodedc-toast__dismiss {
+ display: grid;
+ width: 2rem;
+ height: 2rem;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--nodedc-text-muted);
+ cursor: pointer;
+}
+
+.nodedc-toast__dismiss:hover { background: var(--nodedc-glass-control-hover); color: var(--nodedc-text-primary); }
+
+@keyframes nodedc-toast-spin { to { transform: rotate(360deg); } }
+
.nodedc-window[data-size="sm"] {
width: min(28rem, calc(100vw - 2rem));
}
diff --git a/packages/ui-react/src/Button.tsx b/packages/ui-react/src/Button.tsx
index 8fd95c2..49a4962 100644
--- a/packages/ui-react/src/Button.tsx
+++ b/packages/ui-react/src/Button.tsx
@@ -52,16 +52,17 @@ export interface IconButtonProps extends ButtonHTMLAttributes
shape?: "circle" | "rounded";
}
-export function IconButton({
+export const IconButton = forwardRef(function IconButton({
label,
shape = "circle",
className,
children,
type = "button",
...props
-}: IconButtonProps) {
+}, ref) {
return (
;
}
+
+export interface MapGlassSurfaceProps extends HTMLAttributes