feat(map): add reference layers and stabilize workspace controls

This commit is contained in:
Codex
2026-07-25 13:12:24 +03:00
parent c859ae4db0
commit eaecce9f56
29 changed files with 1593 additions and 247 deletions
+178 -49
View File
@@ -32,6 +32,13 @@ import {
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
import {
ensureMapReferencePresentationProfiles,
initialMapReferenceLayers,
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { useMapReferenceRuntime } from "./useMapReferenceRuntime.js";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -171,6 +178,8 @@ export type MapPageLayout = {
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string;
};
@@ -247,10 +256,12 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: [],
presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [],
subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
};
}
@@ -354,7 +365,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
initialLayout?.inspectorOpenSections ?? ["map-base"]
));
const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
@@ -388,7 +403,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Presentation profiles are application/page-owned, versioned map.style_profile
// values. A human camera/settings save must preserve profiles provisioned by MCP.
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []))
));
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
initialLayout?.subjectDetailProfiles?.length
@@ -399,6 +414,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [referenceLayers, setReferenceLayers] = useState<MapReferenceLayer[]>(() => (
initialMapReferenceLayers(initialLayout?.referenceLayers)
));
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
));
@@ -415,10 +433,23 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
const referencePresentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
), [referenceLayers]);
const rendererPresentationFilters = useMemo<MapPresentationFilters>(() => ({
...presentationFilters,
...referencePresentationFilters,
}), [presentationFilters, referencePresentationFilters]);
const primaryBindingIds = useMemo(() => new Set(
dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id),
), [dataProductBindings]);
const primaryRuntimeBindings = useMemo(() => (
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
), [primaryBindingIds, runtimeBindings]);
const selectable = useMemo(() => (
runtimeBindings.flatMap((binding) => {
primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
if (bindingConfig?.joinToBindingId) return [];
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
presentationProfiles,
@@ -440,8 +471,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
};
});
})
), [dataProductBindings, presentationProfiles, runtimeBindings]);
), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
@@ -457,7 +489,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
counts: mapPresentationFacetCounts(facts, profile),
}];
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => {
const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId);
if (!profile) return [];
const runtime = referenceRuntimeBindings.find((candidate) => candidate.bindingId === layer.id);
return [{
layer,
displayName: profile.title,
total: runtime?.facts.length ?? 0,
}];
}), [presentationProfiles, referenceLayers, referenceRuntimeBindings]);
const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length;
const filteredTargets = useMemo(() => primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
@@ -475,7 +518,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, primaryRuntimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
@@ -714,6 +757,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
presentationProfiles,
subjectDetailProfiles,
dataProductBindings,
referenceLayers,
inspectorOpenSections,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id,
visible: true,
@@ -726,7 +771,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
: state;
}),
}),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectDetailProfiles, subjectStates]);
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
@@ -811,7 +856,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? ""));
setSubjectCardTabId(profile?.defaultTabId ?? "overview");
setSubjectCardTabId((current) => (
profile?.tabs.some((tab) => tab.id === current)
? current
: (profile?.defaultTabId ?? "overview")
));
setSubjectCardOpen(true);
setSubjectCardActive(true);
setLayersWindowActive(false);
@@ -819,6 +868,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
mapRendererRef.current?.focusRuntimeEntity(entityId);
}, [handleSelect]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health;
setGatewayHealth(health);
@@ -1010,14 +1064,26 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
...presentationProfiles.flatMap((profile) => [
...presentationProfiles.flatMap((profile) => {
const referenceProfile = isMapReferencePresentationProfile(profile);
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
return [
{
id: `map-target-${profile.id}`,
label: profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
<Checker
checked={referenceLayer.visible}
label={`Показывать слой «${profile.title}»`}
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
layer.id === referenceLayer.id ? { ...layer, visible } : layer
)))}
/>
) : null}
{profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => {
@@ -1054,14 +1120,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
{profile.target.variant === "elevated-spike" && <>
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
</>}
</>,
},
...(profile.target.variant === "surface-fill" ? [] : [{
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
@@ -1078,7 +1139,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
})}
</>,
}]),
]),
];
}),
{
id: "map-grid",
label: "Сетка и LOD",
@@ -1239,9 +1301,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={runtimeBindings}
runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={presentationFilters}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
@@ -1308,7 +1370,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small>
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div>
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
@@ -1324,30 +1386,45 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={visible}
role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => toggleSubjectVisibility(summary.bindingId)}
onClick={() => {
if (hasControls) {
openSubjectWindow(summary.bindingId);
close();
return;
}
toggleSubjectVisibility(summary.bindingId);
}}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
{hasControls ? (
<IconButton
label={`Фильтры и счётчики: ${summary.displayName}`}
role="menuitem"
aria-pressed={state?.window.open || false}
data-active={state?.window.open || undefined}
onClick={() => {
openSubjectWindow(summary.bindingId);
close();
}}
><Icon name="settings" /></IconButton>
) : null}
</div>
);
})}
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
{referenceObjectSummaries.map(({ layer, displayName, total }) => (
<div
className="catalog-map-fixture__objects-menu-item"
key={layer.id}
data-visible={layer.visible || undefined}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={layer.visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => setReferenceLayers((current) => current.map((candidate) => (
candidate.id === layer.id ? { ...candidate, visible: !candidate.visible } : candidate
)))}
>
<span>{displayName}</span>
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
</button>
</div>
))}
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
@@ -1382,6 +1459,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
@@ -1390,17 +1468,63 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{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 rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
entity.bindingId === summary.bindingId
&& mapFactMatchesFilters(
entity.fact,
summary.profile,
{
[summary.bindingId]: {
visible: true,
facets: { [facet.field]: [item.value] },
},
},
summary.bindingId,
)
));
return (
<button
type="button"
key={`${facet.field}:${item.value}`}
aria-pressed={active}
data-active={active || undefined}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
>
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
<button
type="button"
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.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>
</button>
<button
type="button"
className="catalog-map-fixture__target-filter-expander"
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
aria-expanded={expanded}
onClick={() => setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
{expanded ? (
<div className="catalog-map-fixture__target-filter-children">
{matchingEntities.map((entity) => (
<button
type="button"
className="catalog-map-fixture__target-filter-entity"
key={entity.id}
data-selected={entity.id === selectedId || undefined}
onClick={() => handleSelectAndFocus(entity.id)}
>
<span>{entity.title}</span>
{entity.status ? <small>{entity.status}</small> : null}
</button>
))}
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
</div>
) : null}
</div>
);
})
))}
@@ -1501,7 +1625,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)}
>
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</Window>
</div>
);