fix(map): canonicalize facet visibility and compact header

This commit is contained in:
Codex
2026-08-09 15:48:23 +03:00
parent 71c1528b83
commit bfa3af896b
8 changed files with 213 additions and 91 deletions
+6 -4
View File
@@ -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" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : undefined}
headerTools={(
<div className="catalog-application-header-tools">
<SegmentedControl label="Режим модуля" value={applicationMode} items={[{ value: "edit", label: "Настройка" }, { value: "preview", label: "Предпросмотр" }]} onChange={setApplicationMode} />
{activeApplicationPage?.template.id === "map" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : null}
</div>
)}
utilityActions={[{
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
icon: "save",
@@ -2103,9 +2108,6 @@ export function CatalogApp() {
onClose={workspace.closeView}
>
<div className="catalog-panel-content">
<div className="catalog-module-mode">
<SegmentedControl label="Режим модуля" value={applicationMode} items={[{ value: "edit", label: "Настройка" }, { value: "preview", label: "Предпросмотр" }]} onChange={setApplicationMode} />
</div>
{activeApplicationPage ? renderApplicationPage() : renderApplicationDraft()}
</div>
</ApplicationPanel>
+44 -14
View File
@@ -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<MapFixturePreviewHandle, {
initialMapReferenceLayers(initialLayout?.referenceLayers)
));
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates, presentationProfiles)
));
const presentationFilters = useMemo<MapPresentationFilters>(() => 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<MapFixturePreviewHandle, {
window: defaultSubjectWindowState(0),
}).map((state) => {
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<MapFixturePreviewHandle, {
});
}, [dataProductBindings]);
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
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<MapFixturePreviewHandle, {
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = state.filters[facet.field]?.includes(item.value) ?? false;
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<MapFixturePreviewHandle, {
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
onClick={() => togglePresentationFilter(
summary.bindingId,
facet.field,
item.value,
facet.values.map((value) => value.value),
)}
>
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
+42 -28
View File
@@ -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<string, string[]>,
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<string, string[]>,
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<string, string[]>,
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(
+3 -2
View File
@@ -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 {
+1 -1
View File
@@ -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",
@@ -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/);
+11
View File
@@ -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");
+104 -42
View File
@@ -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"],
});
});