feat(map): open bounded telemetry card from entities
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
findCameraSurveyPreset,
|
||||
type CameraSurveySelection,
|
||||
} from "./mapCameraPresets.js";
|
||||
import { buildMapSubjectCardModel } from "./mapSubjectCard.mjs";
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -255,6 +256,13 @@ const defaultLayersWindowRect: WorkspaceWindowRect = {
|
||||
height: 500,
|
||||
};
|
||||
|
||||
const defaultSubjectCardRect: WorkspaceWindowRect = {
|
||||
x: 940,
|
||||
y: 52,
|
||||
width: 390,
|
||||
height: 560,
|
||||
};
|
||||
|
||||
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
|
||||
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
|
||||
return Object.fromEntries(bindings.map((binding, index) => {
|
||||
@@ -307,6 +315,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
|
||||
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
|
||||
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
|
||||
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
|
||||
const [subjectCardActive, setSubjectCardActive] = useState(false);
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
|
||||
@@ -381,6 +394,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
title: mapRuntimeDisplayLabel(fact, profile),
|
||||
kind: fact.semanticType,
|
||||
status: presentationClass?.label ?? fact.presentationStatus,
|
||||
bindingId: binding.bindingId,
|
||||
dataProductId: binding.dataProductId,
|
||||
fact,
|
||||
};
|
||||
});
|
||||
})
|
||||
@@ -458,6 +474,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
}] : []),
|
||||
], [spiralPresetId]);
|
||||
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, {
|
||||
title: entity.title,
|
||||
bindingId: entity.bindingId,
|
||||
dataProductId: entity.dataProductId,
|
||||
}) : null;
|
||||
}, [selectable, selectedId]);
|
||||
const presentation = useMemo<MapPresentation>(
|
||||
() => ({ ...mapSettings, cacheRefresh }),
|
||||
[cacheRefresh, mapSettings],
|
||||
@@ -670,12 +694,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
};
|
||||
|
||||
const openSubjectWindow = (bindingId: string) => {
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
updateSubjectState(bindingId, (state) => ({
|
||||
...state,
|
||||
window: { ...state.window, open: true, zIndex: nextZIndex },
|
||||
}));
|
||||
setLayersWindowActive(false);
|
||||
setSubjectCardActive(false);
|
||||
setActiveSubjectBindingId(bindingId);
|
||||
};
|
||||
|
||||
@@ -685,9 +710,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
};
|
||||
|
||||
const activateLayersWindow = () => {
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
setLayersWindowZIndex(nextZIndex);
|
||||
setLayersWindowActive(true);
|
||||
setSubjectCardActive(false);
|
||||
setActiveSubjectBindingId(undefined);
|
||||
};
|
||||
|
||||
@@ -718,7 +744,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const handleSelect = useCallback((entityId: string) => {
|
||||
if (!selectable.some((entity) => entity.id === entityId)) return;
|
||||
setSelectedId(entityId);
|
||||
}, [selectable]);
|
||||
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]);
|
||||
|
||||
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
|
||||
gatewayHealthRef.current = health;
|
||||
@@ -1312,6 +1343,65 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
);
|
||||
})}
|
||||
|
||||
{subjectCardOpen && selectedSubjectCard ? (
|
||||
<WorkspaceWindow
|
||||
boundsRef={workspaceRef}
|
||||
rect={subjectCardRect}
|
||||
onRectChange={setSubjectCardRect}
|
||||
maximized={subjectCardMaximized}
|
||||
onMaximizedChange={setSubjectCardMaximized}
|
||||
onActivate={() => {
|
||||
const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
|
||||
setSubjectCardZIndex(nextZIndex);
|
||||
setSubjectCardActive(true);
|
||||
setLayersWindowActive(false);
|
||||
setActiveSubjectBindingId(undefined);
|
||||
}}
|
||||
onClose={() => {
|
||||
setSubjectCardOpen(false);
|
||||
setSubjectCardActive(false);
|
||||
}}
|
||||
title={selectedSubjectCard.title}
|
||||
subtitle={selectedSubjectCard.sourceId}
|
||||
active={subjectCardActive}
|
||||
zIndex={subjectCardZIndex}
|
||||
minWidth={320}
|
||||
minHeight={320}
|
||||
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
|
||||
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>
|
||||
))}
|
||||
<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}
|
||||
|
||||
{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>
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||
|
||||
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 MapSubjectCardModel = {
|
||||
title: string;
|
||||
sourceId: string;
|
||||
sections: MapSubjectCardSection[];
|
||||
readings: MapTelemetryReading[];
|
||||
};
|
||||
|
||||
export function buildMapSubjectCardModel(
|
||||
fact: MapRuntimeFact,
|
||||
context?: { title?: string; bindingId?: string; dataProductId?: string },
|
||||
): MapSubjectCardModel | null;
|
||||
|
||||
export function normalizeTelemetryReadings(value: unknown): MapTelemetryReading[];
|
||||
@@ -0,0 +1,125 @@
|
||||
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
|
||||
|
||||
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: "Источник позиции" },
|
||||
});
|
||||
|
||||
const SECTION_ORDER = Object.freeze([
|
||||
["state", "Состояние"],
|
||||
["position", "Позиция"],
|
||||
["quality", "Качество GPS"],
|
||||
["operation", "Эксплуатация"],
|
||||
["identity", "Идентификация"],
|
||||
["other", "Прочие данные"],
|
||||
]);
|
||||
|
||||
export function buildMapSubjectCardModel(fact, context = {}) {
|
||||
if (!fact || typeof fact !== "object") 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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelemetryReadings(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
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 [];
|
||||
seen.add(id);
|
||||
const unit = stringValue(candidate.unit).slice(0, 32);
|
||||
const observedAt = isIsoTimestamp(candidate.observedAt) ? candidate.observedAt : undefined;
|
||||
return [{ id, label: label.slice(0, 160), value: formatScalar(candidate.value, unit), observedAt }];
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
return formatScalar(value, unit);
|
||||
}
|
||||
|
||||
function formatScalar(value, unit = "") {
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
const number = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 3 }).format(value);
|
||||
return unit ? `${number} ${unit}` : number;
|
||||
}
|
||||
const text = stringValue(value);
|
||||
return unit && text ? `${text} ${unit}` : text || "—";
|
||||
}
|
||||
|
||||
function formatTimestamp(value) {
|
||||
const date = new Date(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 isScalar(value) {
|
||||
return typeof value === "string"
|
||||
|| typeof value === "boolean"
|
||||
|| (typeof value === "number" && Number.isFinite(value));
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isIsoTimestamp(value) {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -808,6 +808,95 @@ textarea {
|
||||
padding: 0.42rem 0.65rem 0.68rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__subject-card {
|
||||
--nodedc-radius-modal: 1.45rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__subject-card .nodedc-workspace-window__body {
|
||||
overflow: auto;
|
||||
padding: 0.35rem 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
padding: 0.72rem 0.78rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section h3,
|
||||
.catalog-map-subject-card__section dl,
|
||||
.catalog-map-subject-card__section dt,
|
||||
.catalog-map-subject-card__section dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section h3 {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section dl,
|
||||
.catalog-map-subject-card__readings {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__row,
|
||||
.catalog-map-subject-card__reading {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(7.5rem, 0.85fr) minmax(0, 1.15fr);
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
border-top: 1px solid rgba(13, 14, 17, 0.08);
|
||||
padding: 0.34rem 0;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__row:first-child,
|
||||
.catalog-map-subject-card__reading:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__row dt,
|
||||
.catalog-map-subject-card__reading span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__row dd,
|
||||
.catalog-map-subject-card__reading strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: 760;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__reading time {
|
||||
grid-column: 2;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.catalog-map-subject-card__section > small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filters,
|
||||
.catalog-map-fixture__target-filters section {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user