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(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); const hasDraggedRef = useRef(false); const hoverRef = useRef(undefined); const hoverMarkerRef = useRef(undefined); const selectedMarkerRef = useRef(undefined); const [size, setSize] = useState({ width: 1000, height: 560 }); const [view, setView] = useState(INITIAL_VIEW); const [tileVersion, setTileVersion] = useState(0); const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const [tooltip, setTooltip] = useState(); const selectedPolygonRef = useRef(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 (
{ dragRef.current = undefined; hoverRef.current = undefined; hoverMarkerRef.current = undefined; setTooltip(undefined); }} onWheel={onWheel} > {tooltip && (
{tooltip.title}
{tooltip.subtitle}
)}
© OpenStreetMap contributors · © CARTO
); } 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, 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[] { const result: MapMarker[] = []; const ids = new Set(); 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[] { const result: MapPolygon[] = []; const ids = new Set(); for (const polygon of polygons) { if (!polygon || ids.has(polygon.id)) continue; ids.add(polygon.id); result.push(polygon); } return result; }