feat(foundry): add composable subject detail profiles
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||
import type {
|
||||
CameraSpiralState,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} from "./mapCameraPresets.js";
|
||||
import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs";
|
||||
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -112,6 +112,36 @@ export type MapDataProductBinding = {
|
||||
semanticTypes: string[];
|
||||
fieldProjection: string[];
|
||||
presentationProfileId?: string;
|
||||
subjectDetailProfileId?: string;
|
||||
aspectId?: string;
|
||||
joinToBindingId?: string;
|
||||
};
|
||||
|
||||
export type MapSubjectDetailProfile = {
|
||||
id: string;
|
||||
version: string;
|
||||
title: string;
|
||||
semanticTypes: string[];
|
||||
defaultTabId: string;
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
emptyMessage: string;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
fields: Array<{
|
||||
id: string;
|
||||
aspectId?: string;
|
||||
source: "fact" | "attribute" | "geometry" | "context";
|
||||
field: string;
|
||||
label: string;
|
||||
format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "telemetry_readings";
|
||||
unit?: string;
|
||||
allowedReadingIds?: string[];
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MapSubjectWindowState = {
|
||||
@@ -137,6 +167,7 @@ export type MapPageLayout = {
|
||||
camera: MapCameraView;
|
||||
pinBindings: MapPinBinding[];
|
||||
presentationProfiles: MapPresentationProfile[];
|
||||
subjectDetailProfiles: MapSubjectDetailProfile[];
|
||||
dataProductBindings: MapDataProductBinding[];
|
||||
subjectStates: MapSubjectState[];
|
||||
savedAt?: string;
|
||||
@@ -216,6 +247,7 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
|
||||
camera: { ...fallbackMapCamera },
|
||||
pinBindings: [],
|
||||
presentationProfiles: [],
|
||||
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
};
|
||||
@@ -320,6 +352,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
|
||||
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
|
||||
const [subjectCardActive, setSubjectCardActive] = useState(false);
|
||||
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
|
||||
@@ -356,6 +389,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
|
||||
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
|
||||
));
|
||||
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
|
||||
initialLayout?.subjectDetailProfiles?.length
|
||||
? initialLayout.subjectDetailProfiles
|
||||
: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile]
|
||||
));
|
||||
// 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.
|
||||
@@ -379,6 +417,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const selectable = useMemo(() => (
|
||||
runtimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
if (bindingConfig?.joinToBindingId) return [];
|
||||
const facts = [...binding.facts];
|
||||
const primaryProfile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
@@ -476,12 +515,33 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
||||
const selectedSubjectCard = useMemo(() => {
|
||||
const entity = selectable.find((candidate) => candidate.id === selectedId);
|
||||
return entity ? buildMapSubjectCardModel(entity.fact, {
|
||||
if (!entity) return null;
|
||||
const primaryBinding = dataProductBindings.find((binding) => binding.id === entity.bindingId);
|
||||
const profile = subjectDetailProfiles.find((candidate) => candidate.id === primaryBinding?.subjectDetailProfileId)
|
||||
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity.fact.semanticType))
|
||||
?? DEFAULT_MAP_SUBJECT_DETAIL_PROFILE;
|
||||
const aspects = Object.fromEntries(dataProductBindings
|
||||
.filter((binding) => binding.id === entity.bindingId || binding.joinToBindingId === entity.bindingId)
|
||||
.flatMap((binding) => {
|
||||
const runtime = runtimeBindings.find((candidate) => candidate.bindingId === binding.id);
|
||||
const fact = binding.id === entity.bindingId
|
||||
? entity.fact
|
||||
: runtime?.facts.find((candidate) => candidate.sourceId === entity.fact.sourceId);
|
||||
if (!fact) return [];
|
||||
return [[binding.id === entity.bindingId ? "primary" : (binding.aspectId ?? binding.id), {
|
||||
fact,
|
||||
bindingId: binding.id,
|
||||
dataProductId: binding.dataProductId,
|
||||
}]];
|
||||
}));
|
||||
return buildMapSubjectCardModel(entity.fact, {
|
||||
title: entity.title,
|
||||
bindingId: entity.bindingId,
|
||||
dataProductId: entity.dataProductId,
|
||||
}) : null;
|
||||
}, [selectable, selectedId]);
|
||||
profile,
|
||||
aspects,
|
||||
});
|
||||
}, [dataProductBindings, runtimeBindings, selectable, selectedId, subjectDetailProfiles]);
|
||||
const presentation = useMemo<MapPresentation>(
|
||||
() => ({ ...mapSettings, cacheRefresh }),
|
||||
[cacheRefresh, mapSettings],
|
||||
@@ -650,6 +710,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||||
pinBindings,
|
||||
presentationProfiles,
|
||||
subjectDetailProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
|
||||
bindingId: binding.id,
|
||||
@@ -663,7 +724,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
: state;
|
||||
}),
|
||||
}),
|
||||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectStates]);
|
||||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectDetailProfiles, subjectStates]);
|
||||
|
||||
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
|
||||
setSubjectStates((current) => {
|
||||
@@ -744,12 +805,17 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const handleSelect = useCallback((entityId: string) => {
|
||||
if (!selectable.some((entity) => entity.id === entityId)) return;
|
||||
setSelectedId(entityId);
|
||||
const entity = selectable.find((candidate) => candidate.id === entityId);
|
||||
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");
|
||||
setSubjectCardOpen(true);
|
||||
setSubjectCardActive(true);
|
||||
setLayersWindowActive(false);
|
||||
setActiveSubjectBindingId(undefined);
|
||||
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
|
||||
}, [layersWindowZIndex, selectable, subjectStates]);
|
||||
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
|
||||
|
||||
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
|
||||
gatewayHealthRef.current = health;
|
||||
@@ -1368,36 +1434,52 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
minWidth={320}
|
||||
minHeight={320}
|
||||
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
|
||||
aria-label={`Телеметрия: ${selectedSubjectCard.title}`}
|
||||
aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
|
||||
>
|
||||
<div className="catalog-map-subject-card">
|
||||
{selectedSubjectCard.sections.map((section) => (
|
||||
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${section.id}`}>
|
||||
<h3 id={`subject-card-${section.id}`}>{section.label}</h3>
|
||||
<dl>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.key} className="catalog-map-subject-card__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<div className="catalog-map-subject-card__tabs">
|
||||
<SegmentedControl
|
||||
value={selectedSubjectCard.tabs.some((tab) => tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId}
|
||||
items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
|
||||
label="Разделы карточки объекта"
|
||||
onChange={setSubjectCardTabId}
|
||||
/>
|
||||
</div>
|
||||
{selectedSubjectCard.tabs.filter((tab) => tab.id === (
|
||||
selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId)
|
||||
? subjectCardTabId
|
||||
: selectedSubjectCard.defaultTabId
|
||||
)).map((tab) => (
|
||||
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
|
||||
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
|
||||
{tab.sections.map((section) => (
|
||||
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
|
||||
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
|
||||
{section.rows.length ? (
|
||||
<dl>
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.key} className="catalog-map-subject-card__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{section.readings.length ? (
|
||||
<div className="catalog-map-subject-card__readings">
|
||||
{section.readings.map((reading) => (
|
||||
<div className="catalog-map-subject-card__reading" key={reading.id}>
|
||||
<span>{reading.label}</span>
|
||||
<strong>{reading.value}</strong>
|
||||
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<section className="catalog-map-subject-card__section" aria-labelledby="subject-card-readings">
|
||||
<h3 id="subject-card-readings">Датчики</h3>
|
||||
{selectedSubjectCard.readings.length ? (
|
||||
<div className="catalog-map-subject-card__readings">
|
||||
{selectedSubjectCard.readings.map((reading) => (
|
||||
<div className="catalog-map-subject-card__reading" key={reading.id}>
|
||||
<span>{reading.label}</span>
|
||||
<strong>{reading.value}</strong>
|
||||
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <small>Gelios не передал разрешённых значений датчиков для этого объекта.</small>}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceWindow>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user