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 {