2D-карта (станции, заявки) и вкладка «Заявки»

Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:50 +03:00
parent 715b5108d6
commit 0128c5569c
26 changed files with 1851 additions and 96 deletions
@@ -1,8 +1,9 @@
import type { StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { parseWktPolygon } from "./geometry/wktParser";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
@@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = {
platforms: TguPlatformUi[];
hiddenSpacecraftIds?: ReadonlySet<string>;
requestItems?: RequestMapItem[];
stationItems?: StationDto[];
};
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
const stationMarkers = buildStationMarkers(input.stationItems ?? []);
return {
polygons: requestPolygons,
lines: [],
markers: []
markers: stationMarkers
};
}
@@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] {
return polygons;
}
export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability {
export function buildStationMarkers(items: StationDto[]): MapMarker[] {
return items.map((item, index) => ({
id: item.id ?? `station:${index}`,
layer: "stations" as const,
point: { lon: item.position.long, lat: item.position.lat },
label: item.name,
zIndex: index,
stationNumber: item.number,
altitude: item.position.height,
elevationMin: item.elevationMin,
elevationMax: item.elevationMax
}));
}
export function getLayerAvailability(
layers: Tgu2DMapLayersState,
scene: Tgu2DMapScene,
stationsFetchFailed?: boolean
): Tgu2DMapLayerAvailability {
const availability: Tgu2DMapLayerAvailability = {};
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
availability.tracks = UNAVAILABLE;
@@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
availability.planWorks = UNAVAILABLE;
}
if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) {
if (layers.stations && stationsFetchFailed) {
availability.stations = UNAVAILABLE;
}
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
@@ -0,0 +1,115 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onClose: () => void;
};
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Заявки в точке ({requests.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "is-selected" : ""}`}
type="button"
onMouseEnter={() => onHover(polygon.id)}
onMouseLeave={() => onHover(undefined)}
onClick={() => onSelect(polygon.id)}
>
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
<span className="tgu-map-request-row__meta">
{formatDateRange(item.beginDateTime, item.endDateTime)}
{" · "}
{item.surveyType}
{" · "}
{item.status}
</span>
</span>
</button>
))}
</div>
{selectedEntry && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали заявки</div>
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{selectedEntry.item.id}</dd>
<dt>Название</dt>
<dd>{selectedEntry.item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{selectedEntry.item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{selectedEntry.item.surveyType}</dd>
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{selectedEntry.item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
<dt>Создано</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
<dt>Изменено</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(begin);
const e = new Date(end);
const sameDay =
b.getFullYear() === e.getFullYear() &&
b.getMonth() === e.getMonth() &&
b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
return `${beginStr} ${endStr}`;
}
@@ -70,7 +70,7 @@ export function Tgu2DMapSidebar({
return (
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Карта 2D</span>
<span>Карта</span>
{hiddenCount > 0 && (
<button className="tgu-link-button" type="button" onClick={onShowAll}>
Показать все ({hiddenCount} скрыто)
@@ -202,19 +202,7 @@ export function Tgu2DMapSidebar({
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Статусы планов</div>
<div className="tgu-map-status-legend">
<span><i className="status-accepted" />ACCEPTED</span>
<span><i className="status-rejected" />REJECTED</span>
<span><i className="status-waiting" />WAITING_DECISION</span>
<span><i className="status-planned" />PLANNED</span>
<span><i className="status-issuing" />ISSUING</span>
<span><i className="status-expired" />EXPIRED</span>
<span><i className="status-superseded" />SUPERSEDED</span>
<span><i className="status-ambiguous" />START_AMBIGUOUS</span>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,43 @@
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
type Props = {
selection: StationSelection;
onClose: () => void;
};
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Станция</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<div className="tgu-map-panel__label">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</div>
</aside>
);
}
@@ -1,13 +1,17 @@
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";
@@ -56,6 +60,18 @@ export function Tgu2DMapTab({
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;
@@ -146,11 +162,15 @@ export function Tgu2DMapTab({
selectedPlan,
platforms,
hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : []
requestItems: layers.requests ? requestsState.items : [],
stationItems: layers.stations ? stations : []
}),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests]
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
);
const layerAvailability = useMemo(
() => getLayerAvailability(layers, scene, stationsFetchFailed),
[layers, scene, stationsFetchFailed]
);
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
const overlayMessage = invalidRange
? "Некорректный интервал: from должен быть меньше to."
@@ -162,6 +182,49 @@ export function Tgu2DMapTab({
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
@@ -172,7 +235,7 @@ export function Tgu2DMapTab({
onToggleLayer={toggleLayer}
/>
<div className="tgu-map-content">
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<Tgu2DMapSidebar
platforms={platforms}
selectedPlanId={selectedPlan?.planId}
@@ -196,9 +259,24 @@ export function Tgu2DMapTab({
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers}
redrawToken={redrawToken}
onObjectSelect={setSelectedObject}
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">
@@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({
return (
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Карта 2D</div>
<div className="tgu-map-toolbar__title">Карта</div>
<div className="tgu-map-toolbar__subtitle">
{satelliteLabel} · {intervalLabel}
</div>
@@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = {
@@ -17,12 +17,18 @@ type Tgu2DMapViewProps = {
layers: Tgu2DMapLayersState;
redrawToken: number;
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
hoveredRequestPolygonId?: string;
selectedRequestPolygonId?: string;
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string;
onTargetSelect?: (id: string) => void;
drawMode?: boolean;
draftPolygon?: GeoPoint[];
onDrawAddPoint?: (point: GeoPoint) => void;
};
export type Tgu2DMapTarget = {
@@ -59,17 +65,26 @@ export function Tgu2DMapView({
layers,
redrawToken,
onObjectSelect,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
pickMode = false,
onPickPoint,
targets = [],
pickTarget,
selectedTargetId,
onTargetSelect
onTargetSelect,
drawMode = false,
draftPolygon = [],
onDrawAddPoint
}: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0);
@@ -81,6 +96,10 @@ export function Tgu2DMapView({
[scene.polygons, layers]
);
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const activeMarkers = useMemo(
() => scene.markers.filter((marker) => layers[marker.layer]),
[scene.markers, layers]
);
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => {
// When the canvas is wider than the world tile, the entire globe is visible —
@@ -170,11 +189,15 @@ export function Tgu2DMapView({
const updateHover = useCallback(
(event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (polygon?.id === hoverRef.current?.id) return;
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
hoverMarkerRef.current = marker;
hoverRef.current = polygon;
if (!polygon) {
if (!marker && !polygon) {
setTooltip(undefined);
return;
}
@@ -182,15 +205,16 @@ export function Tgu2DMapView({
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: polygon.name,
subtitle: layerTitle(polygon.layer)
title: marker ? marker.label : polygon!.name,
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
});
},
[polygonIndex, projection, screenPointFromEvent]
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
@@ -199,6 +223,8 @@ export function Tgu2DMapView({
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
@@ -209,6 +235,9 @@ export function Tgu2DMapView({
const screenPoint = screenPointFromEvent(event);
const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
hasDraggedRef.current = true;
}
const dragProjection = createMapProjection(
{
centerLon: drag.centerAtStart.lon,
@@ -231,15 +260,53 @@ export function Tgu2DMapView({
};
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
}
if (pickMode) {
onPickPoint?.(projection.unproject(screenPoint));
onObjectSelect(undefined);
return;
}
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
selectedPolygonRef.current = undefined;
onRequestsAtPoint?.([]);
onObjectSelect({
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
});
return;
}
const geoPoint = projection.unproject(screenPoint);
const polygon = hitTestPolygons(polygonIndex, geoPoint);
if (polygon?.layer === "requests" && onRequestsAtPoint) {
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
onRequestsAtPoint(allRequests);
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
onObjectSelect(undefined);
return;
}
onRequestsAtPoint?.([]);
selectedPolygonRef.current = polygon;
selectedMarkerRef.current = undefined;
if (!polygon) {
onObjectSelect(undefined);
@@ -276,11 +343,21 @@ export function Tgu2DMapView({
};
const selectedPolygon = selectedPolygonRef.current;
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
const selectedMarker = selectedMarkerRef.current;
const externalSelectedPolygon = useMemo(
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
[selectedRequestPolygonId, activePolygons]
);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
return (
<div
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
@@ -290,6 +367,7 @@ export function Tgu2DMapView({
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
}}
onWheel={onWheel}
@@ -299,7 +377,10 @@ export function Tgu2DMapView({
{overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered";
const cls =
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
? "is-selected"
: "is-hovered";
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
@@ -315,6 +396,11 @@ export function Tgu2DMapView({
);
});
})}
{overlayMarkers.map((marker) => {
const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
})}
{targets.map((t) => {
const screen = projection.project(t);
const isSelected = t.id === selectedTargetId;
@@ -344,6 +430,22 @@ export function Tgu2DMapView({
</g>
);
})()}
{draftPolygon.length > 0 && (() => {
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
return (
<g className="tgu-2d-map__draft">
{pts.length >= 2 && (
pts.length >= 3
? <polygon className="tgu-2d-map__draft-fill" points={path} />
: <polyline className="tgu-2d-map__draft-line" points={path} />
)}
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu
return out;
}
function hitTestMarkers(
markers: MapMarker[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
hitRadius = 10
): MapMarker | undefined {
let best: MapMarker | undefined;
let bestDist = hitRadius;
for (const marker of markers) {
const pt = projection.project(marker.point);
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
if (dist <= bestDist) {
bestDist = dist;
best = marker;
}
}
return best;
}
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
const result: MapMarker[] = [];
const ids = new Set<string>();
for (const marker of markers) {
if (!marker || ids.has(marker.id)) continue;
ids.add(marker.id);
result.push(marker);
}
return result;
}
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
const result: MapPolygon[] = [];
const ids = new Set<string>();
@@ -20,6 +20,28 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia
};
}
export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] {
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit) hits.push(entry.polygon);
}
return hits.sort((a, b) => b.zIndex - a.zIndex);
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined;
@@ -9,31 +9,16 @@ export type Tgu2DMapLayerDefinition = {
};
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "tracks",
label: "Трасса КА",
description: "Проекция движения КА на поверхность."
},
{
key: "swath",
label: "Полоса обзора",
description: "Массовые полосы обзора через canvas."
},
{
key: "planWorks",
label: "Работы плана",
description: "Полигоны работ выбранного плана."
description: "Маршруты съёмки из выбранного плана."
},
{
key: "stations",
label: "Станции",
description: "Наземные станции приёма."
},
{
key: "spacecraftMarkers",
label: "Маркеры КА",
description: "Текущие или расчётные позиции КА."
},
{
key: "requests",
label: "Заявки на съёмку",
@@ -42,11 +27,11 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
tracks: false,
swath: false,
planWorks: true,
stations: true,
spacecraftMarkers: true,
spacecraftMarkers: false,
requests: true
};
@@ -18,5 +18,11 @@ export type Tgu2DMapSelection =
name: string;
layer: "stations" | "spacecraftMarkers";
spacecraftId?: string;
stationNumber?: number;
lat?: number;
lon?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
@@ -50,6 +50,10 @@ export type MapMarker = {
point: GeoPoint;
label: string;
zIndex: number;
stationNumber?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
export type Tgu2DMapScene = {
@@ -45,13 +45,13 @@ describe("tgu2DMapInterval", () => {
});
});
it("enables all requested 2D map layers by default", () => {
it("enables plan, stations and requests layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: true,
swath: true,
tracks: false,
swath: false,
planWorks: true,
stations: true,
spacecraftMarkers: true,
spacecraftMarkers: false,
requests: true
});
});