feat(foundry): close the operational map data loop
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window } from "@nodedc/ui-react";
|
||||
import type { SelectOption } from "@nodedc/ui-react";
|
||||
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||
import type {
|
||||
CameraSpiralState,
|
||||
CesiumMapRendererHandle,
|
||||
@@ -10,6 +10,19 @@ import type {
|
||||
MapProviderStatus,
|
||||
} from "./CesiumMapRenderer.js";
|
||||
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
||||
import {
|
||||
compareMapRuntimeFacts,
|
||||
mapFactMatchesFilters,
|
||||
mapPresentationFacetCounts,
|
||||
mapPresentationProfileForFact,
|
||||
mapRuntimeDisplayLabel,
|
||||
mapRuntimeFactIsRenderable,
|
||||
normalizeClientMapPresentationProfiles,
|
||||
resolveMapPresentationClass,
|
||||
toggleMapPresentationFacetSelection,
|
||||
type MapPresentationFilters,
|
||||
type MapPresentationProfile,
|
||||
} from "./mapPresentationProfile.js";
|
||||
import {
|
||||
CAMERA_SURVEY_PRESETS,
|
||||
DEFAULT_CAMERA_SURVEY_PRESET,
|
||||
@@ -18,8 +31,6 @@ import {
|
||||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} from "./mapCameraPresets.js";
|
||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -92,11 +103,29 @@ export type MapPinBinding = {
|
||||
*/
|
||||
export type MapDataProductBinding = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
order?: number;
|
||||
dataProductId: string;
|
||||
slotId: string;
|
||||
delivery: "snapshot+patch";
|
||||
semanticTypes: string[];
|
||||
fieldProjection: string[];
|
||||
presentationProfileId?: string;
|
||||
};
|
||||
|
||||
export type MapSubjectWindowState = {
|
||||
open: boolean;
|
||||
rect: WorkspaceWindowRect;
|
||||
maximized: boolean;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type MapSubjectState = {
|
||||
bindingId: string;
|
||||
visible: boolean;
|
||||
/** Missing facet means unconstrained; an explicit empty list means no matches. */
|
||||
filters: Record<string, string[]>;
|
||||
window: MapSubjectWindowState;
|
||||
};
|
||||
|
||||
export type MapPageLayout = {
|
||||
@@ -106,7 +135,9 @@ export type MapPageLayout = {
|
||||
mapHeight: number;
|
||||
camera: MapCameraView;
|
||||
pinBindings: MapPinBinding[];
|
||||
presentationProfiles: MapPresentationProfile[];
|
||||
dataProductBindings: MapDataProductBinding[];
|
||||
subjectStates: MapSubjectState[];
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
@@ -123,9 +154,9 @@ const initialMapSettings: MapPageSettings = {
|
||||
terrainExaggeration: 1,
|
||||
monochrome: false,
|
||||
monochromeColor: "#15151b",
|
||||
imageryGamma: 100,
|
||||
imageryHue: 0,
|
||||
imageryAlpha: 100,
|
||||
imageryGamma: 57,
|
||||
imageryHue: 13,
|
||||
imageryAlpha: 27,
|
||||
globeColor: "#15151b",
|
||||
backgroundColor: "#08090d",
|
||||
atmosphereEnabled: false,
|
||||
@@ -140,11 +171,11 @@ const initialMapSettings: MapPageSettings = {
|
||||
shadowsEnabled: true,
|
||||
buildingsVisible: true,
|
||||
buildingsColor: "#a27aff",
|
||||
buildingsOpacity: 0.82,
|
||||
buildingsDetail: 16,
|
||||
imageryBrightness: 100,
|
||||
imageryContrast: 100,
|
||||
imagerySaturation: 100,
|
||||
buildingsOpacity: 1,
|
||||
buildingsDetail: 4,
|
||||
imageryBrightness: 118,
|
||||
imageryContrast: 102,
|
||||
imagerySaturation: 0,
|
||||
gridVisible: true,
|
||||
gridLodEnabled: true,
|
||||
gridHeightMeters: 500,
|
||||
@@ -167,14 +198,28 @@ const initialMapSettings: MapPageSettings = {
|
||||
// first move-end event. It makes the page contract immediately saveable;
|
||||
// the renderer replaces it with the exact live camera as soon as it is ready.
|
||||
const fallbackMapCamera: MapCameraView = {
|
||||
longitude: sceneFixture.viewport.center[0],
|
||||
latitude: sceneFixture.viewport.center[1],
|
||||
height: sceneFixture.viewport.range,
|
||||
heading: (sceneFixture.viewport.heading * Math.PI) / 180,
|
||||
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180,
|
||||
longitude: 37.618423,
|
||||
latitude: 55.751244,
|
||||
height: 40_000,
|
||||
heading: 0,
|
||||
pitch: -0.9,
|
||||
roll: 0,
|
||||
};
|
||||
|
||||
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
pageId: "map",
|
||||
settings: structuredClone(initialMapSettings),
|
||||
mapHeight: expanded ? 620 : 470,
|
||||
camera: { ...fallbackMapCamera },
|
||||
pinBindings: [],
|
||||
presentationProfiles: [],
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
const initialProviderStatus: MapProviderStatus = {
|
||||
imagery: "loading",
|
||||
terrain: "loading",
|
||||
@@ -189,6 +234,40 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
||||
"not-configured": "не настроен",
|
||||
};
|
||||
|
||||
function defaultSubjectWindowState(index: number): MapSubjectWindowState {
|
||||
return {
|
||||
open: false,
|
||||
rect: {
|
||||
x: 24 + (index % 5) * 28,
|
||||
y: 56 + (index % 5) * 28,
|
||||
width: 280,
|
||||
height: 260,
|
||||
},
|
||||
maximized: false,
|
||||
zIndex: 20 + index,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultLayersWindowRect: WorkspaceWindowRect = {
|
||||
x: 1024,
|
||||
y: 72,
|
||||
width: 336,
|
||||
height: 500,
|
||||
};
|
||||
|
||||
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
|
||||
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 ?? {
|
||||
bindingId: binding.id,
|
||||
visible: true,
|
||||
filters: {},
|
||||
window: defaultSubjectWindowState(index),
|
||||
}];
|
||||
})) as Record<string, MapSubjectState>;
|
||||
}
|
||||
|
||||
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
|
||||
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
|
||||
const formatMetricDistance = (value: number) => value >= 1000
|
||||
@@ -222,13 +301,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
applicationId?: string;
|
||||
pageId?: string;
|
||||
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
|
||||
const fixtureSelectable = useMemo(() => [
|
||||
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
|
||||
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
|
||||
], []);
|
||||
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? fixtureSelectable[0]?.id);
|
||||
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
|
||||
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
|
||||
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
|
||||
const [layersWindowActive, setLayersWindowActive] = useState(false);
|
||||
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
|
||||
@@ -254,31 +334,91 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
// Map pin bindings belong to the application page instance. They are kept
|
||||
// intact when a human changes camera or visual settings and presses Save.
|
||||
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
|
||||
// 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 ?? [])
|
||||
));
|
||||
// Data-product bindings are provisioned by Foundry MCP / Platform and do
|
||||
// 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 [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
|
||||
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
|
||||
));
|
||||
const [activeSubjectBindingId, setActiveSubjectBindingId] = useState<string>();
|
||||
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
|
||||
Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
|
||||
visible: state.visible,
|
||||
facets: state.filters,
|
||||
}]),
|
||||
), [subjectStates]);
|
||||
const runtimeBindings = useMapDataProductRuntime({
|
||||
applicationId,
|
||||
pageId,
|
||||
bindings: dataProductBindings,
|
||||
enabled: Boolean(applicationId && pageId),
|
||||
});
|
||||
const selectable = useMemo(() => [
|
||||
...fixtureSelectable,
|
||||
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
|
||||
const attributes = fact.attributes;
|
||||
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
|
||||
return {
|
||||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||
title: typeof label === "string" ? label : fact.sourceId,
|
||||
kind: fact.semanticType,
|
||||
status,
|
||||
};
|
||||
})),
|
||||
], [fixtureSelectable, runtimeBindings]);
|
||||
const selectable = useMemo(() => (
|
||||
runtimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
const facts = [...binding.facts];
|
||||
const primaryProfile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
bindingConfig?.presentationProfileId,
|
||||
bindingConfig?.semanticTypes[0] ?? facts[0]?.semanticType ?? "",
|
||||
);
|
||||
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
|
||||
return facts.map((fact) => {
|
||||
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType);
|
||||
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
|
||||
return {
|
||||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||
title: mapRuntimeDisplayLabel(fact, profile),
|
||||
kind: fact.semanticType,
|
||||
status: presentationClass?.label ?? fact.presentationStatus,
|
||||
};
|
||||
});
|
||||
})
|
||||
), [dataProductBindings, presentationProfiles, runtimeBindings]);
|
||||
const presentationSummaries = useMemo(() => [...dataProductBindings]
|
||||
.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);
|
||||
const facts = binding?.facts ?? [];
|
||||
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
|
||||
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType);
|
||||
if (!profile) return [];
|
||||
return [{
|
||||
bindingId: bindingConfig.id,
|
||||
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
|
||||
profile,
|
||||
total: facts.length,
|
||||
counts: mapPresentationFacetCounts(facts, profile),
|
||||
}];
|
||||
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
|
||||
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
return binding.facts.flatMap((fact) => {
|
||||
const profile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
bindingConfig?.presentationProfileId,
|
||||
fact.semanticType,
|
||||
);
|
||||
if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return [];
|
||||
const presentationClass = resolveMapPresentationClass(fact, profile);
|
||||
return [{
|
||||
bindingId: binding.bindingId,
|
||||
entityId: mapRuntimeEntityId(binding.bindingId, fact),
|
||||
title: mapRuntimeDisplayLabel(fact, profile),
|
||||
status: presentationClass?.label ?? "",
|
||||
renderable: mapRuntimeFactIsRenderable(fact, profile),
|
||||
}];
|
||||
});
|
||||
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
|
||||
const visibleTargetEntityIds = useMemo(() => (
|
||||
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
|
||||
), [filteredTargets]);
|
||||
// The header Save action can be pressed immediately after Cesium finishes
|
||||
// constructing the scene. Keep the last camera synchronously as well as in
|
||||
// state, so the imperative page-layout contract never waits for React's
|
||||
@@ -481,9 +621,86 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
mapHeight: Math.round(mapHeight),
|
||||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||
pinBindings,
|
||||
presentationProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
|
||||
bindingId: binding.id,
|
||||
visible: true,
|
||||
filters: {},
|
||||
window: defaultSubjectWindowState(0),
|
||||
}),
|
||||
}),
|
||||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]);
|
||||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, subjectStates]);
|
||||
|
||||
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
|
||||
setSubjectStates((current) => {
|
||||
const index = dataProductBindings.findIndex((binding) => binding.id === bindingId);
|
||||
const state = current[bindingId] ?? {
|
||||
bindingId,
|
||||
visible: true,
|
||||
filters: {},
|
||||
window: defaultSubjectWindowState(Math.max(0, index)),
|
||||
};
|
||||
return { ...current, [bindingId]: update(state) };
|
||||
});
|
||||
};
|
||||
|
||||
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
|
||||
updateSubjectState(bindingId, (state) => {
|
||||
const filters = state.visible ? state.filters : {};
|
||||
return {
|
||||
...state,
|
||||
visible: true,
|
||||
filters: toggleMapPresentationFacetSelection(filters, field, value),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openSubjectWindow = (bindingId: string) => {
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
updateSubjectState(bindingId, (state) => ({
|
||||
...state,
|
||||
window: { ...state.window, open: true, zIndex: nextZIndex },
|
||||
}));
|
||||
setLayersWindowActive(false);
|
||||
setActiveSubjectBindingId(bindingId);
|
||||
};
|
||||
|
||||
const closeSubjectWindow = (bindingId: string) => {
|
||||
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
|
||||
setActiveSubjectBindingId((current) => current === bindingId ? undefined : current);
|
||||
};
|
||||
|
||||
const activateLayersWindow = () => {
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
setLayersWindowZIndex(nextZIndex);
|
||||
setLayersWindowActive(true);
|
||||
setActiveSubjectBindingId(undefined);
|
||||
};
|
||||
|
||||
const toggleLayersWindow = () => {
|
||||
if (layersOpen) {
|
||||
setLayersOpen(false);
|
||||
setLayersWindowActive(false);
|
||||
return;
|
||||
}
|
||||
setLayersOpen(true);
|
||||
activateLayersWindow();
|
||||
};
|
||||
|
||||
const updatePresentationProfile = (
|
||||
profileId: string,
|
||||
update: (profile: MapPresentationProfile) => MapPresentationProfile,
|
||||
) => setPresentationProfiles((current) => current.map((profile) => (
|
||||
profile.id === profileId ? update(profile) : profile
|
||||
)));
|
||||
|
||||
const updatePresentationStyle = (profileId: string, styleId: string, patch: Partial<MapPresentationProfile["styles"][number]>) => {
|
||||
updatePresentationProfile(profileId, (profile) => ({
|
||||
...profile,
|
||||
styles: profile.styles.map((style) => style.id === styleId ? { ...style, ...patch } : style),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelect = useCallback((entityId: string) => {
|
||||
if (!selectable.some((entity) => entity.id === entityId)) return;
|
||||
@@ -681,6 +898,57 @@ 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) => [
|
||||
{
|
||||
id: `map-target-${profile.id}`,
|
||||
label: "Таргет",
|
||||
description: profile.title,
|
||||
group: "Таргеты",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
|
||||
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemHeightMeters } }))} />
|
||||
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, headSizePx } }))} />
|
||||
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemWidthPx } }))} />
|
||||
<InspectorSelectField
|
||||
label="Подпись"
|
||||
value={profile.label.mode}
|
||||
options={[
|
||||
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
|
||||
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
|
||||
{ value: "none", label: "Нет", description: "Не показывать плашку" },
|
||||
]}
|
||||
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
|
||||
/>
|
||||
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
|
||||
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
|
||||
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
|
||||
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
|
||||
<RangeControl label="Скрывать таргет выше" 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 } }))} />
|
||||
<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 } }))} />
|
||||
</>,
|
||||
},
|
||||
{
|
||||
id: `map-state-classes-${profile.id}`,
|
||||
label: "Классы состояния",
|
||||
description: "нормализованные фасеты онтологии",
|
||||
group: "Таргеты",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
|
||||
{profile.styles.map((style) => {
|
||||
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
|
||||
const label = classLabels.length ? classLabels.join(" · ") : style.id;
|
||||
return <div className="catalog-map-inspector__style" key={style.id}>
|
||||
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
|
||||
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
|
||||
</div>;
|
||||
})}
|
||||
</>,
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: "map-grid",
|
||||
label: "Сетка и LOD",
|
||||
@@ -823,6 +1091,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
|
||||
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
|
||||
aria-label="Map Page Cesium adapter"
|
||||
@@ -841,43 +1110,160 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
initialCamera={mapCamera ?? undefined}
|
||||
presentation={presentation}
|
||||
runtimeBindings={runtimeBindings}
|
||||
presentationProfiles={presentationProfiles}
|
||||
presentationFilters={presentationFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<div className="catalog-map-fixture__actions">
|
||||
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
|
||||
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={() => setLayersOpen((value) => !value)}><Icon name="grid" /></IconButton>
|
||||
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={toggleLayersWindow}><Icon name="grid" /></IconButton>
|
||||
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
|
||||
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
|
||||
</div>
|
||||
|
||||
{layersOpen ? (
|
||||
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
|
||||
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
|
||||
<div className="catalog-map-fixture__provider">
|
||||
<strong>Cesium World Imagery</strong>
|
||||
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
|
||||
<WorkspaceWindow
|
||||
boundsRef={workspaceRef}
|
||||
rect={layersWindowRect}
|
||||
onRectChange={setLayersWindowRect}
|
||||
maximized={layersWindowMaximized}
|
||||
onMaximizedChange={setLayersWindowMaximized}
|
||||
onActivate={activateLayersWindow}
|
||||
onClose={() => {
|
||||
setLayersOpen(false);
|
||||
setLayersWindowActive(false);
|
||||
}}
|
||||
title="Слои карты"
|
||||
active={layersWindowActive}
|
||||
zIndex={layersWindowZIndex}
|
||||
minWidth={320}
|
||||
minHeight={360}
|
||||
className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window"
|
||||
aria-label="Настройки слоёв карты"
|
||||
>
|
||||
<div className="catalog-map-fixture__layers-content">
|
||||
<div className="catalog-map-fixture__provider">
|
||||
<strong>Cesium World Imagery</strong>
|
||||
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
|
||||
</div>
|
||||
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
||||
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||||
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
|
||||
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||||
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
|
||||
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
|
||||
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
|
||||
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
|
||||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
||||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
|
||||
</div>
|
||||
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
||||
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||||
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
|
||||
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||||
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
|
||||
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
|
||||
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
|
||||
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
|
||||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
||||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
|
||||
</GlassSurface>
|
||||
</WorkspaceWindow>
|
||||
) : null}
|
||||
|
||||
{toolbarOpen ? (
|
||||
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
|
||||
<IconButton label="Обзор"><Icon name="globe" /></IconButton>
|
||||
<Dropdown
|
||||
placement="top-start"
|
||||
width={320}
|
||||
minWidth={240}
|
||||
offset={10}
|
||||
surfaceRole="menu"
|
||||
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
|
||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<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>
|
||||
</div>
|
||||
{presentationSummaries.map((summary) => {
|
||||
const state = subjectStates[summary.bindingId];
|
||||
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="catalog-map-fixture__objects-menu-item"
|
||||
key={summary.bindingId}
|
||||
data-open={state?.window.open || undefined}
|
||||
onClick={() => {
|
||||
openSubjectWindow(summary.bindingId);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span>{summary.displayName}</span>
|
||||
<small>{state?.visible === false || visibleCount === 0 ? "на карте: 0" : `на карте: ${visibleCount}`}{state?.window.open ? " · окно открыто" : ""}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
|
||||
<IconButton label="Поиск"><Icon name="search" /></IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{presentationSummaries.map((summary) => {
|
||||
const state = subjectStates[summary.bindingId];
|
||||
if (!state?.window.open) return null;
|
||||
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
key={summary.bindingId}
|
||||
boundsRef={workspaceRef}
|
||||
rect={state.window.rect}
|
||||
onRectChange={(rect) => updateSubjectState(summary.bindingId, (current) => ({
|
||||
...current,
|
||||
window: { ...current.window, rect },
|
||||
}))}
|
||||
maximized={state.window.maximized}
|
||||
onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({
|
||||
...current,
|
||||
window: { ...current.window, maximized },
|
||||
}))}
|
||||
onActivate={() => openSubjectWindow(summary.bindingId)}
|
||||
onClose={() => closeSubjectWindow(summary.bindingId)}
|
||||
title={summary.displayName}
|
||||
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
|
||||
active={activeSubjectBindingId === summary.bindingId}
|
||||
zIndex={state.window.zIndex}
|
||||
minWidth={240}
|
||||
minHeight={220}
|
||||
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
|
||||
>
|
||||
<div className="catalog-map-fixture__target-filters">
|
||||
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
|
||||
<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;
|
||||
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>
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
})}
|
||||
|
||||
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
|
||||
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
|
||||
|
||||
@@ -890,6 +1276,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
closeOnBackdrop={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="catalog-map-fixture__map-settings-window"
|
||||
onClose={() => setInspectorOpen(false)}
|
||||
>
|
||||
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
|
||||
|
||||
Reference in New Issue
Block a user