diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx
index ea1d001..5840264 100644
--- a/apps/catalog/src/CatalogApp.tsx
+++ b/apps/catalog/src/CatalogApp.tsx
@@ -2089,7 +2089,12 @@ export function CatalogApp() {
description={activeApplicationPage ? `${activeApplication.metadata.name} · ${activeApplicationPage.template.id}@${activeApplicationPage.template.version}` : `/${activeApplication.metadata.slug} · manifest ${activeApplication.schemaVersion}`}
expanded={panelExpanded}
onExpandedChange={workspace.setContentExpanded}
- headerTools={activeApplicationPage?.template.id === "map" ?
: undefined}
+ headerTools={(
+
+
+ {activeApplicationPage?.template.id === "map" ?
: null}
+
+ )}
utilityActions={[{
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
icon: "save",
@@ -2103,9 +2108,6 @@ export function CatalogApp() {
onClose={workspace.closeView}
>
-
-
-
{activeApplicationPage ? renderApplicationPage() : renderApplicationDraft()}
diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx
index b5a9e45..26e2d47 100644
--- a/apps/catalog/src/MapFixturePreview.tsx
+++ b/apps/catalog/src/MapFixturePreview.tsx
@@ -17,10 +17,12 @@ import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
+ mapPresentationFacetValueIsEnabled,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
+ normalizeMapPresentationFacetSelections,
normalizeClientMapPresentationProfiles,
resolveMapPresentationClass,
toggleMapPresentationFacetSelection,
@@ -665,11 +667,23 @@ const defaultSubjectCardRect: WorkspaceWindowRect = {
height: 520,
};
-function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
+function initialSubjectState(
+ bindings: MapDataProductBinding[],
+ saved: MapSubjectState[] | undefined,
+ profiles: MapPresentationProfile[],
+) {
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 ?? {
+ const profile = mapPresentationProfileForFact(
+ profiles,
+ binding.presentationProfileId,
+ binding.semanticTypes[0] ?? "",
+ );
+ return [binding.id, state ? {
+ ...state,
+ filters: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
+ } : {
bindingId: binding.id,
visible: true,
filters: {},
@@ -796,14 +810,22 @@ export const MapFixturePreview = forwardRef>(() => (
- initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
+ initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates, presentationProfiles)
));
const presentationFilters = useMemo(() => Object.fromEntries(
- Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
- visible: state.visible,
- facets: state.filters,
- }]),
- ), [subjectStates]);
+ Object.entries(subjectStates).map(([bindingId, state]) => {
+ const binding = dataProductBindings.find((candidate) => candidate.id === bindingId);
+ const profile = mapPresentationProfileForFact(
+ presentationProfiles,
+ binding?.presentationProfileId,
+ binding?.semanticTypes[0] ?? "",
+ );
+ return [bindingId, {
+ visible: state.visible,
+ facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
+ }];
+ }),
+ ), [dataProductBindings, presentationProfiles, subjectStates]);
const runtimeBindings = useMapDataProductRuntime({
applicationId,
pageId,
@@ -1339,9 +1361,12 @@ export const MapFixturePreview = forwardRef {
const summary = presentationSummaries.find((candidate) => candidate.bindingId === state.bindingId);
- return summary && !hasSubjectWindowControls(summary.profile)
- ? { ...state, window: { ...state.window, open: false } }
+ const normalizedState = summary
+ ? { ...state, filters: normalizeMapPresentationFacetSelections(state.filters, summary.profile) }
: state;
+ return summary && !hasSubjectWindowControls(summary.profile)
+ ? { ...normalizedState, window: { ...normalizedState.window, open: false } }
+ : normalizedState;
}),
}),
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
@@ -1359,13 +1384,13 @@ export const MapFixturePreview = forwardRef {
+ const togglePresentationFilter = (bindingId: string, field: string, value: string, availableValues: string[]) => {
updateSubjectState(bindingId, (state) => {
const filters = state.visible ? state.filters : {};
return {
...state,
visible: true,
- filters: toggleMapPresentationFacetSelection(filters, field, value),
+ filters: toggleMapPresentationFacetSelection(filters, field, value, availableValues),
};
});
};
@@ -2619,7 +2644,7 @@ export const MapFixturePreview = forwardRef
{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;
+ const active = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value);
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
@@ -2644,7 +2669,12 @@ export const MapFixturePreview = forwardRef togglePresentationFilter(summary.bindingId, facet.field, item.value)}
+ onClick={() => togglePresentationFilter(
+ summary.bindingId,
+ facet.field,
+ item.value,
+ facet.values.map((value) => value.value),
+ )}
>
{item.label}
{summary.counts[facet.field]?.[item.value] ?? 0}
diff --git a/apps/catalog/src/mapPresentationProfile.ts b/apps/catalog/src/mapPresentationProfile.ts
index cae34a7..e522cda 100644
--- a/apps/catalog/src/mapPresentationProfile.ts
+++ b/apps/catalog/src/mapPresentationProfile.ts
@@ -105,42 +105,56 @@ export function mapPresentationBindingIsAll(bindingId: string, filters: MapPrese
}
/**
- * Apply one interactive facet-chip transition without collapsing the storage
- * contract. A missing field means that facet is not part of the current union,
- * while one explicitly empty field represents the intentional match-nothing
- * view after the final selected chip is switched off.
+ * A missing facet is the compact canonical representation of every configured
+ * value being enabled. An explicit array is the exact enabled subset; an empty
+ * array therefore remains an intentional match-nothing state.
*/
-export function toggleMapPresentationFacetSelection(
+export function mapPresentationFacetValueIsEnabled(
facets: Record,
field: string,
value: string,
) {
const selected = facets[field];
- if (!selected?.includes(value)) {
- // A saved empty view can contain one or more empty facet arrays. They are
- // a global zero-match sentinel, not active constraints, so selecting the
- // first chip must replace them instead of leaving a hidden empty array that
- // would continue to suppress every fact.
- const activeFacets = Object.fromEntries(
- Object.entries(facets).filter(([, values]) => values.length > 0),
- );
- return { ...activeFacets, [field]: [...(activeFacets[field] ?? []), value] };
- }
+ return selected === undefined || selected.includes(value);
+}
- const nextSelected = selected.filter((item) => item !== value);
- if (nextSelected.length > 0) return { ...facets, [field]: nextSelected };
+export function normalizeMapPresentationFacetSelections(
+ facets: Record,
+ profile: MapPresentationProfile,
+) {
+ return Object.fromEntries(profile.facets.flatMap((facet) => {
+ const selected = facets[facet.field];
+ if (selected === undefined) return [];
+ const availableValues = [...new Set(facet.values.map((item) => item.value))];
+ const enabledValues = availableValues.filter((value) => selected.includes(value));
+ // Legacy layouts could persist every value explicitly. Canonicalize that
+ // shape to an unconstrained facet so scoped facets (for example movement
+ // on online subjects) cannot accidentally suppress unrelated subjects.
+ return availableValues.length > 0 && enabledValues.length === availableValues.length
+ ? []
+ : [[facet.field, enabledValues] as const];
+ }));
+}
- const { [field]: _removed, ...remaining } = facets;
- const activeFacets = Object.fromEntries(
- Object.entries(remaining).filter(([, values]) => values.length > 0),
- );
- if (Object.keys(activeFacets).length > 0) return activeFacets;
-
- // Never collapse the last interactive deselection to `{}`: the renderer
- // correctly interprets `{}` as unconstrained/all. Retain an explicit empty
- // facet so the zero-selection state remains zero matches through any toggle
- // cycle and through a later Application Save.
- return { [field]: [] };
+export function toggleMapPresentationFacetSelection(
+ facets: Record,
+ field: string,
+ value: string,
+ availableValues: string[],
+) {
+ const values = [...new Set(availableValues)];
+ if (!values.includes(value)) return facets;
+ const selected = facets[field];
+ const enabled = new Set(selected === undefined
+ ? values
+ : values.filter((item) => selected.includes(item)));
+ if (enabled.has(value)) enabled.delete(value);
+ else enabled.add(value);
+ const nextEnabled = values.filter((item) => enabled.has(item));
+ const next = { ...facets };
+ if (nextEnabled.length === values.length) delete next[field];
+ else next[field] = nextEnabled;
+ return next;
}
export function mapPresentationProfileForFact(
diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css
index 1f8a39d..e63ba46 100644
--- a/apps/catalog/src/styles.css
+++ b/apps/catalog/src/styles.css
@@ -377,9 +377,10 @@ textarea {
padding-right: 1.25rem;
}
-.catalog-module-mode {
+.catalog-application-header-tools {
display: flex;
- justify-content: flex-end;
+ align-items: center;
+ gap: 0.45rem;
}
.catalog-module-preview {
diff --git a/package.json b/package.json
index f45f73e..df06e45 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
- "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
+ "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs",
diff --git a/scripts/map-inspector-overlay-state.test.mjs b/scripts/map-inspector-overlay-state.test.mjs
index b87524e..9ddf108 100644
--- a/scripts/map-inspector-overlay-state.test.mjs
+++ b/scripts/map-inspector-overlay-state.test.mjs
@@ -61,6 +61,8 @@ test("Map settings use a canonical trailing push panel and ApplicationPanel head
assert.doesNotMatch(preview, /settingsPanelPinned|onPointerDownCapture/);
assert.match(catalog, /endPanelOpen=\{mapSettingsPanelOpen\}/);
assert.match(catalog, /headerTools=\{activePageTemplate\.id === "map"[\s\S]*?setMapHeaderActionsHost/);
+ assert.match(catalog, /className="catalog-application-header-tools"[\s\S]*?label="Режим модуля"[\s\S]*?setMapHeaderActionsHost/);
+ assert.doesNotMatch(catalog, /className="catalog-module-mode"/);
assert.match(styles, /data-content-expanded="true"\]\[data-end-panel-open="true"\][\s\S]*?right: calc\(/);
assert.match(styles, /@keyframes nodedc-application-side-panel-in[\s\S]*?translateX\(1\.6rem\)/);
assert.match(catalogStyles, /\.catalog-map-header-action\.nodedc-icon-button[\s\S]*?--nodedc-panel-action-bg/);
diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs
index 2f1934c..7b83744 100644
--- a/scripts/map-object-layers.test.mjs
+++ b/scripts/map-object-layers.test.mjs
@@ -29,6 +29,17 @@ test("hidden and visible layers have an explicit visual state", async () => {
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
});
+test("facet controls and renderer consume the same canonical enabled-value state", async () => {
+ const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
+
+ assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/);
+ assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/);
+ assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/);
+ assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
+ assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/);
+ assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
+});
+
test("joined detail aspects do not become independent map layers", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
diff --git a/scripts/map-presentation-filters.test.mjs b/scripts/map-presentation-filters.test.mjs
index a044589..29e89d3 100644
--- a/scripts/map-presentation-filters.test.mjs
+++ b/scripts/map-presentation-filters.test.mjs
@@ -12,6 +12,8 @@ const {
mapFactMatchesFilters,
mapPresentationBindingIsAll,
mapPresentationFacetCounts,
+ mapPresentationFacetValueIsEnabled,
+ normalizeMapPresentationFacetSelections,
toggleMapPresentationFacetSelection,
} = await import(moduleUrl);
@@ -35,6 +37,8 @@ const onlineMoving = {
test("missing subject state is explicit all for that binding", () => {
assert.equal(mapPresentationBindingIsAll("fleet", {}), true);
assert.equal(mapFactMatchesFilters(onlineMoving, profile, {}, "fleet"), true);
+ assert.equal(mapPresentationFacetValueIsEnabled({}, "signal_state", "active"), true);
+ assert.equal(mapPresentationFacetValueIsEnabled({}, "signal_state", "inactive"), true);
});
test("a selected chip restricts only its own facet", () => {
@@ -49,62 +53,120 @@ test("deselecting the last chip remains empty and never normalizes to all", () =
assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false);
});
-test("interactive deselect of the last chip preserves the zero-match state", () => {
- const selected = toggleMapPresentationFacetSelection({}, "signal_state", "active");
- assert.deepEqual(selected, { signal_state: ["active"] });
+test("toggling from implicit all disables the clicked value instead of selecting only it", () => {
+ const facets = toggleMapPresentationFacetSelection(
+ {},
+ "signal_state",
+ "active",
+ ["active", "inactive"],
+ );
- const empty = toggleMapPresentationFacetSelection(selected, "signal_state", "active");
- const filters = { fleet: { visible: true, facets: empty } };
- assert.deepEqual(empty, { signal_state: [] });
- assert.equal(mapPresentationBindingIsAll("fleet", filters), false);
- assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false);
-});
-
-test("the first chip selected after a saved empty view removes every empty sentinel", () => {
- const selected = toggleMapPresentationFacetSelection({
- signal_state: [],
- movement_state: [],
- }, "signal_state", "active");
-
- assert.deepEqual(selected, { signal_state: ["active"] });
+ assert.deepEqual(facets, { signal_state: ["inactive"] });
+ assert.equal(mapPresentationFacetValueIsEnabled(facets, "signal_state", "active"), false);
+ assert.equal(mapPresentationFacetValueIsEnabled(facets, "signal_state", "inactive"), true);
assert.equal(mapFactMatchesFilters(onlineMoving, profile, {
- fleet: { visible: true, facets: selected },
- }, "fleet"), true);
+ fleet: { visible: true, facets },
+ }, "fleet"), false);
});
-test("a complete interactive toggle cycle returns to zero matches instead of all", () => {
- let facets = { signal_state: [], movement_state: [] };
- const toggle = (field, value) => {
- facets = toggleMapPresentationFacetSelection(facets, field, value);
- };
+test("turning off the last enabled value is explicit zero and can be re-enabled locally", () => {
+ const none = toggleMapPresentationFacetSelection(
+ { signal_state: ["inactive"] },
+ "signal_state",
+ "inactive",
+ ["active", "inactive"],
+ );
+ assert.deepEqual(none, { signal_state: [] });
+ assert.equal(mapFactMatchesFilters(onlineMoving, profile, {
+ fleet: { visible: true, facets: none },
+ }, "fleet"), false);
- toggle("signal_state", "active");
- toggle("signal_state", "inactive");
- toggle("movement_state", "moving");
- toggle("movement_state", "stopped");
- assert.deepEqual(facets, {
+ const active = toggleMapPresentationFacetSelection(
+ { ...none, movement_state: [] },
+ "signal_state",
+ "active",
+ ["active", "inactive"],
+ );
+ assert.deepEqual(active, { signal_state: ["active"], movement_state: [] });
+ assert.equal(mapPresentationFacetValueIsEnabled(active, "signal_state", "active"), true);
+ assert.equal(mapPresentationFacetValueIsEnabled(active, "movement_state", "moving"), false);
+});
+
+test("enabling the final disabled value canonicalizes that facet back to implicit all", () => {
+ const facets = toggleMapPresentationFacetSelection(
+ { signal_state: ["active"] },
+ "signal_state",
+ "inactive",
+ ["active", "inactive"],
+ );
+
+ assert.deepEqual(facets, {});
+ assert.equal(mapPresentationBindingIsAll("fleet", { fleet: { visible: true, facets } }), true);
+ assert.equal(mapPresentationFacetValueIsEnabled(facets, "signal_state", "active"), true);
+ assert.equal(mapPresentationFacetValueIsEnabled(facets, "signal_state", "inactive"), true);
+});
+
+test("legacy visually-all facets cannot keep suppressing offline subjects", () => {
+ const legacyFacets = {
signal_state: ["active", "inactive"],
movement_state: ["moving", "stopped"],
- });
+ };
+ const offlineStopped = {
+ attributes: { signal_state: "inactive", movement_state: "stopped" },
+ };
- toggle("signal_state", "active");
- toggle("signal_state", "inactive");
- toggle("movement_state", "moving");
- toggle("movement_state", "stopped");
+ assert.equal(mapPresentationFacetValueIsEnabled(legacyFacets, "signal_state", "inactive"), true);
+ assert.equal(mapPresentationFacetValueIsEnabled(legacyFacets, "movement_state", "stopped"), true);
+ assert.equal(mapFactMatchesFilters(
+ offlineStopped,
+ profile,
+ { fleet: { visible: true, facets: legacyFacets } },
+ "fleet",
+ ), false);
- const filters = { fleet: { visible: true, facets } };
- assert.deepEqual(facets, { movement_state: [] });
- assert.equal(mapPresentationBindingIsAll("fleet", filters), false);
- assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false);
+ const facets = normalizeMapPresentationFacetSelections(legacyFacets, profile);
+
+ assert.deepEqual(facets, {});
+ assert.equal(mapFactMatchesFilters(
+ offlineStopped,
+ profile,
+ { fleet: { visible: true, facets } },
+ "fleet",
+ ), true);
});
-test("interactive deselect preserves other values and facet constraints", () => {
+test("a full toggle cycle has distinct and recoverable all and none states", () => {
+ let facets = {};
+ const toggle = (value) => {
+ facets = toggleMapPresentationFacetSelection(
+ facets,
+ "signal_state",
+ value,
+ ["active", "inactive"],
+ );
+ };
+
+ toggle("active");
+ assert.deepEqual(facets, { signal_state: ["inactive"] });
+ toggle("inactive");
+ assert.deepEqual(facets, { signal_state: [] });
+ toggle("active");
+ assert.deepEqual(facets, { signal_state: ["active"] });
+ toggle("inactive");
+ assert.deepEqual(facets, {});
+});
+
+test("interactive toggle preserves independent facet constraints", () => {
const facets = {
- signal_state: ["active", "inactive"],
+ signal_state: ["active"],
movement_state: ["moving"],
};
- assert.deepEqual(toggleMapPresentationFacetSelection(facets, "signal_state", "active"), {
- signal_state: ["inactive"],
+ assert.deepEqual(toggleMapPresentationFacetSelection(
+ facets,
+ "signal_state",
+ "inactive",
+ ["active", "inactive"],
+ ), {
movement_state: ["moving"],
});
});