281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
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<Cesium.Cartesian3[]>([])
|
|
|
|
// ── 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])
|
|
}
|