From bfa3af896be38e93178afd685ec833184d355379 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 15:48:23 +0300 Subject: [PATCH] fix(map): canonicalize facet visibility and compact header --- apps/catalog/src/CatalogApp.tsx | 10 +- apps/catalog/src/MapFixturePreview.tsx | 58 ++++++-- apps/catalog/src/mapPresentationProfile.ts | 70 +++++---- apps/catalog/src/styles.css | 5 +- package.json | 2 +- scripts/map-inspector-overlay-state.test.mjs | 2 + scripts/map-object-layers.test.mjs | 11 ++ scripts/map-presentation-filters.test.mjs | 146 +++++++++++++------ 8 files changed, 213 insertions(+), 91 deletions(-) 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"], }); });