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}
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||
|
||||
export type MapSubjectDetailField = {
|
||||
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 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: MapSubjectDetailField[] }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MapSubjectCardRow = { key: string; label: string; value: string };
|
||||
export type MapSubjectCardSection = { id: string; label: string; rows: MapSubjectCardRow[] };
|
||||
export type MapTelemetryReading = { id: string; label: string; value: string; observedAt?: string };
|
||||
export type MapSubjectCardSection = {
|
||||
id: string;
|
||||
label: string;
|
||||
rows: MapSubjectCardRow[];
|
||||
readings: MapTelemetryReading[];
|
||||
};
|
||||
export type MapSubjectCardTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
emptyMessage: string;
|
||||
sections: MapSubjectCardSection[];
|
||||
empty: boolean;
|
||||
};
|
||||
export type MapSubjectCardModel = {
|
||||
title: string;
|
||||
sourceId: string;
|
||||
sections: MapSubjectCardSection[];
|
||||
readings: MapTelemetryReading[];
|
||||
profileId: string;
|
||||
defaultTabId: string;
|
||||
tabs: MapSubjectCardTab[];
|
||||
};
|
||||
|
||||
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE: Readonly<MapSubjectDetailProfile>;
|
||||
|
||||
export function buildMapSubjectCardModel(
|
||||
fact: MapRuntimeFact,
|
||||
context?: { title?: string; bindingId?: string; dataProductId?: string },
|
||||
context?: {
|
||||
title?: string;
|
||||
bindingId?: string;
|
||||
dataProductId?: string;
|
||||
profile?: MapSubjectDetailProfile;
|
||||
aspects?: Record<string, { fact: MapRuntimeFact; bindingId?: string; dataProductId?: string }>;
|
||||
},
|
||||
): MapSubjectCardModel | null;
|
||||
|
||||
export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[];
|
||||
export function normalizeTelemetryReadings(value: unknown, allowedReadingIds?: string[]): MapTelemetryReading[];
|
||||
|
||||
@@ -1,75 +1,184 @@
|
||||
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
|
||||
const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
|
||||
const DIRECT_IDENTIFIER_VALUE = /^(?:\+?\d[\d ()-]{8,18}|\d{14,16})$/;
|
||||
|
||||
const FIELD_PRESENTATION = Object.freeze({
|
||||
signal_state: { section: "state", label: "Связь" },
|
||||
movement_state: { section: "state", label: "Движение" },
|
||||
speed_kph: { section: "position", label: "Скорость", unit: "км/ч" },
|
||||
course_degrees: { section: "position", label: "Курс", unit: "°" },
|
||||
elevation_meters: { section: "position", label: "Высота", unit: "м" },
|
||||
satellite_count: { section: "quality", label: "Спутники" },
|
||||
hdop: { section: "quality", label: "HDOP" },
|
||||
horizontal_accuracy_meters: { section: "quality", label: "Точность", unit: "м" },
|
||||
mileage_km: { section: "operation", label: "Пробег", unit: "км" },
|
||||
engine_hours: { section: "operation", label: "Моточасы", unit: "ч" },
|
||||
object_kind: { section: "identity", label: "Класс объекта" },
|
||||
position_source: { section: "identity", label: "Источник позиции" },
|
||||
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({
|
||||
id: "map.subject-detail.operational.default",
|
||||
version: "1.0.0",
|
||||
title: "Техническая карточка объекта",
|
||||
semanticTypes: ["map.moving_object"],
|
||||
defaultTabId: "overview",
|
||||
tabs: [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Обзор",
|
||||
emptyMessage: "Оперативные данные объекта пока не получены.",
|
||||
sections: [
|
||||
{
|
||||
id: "state",
|
||||
label: "Состояние",
|
||||
fields: [
|
||||
{ id: "overview.signal", aspectId: "primary", source: "attribute", field: "signal_state", label: "Связь", format: "signal_state" },
|
||||
{ id: "overview.movement", aspectId: "primary", source: "attribute", field: "movement_state", label: "Движение", format: "movement_state" },
|
||||
{ id: "overview.speed", aspectId: "primary", source: "attribute", field: "speed_kph", label: "Скорость", format: "number", unit: "км/ч" },
|
||||
{ id: "overview.observed", aspectId: "primary", source: "fact", field: "observedAt", label: "Данные объекта", format: "timestamp" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "position",
|
||||
label: "Позиция",
|
||||
emptyMessage: "Для объекта нет разрешённой позиции.",
|
||||
sections: [
|
||||
{
|
||||
id: "coordinates",
|
||||
label: "Координаты и движение",
|
||||
fields: [
|
||||
{ id: "position.latitude", aspectId: "primary", source: "geometry", field: "latitude", label: "Широта", format: "coordinate" },
|
||||
{ id: "position.longitude", aspectId: "primary", source: "geometry", field: "longitude", label: "Долгота", format: "coordinate" },
|
||||
{ id: "position.course", aspectId: "primary", source: "attribute", field: "course_degrees", label: "Курс", format: "number", unit: "°" },
|
||||
{ id: "position.elevation", aspectId: "primary", source: "attribute", field: "elevation_meters", label: "Высота", format: "number", unit: "м" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
label: "Качество GPS",
|
||||
fields: [
|
||||
{ id: "position.satellites", aspectId: "primary", source: "attribute", field: "satellite_count", label: "Спутники", format: "number" },
|
||||
{ id: "position.hdop", aspectId: "primary", source: "attribute", field: "hdop", label: "HDOP", format: "number" },
|
||||
{ id: "position.accuracy", aspectId: "primary", source: "attribute", field: "horizontal_accuracy_meters", label: "Точность", format: "number", unit: "м" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "telemetry",
|
||||
label: "Телеметрия",
|
||||
emptyMessage: "Разрешённые сенсоры появятся после профилирования подключения.",
|
||||
sections: [{
|
||||
id: "readings",
|
||||
label: "Датчики",
|
||||
fields: [{
|
||||
id: "telemetry.readings",
|
||||
aspectId: "primary",
|
||||
source: "attribute",
|
||||
field: "sensor_readings",
|
||||
label: "Датчики",
|
||||
format: "telemetry_readings",
|
||||
allowedReadingIds: [],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "counters",
|
||||
label: "Счётчики",
|
||||
emptyMessage: "Счётчики для объекта не получены.",
|
||||
sections: [{
|
||||
id: "operation",
|
||||
label: "Эксплуатация",
|
||||
fields: [
|
||||
{ id: "counters.mileage", aspectId: "primary", source: "attribute", field: "mileage_km", label: "Пробег", format: "number", unit: "км" },
|
||||
{ id: "counters.engine-hours", aspectId: "primary", source: "attribute", field: "engine_hours", label: "Моточасы", format: "number", unit: "ч" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{ id: "equipment", label: "Оборудование", emptyMessage: "Технический профиль оборудования ещё не подключён.", sections: [] },
|
||||
{ id: "settings", label: "Настройки", emptyMessage: "Trip/fuel settings ещё не подключены.", sections: [] },
|
||||
{ id: "maintenance", label: "ТО", emptyMessage: "Планы технического обслуживания ещё не подключены.", sections: [] },
|
||||
{
|
||||
id: "diagnostics",
|
||||
label: "Данные",
|
||||
emptyMessage: "Диагностические метаданные не получены.",
|
||||
sections: [{
|
||||
id: "provenance",
|
||||
label: "Происхождение",
|
||||
fields: [
|
||||
{ id: "diagnostics.source-id", aspectId: "primary", source: "fact", field: "sourceId", label: "ID объекта", format: "text" },
|
||||
{ id: "diagnostics.semantic-type", aspectId: "primary", source: "fact", field: "semanticType", label: "Семантический тип", format: "text" },
|
||||
{ id: "diagnostics.product", aspectId: "primary", source: "context", field: "dataProductId", label: "Продукт данных", format: "text" },
|
||||
{ id: "diagnostics.received", aspectId: "primary", source: "fact", field: "receivedAt", label: "Получено NODE.DC", format: "timestamp" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const SECTION_ORDER = Object.freeze([
|
||||
["state", "Состояние"],
|
||||
["position", "Позиция"],
|
||||
["quality", "Качество GPS"],
|
||||
["operation", "Эксплуатация"],
|
||||
["identity", "Идентификация"],
|
||||
["other", "Прочие данные"],
|
||||
]);
|
||||
|
||||
export function buildMapSubjectCardModel(fact, context = {}) {
|
||||
if (!fact || typeof fact !== "object") return null;
|
||||
if (!isPlainObject(fact)) return null;
|
||||
const profile = isPlainObject(context.profile) ? context.profile : DEFAULT_MAP_SUBJECT_DETAIL_PROFILE;
|
||||
const primary = { fact, dataProductId: context.dataProductId, bindingId: context.bindingId };
|
||||
const aspects = { primary, ...(isPlainObject(context.aspects) ? context.aspects : {}) };
|
||||
const tabs = Array.isArray(profile.tabs) ? profile.tabs.flatMap((tab) => buildTab(tab, aspects)) : [];
|
||||
if (!tabs.length) return null;
|
||||
const attributes = isPlainObject(fact.attributes) ? fact.attributes : {};
|
||||
const rows = new Map(SECTION_ORDER.map(([id, label]) => [id, { id, label, rows: [] }]));
|
||||
const append = (section, key, label, value, unit) => {
|
||||
if (value === undefined || SECRET_LIKE_KEY.test(key)) return;
|
||||
rows.get(section)?.rows.push({ key, label, value: formatValue(key, value, unit) });
|
||||
};
|
||||
|
||||
append("identity", "source_id", "ID", fact.sourceId);
|
||||
append("identity", "semantic_type", "Семантический тип", fact.semanticType);
|
||||
append("identity", "data_product_id", "Продукт данных", context.dataProductId);
|
||||
append("identity", "binding_id", "Binding", context.bindingId);
|
||||
append("state", "presentation_status", "Статус отображения", fact.presentationStatus);
|
||||
|
||||
if (fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) {
|
||||
append("position", "latitude", "Широта", fact.geometry.coordinates[1]);
|
||||
append("position", "longitude", "Долгота", fact.geometry.coordinates[0]);
|
||||
}
|
||||
append("state", "observed_at", "Данные объекта", fact.observedAt);
|
||||
append("state", "received_at", "Получено NODE.DC", fact.receivedAt);
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === "display_name" || key === "sensor_readings" || SECRET_LIKE_KEY.test(key)) continue;
|
||||
const presentation = FIELD_PRESENTATION[key];
|
||||
append(presentation?.section ?? "other", key, presentation?.label ?? humanizeKey(key), value, presentation?.unit);
|
||||
}
|
||||
|
||||
const readings = normalizeTelemetryReadings(attributes.sensor_readings);
|
||||
const defaultTabId = tabs.some((tab) => tab.id === profile.defaultTabId) ? profile.defaultTabId : tabs[0].id;
|
||||
return {
|
||||
title: stringValue(attributes.display_name) || stringValue(context.title) || stringValue(fact.sourceId) || "Объект",
|
||||
sourceId: stringValue(fact.sourceId),
|
||||
sections: [...rows.values()].filter((section) => section.rows.length),
|
||||
readings,
|
||||
profileId: stringValue(profile.id),
|
||||
defaultTabId,
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelemetryReadings(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
function buildTab(tab, aspects) {
|
||||
if (!isPlainObject(tab) || !safeIdentifier(tab.id) || !stringValue(tab.label)) return [];
|
||||
const sections = Array.isArray(tab.sections) ? tab.sections.flatMap((section) => buildSection(section, aspects)) : [];
|
||||
return [{
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
emptyMessage: stringValue(tab.emptyMessage) || "Данные для этого раздела пока не получены.",
|
||||
sections,
|
||||
empty: sections.length === 0,
|
||||
}];
|
||||
}
|
||||
|
||||
function buildSection(section, aspects) {
|
||||
if (!isPlainObject(section) || !safeIdentifier(section.id) || !stringValue(section.label)) return [];
|
||||
const rows = [];
|
||||
const readings = [];
|
||||
for (const field of Array.isArray(section.fields) ? section.fields : []) {
|
||||
if (!isPlainObject(field) || SECRET_LIKE.test(String(field.field || "")) || SECRET_LIKE.test(String(field.label || ""))) continue;
|
||||
const aspect = aspects[stringValue(field.aspectId) || "primary"];
|
||||
if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue;
|
||||
const value = resolveFieldValue(aspect, field);
|
||||
if (field.format === "telemetry_readings") {
|
||||
readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds));
|
||||
continue;
|
||||
}
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
rows.push({
|
||||
key: String(field.id || `${field.source}.${field.field}`),
|
||||
label: stringValue(field.label),
|
||||
value: formatValue(field.format, value, stringValue(field.unit)),
|
||||
});
|
||||
}
|
||||
if (!rows.length && !readings.length) return [];
|
||||
return [{ id: section.id, label: section.label, rows, readings }];
|
||||
}
|
||||
|
||||
function resolveFieldValue(aspect, field) {
|
||||
const fact = aspect.fact;
|
||||
if (field.source === "attribute") return isPlainObject(fact.attributes) ? fact.attributes[field.field] : undefined;
|
||||
if (field.source === "fact") return fact[field.field];
|
||||
if (field.source === "context") return aspect[field.field];
|
||||
if (field.source === "geometry" && fact.geometry?.type === "Point" && Array.isArray(fact.geometry.coordinates)) {
|
||||
if (field.field === "latitude") return fact.geometry.coordinates[1];
|
||||
if (field.field === "longitude") return fact.geometry.coordinates[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeTelemetryReadings(value, allowedReadingIds = []) {
|
||||
if (!Array.isArray(value) || !Array.isArray(allowedReadingIds) || allowedReadingIds.length === 0) return [];
|
||||
const allowed = new Set(allowedReadingIds.filter((item) => safeIdentifier(item) && !SECRET_LIKE.test(item)));
|
||||
const seen = new Set();
|
||||
return value.slice(0, 128).flatMap((candidate) => {
|
||||
if (!isPlainObject(candidate)) return [];
|
||||
const id = stringValue(candidate.id);
|
||||
const label = stringValue(candidate.label);
|
||||
if (!/^[a-z][a-z0-9._:-]{1,127}$/.test(id) || !label || seen.has(id) || SECRET_LIKE_KEY.test(id)) return [];
|
||||
if (!isScalar(candidate.value)) return [];
|
||||
if (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return [];
|
||||
if (!isSafeScalar(candidate.value)) return [];
|
||||
seen.add(id);
|
||||
const unit = stringValue(candidate.unit).slice(0, 32);
|
||||
const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined;
|
||||
@@ -77,12 +186,14 @@ export function normalizeTelemetryReadings(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatValue(key, value, unit) {
|
||||
if ((key === "observed_at" || key === "received_at") && isIsoTimestamp(value)) return formatTimestamp(value);
|
||||
if (key === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
|
||||
if (key === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
||||
if (Array.isArray(value)) return value.map((item) => formatScalar(item)).join(", ");
|
||||
if (isPlainObject(value)) return JSON.stringify(value);
|
||||
function formatValue(format, value, unit) {
|
||||
if (format === "timestamp") return isIsoTimestamp(value) ? formatTimestamp(value) : "—";
|
||||
if (format === "signal_state") return value === "active" ? "На связи" : value === "inactive" ? "Не на связи" : formatScalar(value, unit);
|
||||
if (format === "movement_state") return value === "moving" ? "В движении" : value === "stopped" ? "Неподвижен" : formatScalar(value, unit);
|
||||
if (format === "boolean") return Boolean(value) ? "Да" : "Нет";
|
||||
if (format === "coordinate" && typeof value === "number" && Number.isFinite(value)) {
|
||||
return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 5, maximumFractionDigits: 6 }).format(value);
|
||||
}
|
||||
return formatScalar(value, unit);
|
||||
}
|
||||
|
||||
@@ -101,15 +212,16 @@ function formatTimestamp(value) {
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function humanizeKey(value) {
|
||||
const text = String(value).replace(/[._-]+/g, " ").trim();
|
||||
return text ? text[0].toUpperCase() + text.slice(1) : String(value);
|
||||
function isSafeScalar(value) {
|
||||
if (typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) < 1_000_000_000_000);
|
||||
if (typeof value !== "string") return false;
|
||||
const normalized = value.trim();
|
||||
return normalized.length <= 512 && !SECRET_LIKE.test(normalized) && !DIRECT_IDENTIFIER_VALUE.test(normalized);
|
||||
}
|
||||
|
||||
function isScalar(value) {
|
||||
return typeof value === "string"
|
||||
|| typeof value === "boolean"
|
||||
|| (typeof value === "number" && Number.isFinite(value));
|
||||
function safeIdentifier(value) {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9._:-]{1,159}$/.test(value);
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
|
||||
@@ -823,6 +823,35 @@ textarea {
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__tabs {
|
||||
overflow-x: auto;
|
||||
padding: 0.08rem 0 0.12rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__tabs .nodedc-segmented {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__tabs .nodedc-segmented__item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__tab-panel {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__empty {
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
padding: 0.9rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
|
||||
Reference in New Issue
Block a user