2D-карта (станции, заявки) и вкладка «Заявки»
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user