Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx
T
Дмитрий Соловьев 0128c5569c 2D-карта (станции, заявки) и вкладка «Заявки»
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
2026-06-01 22:17:50 +03:00

533 lines
19 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = {
scene: Tgu2DMapScene;
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 = {
id: string;
name: string;
lat: number;
lon: number;
};
type DragState = {
startX: number;
startY: number;
centerAtStart: {
lon: number;
lat: number;
};
};
type TooltipState = {
x: number;
y: number;
title: string;
subtitle: string;
};
const INITIAL_VIEW: MapViewState = {
centerLon: 45,
centerLat: 30,
zoom: 2
};
export function Tgu2DMapView({
scene,
layers,
redrawToken,
onObjectSelect,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
pickMode = false,
onPickPoint,
targets = [],
pickTarget,
selectedTargetId,
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);
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
const [tooltip, setTooltip] = useState<TooltipState>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const activePolygons = useMemo(
() => scene.polygons.filter((polygon) => layers[polygon.layer]),
[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 —
// bbox filtering would produce a nonsensical range, so skip it.
// When the viewport crosses the antimeridian, topLeft.lon ends up east of
// bottomRight.lon — same situation, show everything.
const topLeft = projection.unproject({ x: 0, y: 0 });
const bottomRight = projection.unproject({ x: size.width, y: size.height });
if (size.width >= projection.worldSize || topLeft.lon >= bottomRight.lon) {
return activePolygons;
}
return queryPolygonsByBBox(polygonIndex, {
minLon: topLeft.lon,
maxLon: bottomRight.lon,
minLat: Math.min(topLeft.lat, bottomRight.lat),
maxLat: Math.max(topLeft.lat, bottomRight.lat)
});
}, [activePolygons, polygonIndex, projection, size.height, size.width]);
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
setSize({
width: Math.max(320, element.clientWidth),
height: Math.max(320, element.clientHeight)
});
});
resizeObserver.observe(element);
setSize({
width: Math.max(320, element.clientWidth),
height: Math.max(320, element.clientHeight)
});
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const frameId = window.requestAnimationFrame(() => {
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.floor(size.width * dpr);
canvas.height = Math.floor(size.height * dpr);
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, size.width, size.height);
drawTiles(context, view, projection, size, handleTileLoad);
drawBaseMap(context, projection);
if (layers.requests) {
drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size);
}
if (layers.swath) {
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
}
if (layers.planWorks) {
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
}
if (layers.tracks) {
drawTracks(context, scene.lines.filter((line) => layers[line.layer]), projection);
}
if (layers.stations) {
drawStations(context, scene.markers.filter((marker) => marker.layer === "stations"), projection);
}
});
return () => window.cancelAnimationFrame(frameId);
}, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]);
const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
const bounds = wrapperRef.current?.getBoundingClientRect();
return {
x: bounds ? event.clientX - bounds.left : 0,
y: bounds ? event.clientY - bounds.top : 0
};
}, []);
const updateHover = useCallback(
(event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
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 (!marker && !polygon) {
setTooltip(undefined);
return;
}
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: marker ? marker.label : polygon!.name,
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
});
},
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
};
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
updateHover(event);
return;
}
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,
centerLat: drag.centerAtStart.lat,
zoom: view.zoom
},
size
);
const nextCenter = dragProjection.unproject({ x: size.width / 2 - dx, y: size.height / 2 - dy });
setView((current) => ({
...current,
centerLon: nextCenter.lon,
centerLat: Math.max(-82, Math.min(82, nextCenter.lat))
}));
};
const onPointerUp = (event: React.PointerEvent) => {
dragRef.current = undefined;
wrapperRef.current?.releasePointerCapture(event.pointerId);
};
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 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);
return;
}
onObjectSelect({
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status
});
};
const onWheel = (event: React.WheelEvent) => {
event.preventDefault();
const screenPoint = screenPointFromEvent(event);
const before = projection.unproject(screenPoint);
const nextZoom = Math.max(0.8, Math.min(8, view.zoom + (event.deltaY < 0 ? 0.35 : -0.35)));
const nextProjection = createMapProjection({ ...view, zoom: nextZoom }, size);
const after = nextProjection.unproject(screenPoint);
setView((current) => ({
...current,
zoom: nextZoom,
centerLon: current.centerLon + before.lon - after.lon,
centerLat: Math.max(-82, Math.min(82, current.centerLat + before.lat - after.lat))
}));
};
const selectedPolygon = selectedPolygonRef.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 || drawMode ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
}}
onWheel={onWheel}
>
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
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;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className={cls}
/>
);
});
})}
{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;
return (
<g
className={`tgu-2d-map__target tgu-2d-map__target--confirmed${isSelected ? " is-selected" : ""}`}
key={t.id}
transform={`translate(${screen.x} ${screen.y})`}
onClick={(e) => { e.stopPropagation(); onTargetSelect?.(t.id); }}
style={{ cursor: "pointer" }}
>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{t.name}</text>
</g>
);
})}
{pickTarget && (() => {
const screen = projection.project(pickTarget);
return (
<g className="tgu-2d-map__target tgu-2d-map__target--pending" key="pick" transform={`translate(${screen.x} ${screen.y})`}>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{pickTarget.name}</text>
</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 }}>
<div>{tooltip.title}</div>
<span>{tooltip.subtitle}</span>
</div>
)}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
<div className="tgu-2d-map__zoom">
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
<button type="button" onClick={() => setView(INITIAL_VIEW)}></button>
</div>
</div>
);
}
function layerTitle(layer: MapPolygon["layer"]): string {
switch (layer) {
case "planWorks":
return "Работы плана";
case "swath":
return "Полоса обзора";
case "tracks":
return "Трасса КА";
case "requests":
return "Заявка на съёмку";
}
}
function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: number; y: number }[] {
if (points.length === 0) return [];
const out: { x: number; y: number }[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
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>();
for (const polygon of polygons) {
if (!polygon || ids.has(polygon.id)) continue;
ids.add(polygon.id);
result.push(polygon);
}
return result;
}