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(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); const hoverRef = 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 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 (
{ dragRef.current = undefined; hoverRef.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 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; }