637a5e8848
На вкладке Редактор 2D-карта теперь показывает включения выбранного плана
и новые съёмки, добавленные в текущей сессии.
- editorApi: типы PlanSurveyMode/PlanDropMode/PlanMode и fetchPlanModes —
вызывает GET /api/current-plans/missions/{planId}/modes на pcp-mission-planing-service,
где planId TGU совпадает с missionId (выяснено по Camunda-процессу)
- TguEditorTab: загружает включения при выборе плана, строит MapPolygon[]
из SURVEY-режимов через parseWktPolygon(contourWkt), включён слой planWorks
- Tgu2DMapView: разделены pending-маркер (оранжевый, выбор точки)
и confirmed-маркеры (бирюзовые, подтверждённые съёмки из драфта);
клик на маркер съёмки открывает WorkInspector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
13 KiB
TypeScript
377 lines
13 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, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
|
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
|
import type { 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;
|
|
pickMode?: boolean;
|
|
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
|
targets?: Tgu2DMapTarget[];
|
|
pickTarget?: Tgu2DMapTarget;
|
|
selectedTargetId?: string;
|
|
onTargetSelect?: (id: string) => 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,
|
|
pickMode = false,
|
|
onPickPoint,
|
|
targets = [],
|
|
pickTarget,
|
|
selectedTargetId,
|
|
onTargetSelect
|
|
}: Tgu2DMapViewProps) {
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const dragRef = useRef<DragState | undefined>(undefined);
|
|
const hoverRef = useRef<MapPolygon | 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 projection = useMemo(() => createMapProjection(view, size), [view, size]);
|
|
const visiblePolygons = useMemo(() => {
|
|
const topLeft = projection.unproject({ x: 0, y: 0 });
|
|
const bottomRight = projection.unproject({ x: size.width, y: size.height });
|
|
const minLon = Math.min(topLeft.lon, bottomRight.lon);
|
|
const maxLon = Math.max(topLeft.lon, bottomRight.lon);
|
|
|
|
if (maxLon - minLon > 300) {
|
|
return activePolygons;
|
|
}
|
|
|
|
return queryPolygonsByBBox(polygonIndex, {
|
|
minLon,
|
|
maxLon,
|
|
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 polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
|
if (polygon?.id === hoverRef.current?.id) return;
|
|
|
|
hoverRef.current = polygon;
|
|
if (!polygon) {
|
|
setTooltip(undefined);
|
|
return;
|
|
}
|
|
|
|
setTooltip({
|
|
x: screenPoint.x + 12,
|
|
y: screenPoint.y + 12,
|
|
title: polygon.name,
|
|
subtitle: layerTitle(polygon.layer)
|
|
});
|
|
},
|
|
[polygonIndex, projection, screenPointFromEvent]
|
|
);
|
|
|
|
const onPointerDown = (event: React.PointerEvent) => {
|
|
const screenPoint = screenPointFromEvent(event);
|
|
dragRef.current = {
|
|
startX: screenPoint.x,
|
|
startY: screenPoint.y,
|
|
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
|
|
};
|
|
wrapperRef.current?.setPointerCapture(event.pointerId);
|
|
};
|
|
|
|
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;
|
|
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) => {
|
|
const screenPoint = screenPointFromEvent(event);
|
|
if (pickMode) {
|
|
onPickPoint?.(projection.unproject(screenPoint));
|
|
onObjectSelect(undefined);
|
|
return;
|
|
}
|
|
|
|
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
|
selectedPolygonRef.current = polygon;
|
|
|
|
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 overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
|
|
|
|
return (
|
|
<div
|
|
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
|
|
ref={wrapperRef}
|
|
onClick={onClick}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
onPointerLeave={() => {
|
|
dragRef.current = undefined;
|
|
hoverRef.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.map((polygon) => (
|
|
<polygon
|
|
key={polygon.id}
|
|
points={polygon.points.map((point) => {
|
|
const screen = projection.project(point);
|
|
return `${screen.x},${screen.y}`;
|
|
}).join(" ")}
|
|
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
|
|
/>
|
|
))}
|
|
{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>
|
|
);
|
|
})()}
|
|
</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 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;
|
|
}
|