import { useEffect, useRef } from 'react' import * as Cesium from 'cesium' import { useCesiumViewer } from '../cesium/cesiumContext' import { useChallengeStore } from '../store/challengeStore' function vertsToGeoJson(verts: Cesium.Cartesian3[]): GeoJSON.Polygon { const coords: [number, number][] = verts.map((v) => { const c = Cesium.Cartographic.fromCartesian(v) return [Cesium.Math.toDegrees(c.longitude), Cesium.Math.toDegrees(c.latitude)] }) return { type: 'Polygon', coordinates: [[...coords, coords[0]]] } } function geoJsonToVerts(polygon: GeoJSON.Polygon): Cesium.Cartesian3[] { const ring = polygon.coordinates[0] // Drop the closing duplicate point return ring.slice(0, -1).map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)) } function pickGlobe( viewer: Cesium.Viewer, windowPos: Cesium.Cartesian2, ): Cesium.Cartesian3 | null { const ray = viewer.camera.getPickRay(windowPos) if (!ray) return null return viewer.scene.globe.pick(ray, viewer.scene) ?? null } /** * Manages two phases of polygon interaction: * * Drawing (drawingMode=true) * LEFT_CLICK → place vertex * MOUSE_MOVE → rubber-band line from last vertex to cursor * RIGHT_CLICK → close polygon (≥3 verts), enter edit phase * * Editing (drawingMode=false, draftPolygon set) * Drag vertex handles to reposition them. * Changes are written back to the store on mouse-up so the submit * form always reads the latest geometry. */ export function usePolygonDraw() { const viewer = useCesiumViewer() const { drawingMode, setDrawingMode, setDraftPolygon, draftPolygon } = useChallengeStore() // Persists vertex positions across edit-phase effect re-runs that are // triggered by setDraftPolygon being called after each drag. const editVertsRef = useRef([]) // ── Drawing phase ────────────────────────────────────────────────────────── useEffect(() => { if (!drawingMode) return const verts: Cesium.Cartesian3[] = [] const vertPointEntities: Cesium.Entity[] = [] let outlineEntity: Cesium.Entity | null = null let rubberBandEntity: Cesium.Entity | null = null const canvas = viewer.scene.canvas // Prevent the browser context menu so right-click can close the polygon. const suppressContextMenu = (e: MouseEvent) => e.preventDefault() canvas.addEventListener('contextmenu', suppressContextMenu) const handler = new Cesium.ScreenSpaceEventHandler(canvas) function refreshOutline() { if (outlineEntity) { viewer.entities.remove(outlineEntity) outlineEntity = null } if (verts.length < 2) return outlineEntity = viewer.entities.add({ polyline: { positions: [...verts, verts[0]], width: 2, material: new Cesium.ColorMaterialProperty( Cesium.Color.YELLOW.withAlpha(0.9), ), clampToGround: true, }, }) } function refreshRubberBand(mousePos: Cesium.Cartesian3) { if (rubberBandEntity) { viewer.entities.remove(rubberBandEntity) rubberBandEntity = null } if (verts.length === 0) return rubberBandEntity = viewer.entities.add({ polyline: { positions: [verts[verts.length - 1], mousePos], width: 1.5, material: new Cesium.ColorMaterialProperty( Cesium.Color.WHITE.withAlpha(0.5), ), clampToGround: true, }, }) } handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => { const pos = pickGlobe(viewer, e.endPosition) if (pos) refreshRubberBand(pos) }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) handler.setInputAction((e: { position: Cesium.Cartesian2 }) => { const pos = pickGlobe(viewer, e.position) if (!pos) return verts.push(pos.clone()) vertPointEntities.push( viewer.entities.add({ position: pos, point: { pixelSize: 8, color: Cesium.Color.YELLOW, outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }), ) refreshOutline() }, Cesium.ScreenSpaceEventType.LEFT_CLICK) handler.setInputAction(() => { if (verts.length < 3) return const polygon = vertsToGeoJson(verts) cleanup() setDraftPolygon(polygon) setDrawingMode(false) }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) function cleanup() { if (viewer.isDestroyed()) return vertPointEntities.forEach((e) => viewer.entities.remove(e)) vertPointEntities.length = 0 if (outlineEntity) { viewer.entities.remove(outlineEntity) outlineEntity = null } if (rubberBandEntity) { viewer.entities.remove(rubberBandEntity) rubberBandEntity = null } } return () => { handler.destroy() canvas.removeEventListener('contextmenu', suppressContextMenu) cleanup() } }, [drawingMode, viewer, setDrawingMode, setDraftPolygon]) // ── Edit phase ───────────────────────────────────────────────────────────── useEffect(() => { if (drawingMode || !draftPolygon) { editVertsRef.current = [] return } // Only initialise from the store on the first entry into edit mode. // Subsequent runs (triggered by setDraftPolygon after each drag) reuse // the already-mutated ref so vertex positions are not reset. if (editVertsRef.current.length === 0) { editVertsRef.current = geoJsonToVerts(draftPolygon) } const verts = editVertsRef.current let draggingIndex = -1 const entities: Cesium.Entity[] = [] const canvas = viewer.scene.canvas const suppressContextMenu = (e: MouseEvent) => e.preventDefault() canvas.addEventListener('contextmenu', suppressContextMenu) // Filled polygon entities.push( viewer.entities.add({ polygon: { hierarchy: new Cesium.CallbackProperty( () => new Cesium.PolygonHierarchy(verts), false, ), material: Cesium.Color.YELLOW.withAlpha(0.15), }, }), ) // Outline entities.push( viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty( () => [...verts, verts[0]], false, ), width: 2, material: new Cesium.ColorMaterialProperty( Cesium.Color.YELLOW.withAlpha(0.9), ), clampToGround: true, }, }), ) // Vertex handles — one point entity per vertex, driven by CallbackProperty // so they track the mutable verts array without entity recreation. const vertEntities: Cesium.Entity[] = verts.map((_, i) => { const e = viewer.entities.add({ // eslint-disable-next-line @typescript-eslint/no-explicit-any position: new Cesium.CallbackProperty(() => verts[i], false) as any, point: { pixelSize: 10, color: Cesium.Color.WHITE, outlineColor: Cesium.Color.YELLOW, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }) entities.push(e) return e }) const handler = new Cesium.ScreenSpaceEventHandler(canvas) handler.setInputAction((e: { endPosition: Cesium.Cartesian2 }) => { if (draggingIndex !== -1) { // Update dragged vertex const pos = pickGlobe(viewer, e.endPosition) if (pos) verts[draggingIndex] = pos return } // Cursor feedback const picked = viewer.scene.pick(e.endPosition) const overVertex = picked?.id instanceof Cesium.Entity && vertEntities.includes(picked.id) canvas.style.cursor = overVertex ? 'grab' : 'default' }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) handler.setInputAction((e: { position: Cesium.Cartesian2 }) => { const picked = viewer.scene.pick(e.position) if (!(picked?.id instanceof Cesium.Entity)) return const idx = vertEntities.indexOf(picked.id) if (idx === -1) return draggingIndex = idx canvas.style.cursor = 'grabbing' viewer.scene.screenSpaceCameraController.enableRotate = false viewer.scene.screenSpaceCameraController.enableTranslate = false }, Cesium.ScreenSpaceEventType.LEFT_DOWN) handler.setInputAction(() => { if (draggingIndex === -1) return draggingIndex = -1 canvas.style.cursor = 'default' viewer.scene.screenSpaceCameraController.enableRotate = true viewer.scene.screenSpaceCameraController.enableTranslate = true // Sync updated geometry back to the store for the submit form. setDraftPolygon(vertsToGeoJson(verts)) }, Cesium.ScreenSpaceEventType.LEFT_UP) function cleanup() { if (viewer.isDestroyed()) return entities.forEach((e) => viewer.entities.remove(e)) entities.length = 0 canvas.style.cursor = 'default' viewer.scene.screenSpaceCameraController.enableRotate = true viewer.scene.screenSpaceCameraController.enableTranslate = true } return () => { handler.destroy() canvas.removeEventListener('contextmenu', suppressContextMenu) cleanup() } }, [drawingMode, draftPolygon, viewer, setDraftPolygon]) }