0128c5569c
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки.
533 lines
19 KiB
TypeScript
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;
|
|
}
|