299 lines
10 KiB
TypeScript
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 "Заявки на съёмку";
|
|
}
|
|
}
|