Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx
T
2026-06-25 16:21:17 +03:00

299 lines
10 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 { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { MapObjectInspector } from "./MapObjectInspector";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
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: toLocalInputValue(range.fromMs),
toValue: toLocalInputValue(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 [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 [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
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 = parseLocal(fromValue);
const toMs = parseLocal(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: toUtcIso(fromMs),
beginTo: toUtcIso(toMs)
},
(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 = toLocalInputValue(fromMs);
const toValue = toLocalInputValue(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] }));
};
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
setMapHits(selection ? [selection] : []);
setActiveHitId(selection?.id);
}, []);
const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
setMapHits(selections);
setActiveHitId(selections[0]?.id);
}, []);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(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${mapHits.length > 0 ? " has-side-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}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={activeHitId}
/>
</section>
{mapHits.length > 0 && (
<aside className="tgu-editor-side-panel">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInfoPanel}
requestItems={requestsState.items}
/>
</aside>
)}
</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} />
</main>
);
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
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 "complexPlan":
return "Комплексный план";
case "heatmap":
return "Тепловая карта";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
case "requests":
return "Заявки на съёмку";
}
}