Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx
T
Дмитрий Соловьев 3638afdb85 pcp: откат UTC-слоя — сквозной naive LocalDateTime без зоны на проводе
Возврат к модели «настенных часов»: одна ISO-строка без зоны
("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к
часовому поясу, сдвигаться нечему.

Бэкенд — снять зону с границы API:
- DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация
  atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах:
  tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos,
  complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO.
- Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader,
  imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их
  регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310
  (JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен.
- Kafka-конверт KafkaMessage.time сериализуется ISO без офсета.
- mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис
  переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate
  мапит на naive без миграции — прецедент MissionEntity/PlanEntity).
- RequestServiceClient (dynamic) синхронизирован по типу с request-service.

Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов
оставлен (выбор настенных часов UTC, не зона на проводе).

Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z
(после удаления толерантного десериализатора штатный парсер падал бы на Z).
Дисплейные хелперы (показ «как пришло», без сдвига) сохранены.

Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация
офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md.

Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с
ранее опубликованным офсетом +00:00 дренировать/проиграть.
2026-06-04 00:03:52 +03:00

324 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
invalidRange: boolean;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
};
export type RequestsState = {
fromValue: string;
toValue: string;
items: RequestMapItem[];
loading: boolean;
loadedCount: number;
error?: string;
totalItems: number;
truncated: boolean;
loaded: boolean;
};
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
fromValue: toUtcInputValue(range.fromMs),
toValue: toUtcInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
totalItems: 0,
truncated: false,
loaded: false
};
}
export function Tgu2DMapTab({
range,
invalidRange,
selectedPlan,
platforms
}: Tgu2DMapTabProps) {
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const [stations, setStations] = useState<StationDto[]>([]);
const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => {
fetchStations()
.then((items) => { setStations(items); setStationsFetchFailed(false); })
.catch(() => setStationsFetchFailed(true));
}, []);
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseUtc(fromValue);
const toMs = parseUtc(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{
endFrom: new Date(fromMs).toISOString().slice(0, 19),
beginTo: new Date(toMs).toISOString().slice(0, 19)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
}
);
setRequestsState((current) => ({
...current,
items: result.items,
loadedCount: result.items.length,
totalItems: result.totalItems,
truncated: result.truncated,
loading: false,
loaded: true
}));
} catch (error) {
setRequestsState((current) => ({
...current,
loading: false,
error: error instanceof Error ? error.message : "Не удалось загрузить заявки."
}));
}
}, []);
const applyRequestsRange = useCallback(() => {
void loadRequests(requestsState.fromValue, requestsState.toValue);
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
const setRequestsFrom = useCallback((fromValue: string) => {
setRequestsState((current) => ({ ...current, fromValue }));
}, []);
const setRequestsTo = useCallback((toValue: string) => {
setRequestsState((current) => ({ ...current, toValue }));
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = toUtcInputValue(fromMs);
const toValue = toUtcInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
useEffect(() => {
setRequestsState(buildInitialRequestsState(range));
}, [range]);
const toggleSpacecraft = useCallback((id: string) => {
setHiddenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenIds(new Set()), []);
const planPlatform = useMemo(
() => selectedPlan ? platforms.find((p) => p.spacecraftId === selectedPlan.spacecraftId) : undefined,
[platforms, selectedPlan]
);
const satelliteLabel = selectedPlan
? platformLabel(planPlatform, selectedPlan.spacecraftId)
: hiddenIds.size > 0
? `Скрыто: ${hiddenIds.size} КА`
: "Все КА";
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({
range: mapInterval.range,
selectedPlan,
platforms,
hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : [],
stationItems: layers.stations ? stations : []
}),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
);
const layerAvailability = useMemo(
() => getLayerAvailability(layers, scene, stationsFetchFailed),
[layers, scene, stationsFetchFailed]
);
const overlayMessage = invalidRange
? "Некорректный интервал: from должен быть меньше to."
: mapInterval.tooLarge
? TGU_2D_MAP_INTERVAL_WARNING
: undefined;
const toggleLayer = (key: Tgu2DMapLayerKey) => {
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") {
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" });
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []);
const handleRequestsAtPoint = useCallback(
(polygons: MapPolygon[]) => {
if (polygons.length === 0) {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setStationPanel(undefined);
},
[requestsState.items]
);
const closeRequestsPanel = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
}, []);
const closeStationPanel = useCallback(() => {
setStationPanel(undefined);
}, []);
return (
<main className="tgu-map-workspace">
<Tgu2DMapToolbar
satelliteLabel={satelliteLabel}
intervalLabel={intervalLabel}
layers={layers}
onRefresh={() => setRedrawToken((value) => value + 1)}
onToggleLayer={toggleLayer}
/>
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<Tgu2DMapSidebar
platforms={platforms}
selectedPlanId={selectedPlan?.planId}
hiddenIds={hiddenIds}
layers={layers}
search={search}
layerAvailability={layerAvailability}
requestsState={requestsState}
onSearchChange={setSearch}
onToggleSpacecraft={toggleSpacecraft}
onShowAll={showAll}
onToggleLayer={toggleLayer}
onRequestsFromChange={setRequestsFrom}
onRequestsToChange={setRequestsTo}
onRequestsApply={applyRequestsRange}
onRequestsQuickRange={setRequestsQuickRange}
/>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers}
redrawToken={redrawToken}
onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/>
</section>
{requestsAtPoint.length > 0 && (
<Tgu2DMapRequestsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onClose={closeRequestsPanel}
/>
)}
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)}
</div>
<div className="tgu-map-errors">
{Object.entries(layerAvailability).map(([layer, message]) =>
message ? (
<div className="tgu-alert tgu-alert--warning" key={layer}>
{layerLabel(layer as Tgu2DMapLayerKey)}: {message}
</div>
) : null
)}
</div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} />
</main>
);
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function layerLabel(layer: Tgu2DMapLayerKey): string {
switch (layer) {
case "tracks":
return "Трасса КА";
case "swath":
return "Полоса обзора";
case "planWorks":
return "Работы плана";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
case "requests":
return "Заявки на съёмку";
}
}