Fixed coordinate bounding box math

This commit is contained in:
Tatu Laras
2025-06-19 14:57:14 +03:00
parent 918fd25476
commit 75923e7782
6 changed files with 138 additions and 56 deletions
@@ -0,0 +1,37 @@
import { Button, Modal } from '@mui/material'
import { SingleLocalityMap } from '../Map/SingleLocalityMap'
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
export interface OutOfBoundsWarningModalState {
decLong: number
decLat: number
boxes?: CountryBoundingBox[]
}
interface Props {
onAnswer: (answer: boolean) => void
state: OutOfBoundsWarningModalState
isOpen: boolean
}
export const OutOfBoundsWarningModal = ({ isOpen, state, onAnswer }: Props) => {
return (
<Modal open={isOpen} className={`modal ${isOpen ? 'open' : ''}`} onClick={() => onAnswer(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h3>Coordinate warning</h3>
<p style={{ marginBottom: '2em' }}>
The coordinate selected is outside of the locality country. Do you still want to proceed?
</p>
<SingleLocalityMap decLat={state.decLat} decLong={state.decLong} boxes={state.boxes} />
<div style={{ marginTop: '1em' }}>
<Button variant="contained" onClick={() => onAnswer(false)}>
Cancel
</Button>
<Button sx={{ marginLeft: '0.6em' }} variant="contained" onClick={() => onAnswer(true)}>
Proceed
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useNavigate } from 'react-router-dom'
import { Button, Box, Typography, CircularProgress, Divider, alpha, List, ListItemText, Modal } from '@mui/material'
import { Button, Box, Typography, CircularProgress, Divider, alpha, List, ListItemText } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SaveIcon from '@mui/icons-material/Save'
import { usePageContext } from '../Page'
@@ -9,32 +9,8 @@ import { EditDataType, Editable, Reference } from '@/shared/types'
import { useState, useEffect, Fragment } from 'react'
import { referenceValidator } from '@/shared/validators/validator'
import { countryBoundingBoxes } from '@/country_data/countryBoundingBoxes'
import { isPointInBox } from '@/util/isPointInBox'
export const OutOfBoundsWarningModal = ({
isOpen,
onAnswer,
}: {
isOpen: boolean
onAnswer: (answer: boolean) => void
}) => {
return (
<Modal open={isOpen} className={`modal ${isOpen ? 'open' : ''}`} onClick={() => onAnswer(false)}>
<div className="modal-content">
<h3>Coordinate warning</h3>
<p style={{ marginBottom: '2em' }}>
The coordinate selected is outside of the locality country. Do you still want to proceed?
</p>
<Button variant="contained" onClick={() => onAnswer(false)}>
Cancel
</Button>
<Button sx={{ marginLeft: '0.6em' }} variant="contained" onClick={() => onAnswer(true)}>
Proceed
</Button>
</div>
</Modal>
)
}
import { boundingBoxSplit, isPointInBoxes } from '@/util/isPointInBox'
import { OutOfBoundsWarningModal, OutOfBoundsWarningModalState } from './OutOfBoundsWarningModal'
export const WriteButton = <T,>({
onWrite,
@@ -47,7 +23,13 @@ export const WriteButton = <T,>({
useDetailContext<T>()
const [loading, setLoading] = useState(false)
const [outOfBoundsWarningOpen, setOutOfBoundsWarningOpen] = useState(false)
// For coordinate out of bounds warning
const [warningModalState, setWarningModalState] = useState<OutOfBoundsWarningModalState>({
decLong: 0,
decLat: 0,
boxes: undefined,
})
const [warningModalOpen, setWarningModalOpen] = useState(false)
const getButtonText = () => {
if (!mode.staging) return hasStagingMode ? 'Finalize entry' : 'Save changes'
@@ -57,7 +39,7 @@ export const WriteButton = <T,>({
// Checks if given coordinate is outside of country bounding box
const isCoordinateOutOfBounds = (dec_lat: number, dec_long: number, country: string): boolean => {
if (!(country in countryBoundingBoxes)) return false
return !isPointInBox(dec_lat, dec_long, countryBoundingBoxes[country])
return !isPointInBoxes(dec_lat, dec_long, boundingBoxSplit(countryBoundingBoxes[country]))
}
/* validates all fields when entering new/editing mode
@@ -133,11 +115,12 @@ export const WriteButton = <T,>({
return (
<>
<OutOfBoundsWarningModal
isOpen={outOfBoundsWarningOpen}
isOpen={warningModalOpen}
onAnswer={isOkay => {
setOutOfBoundsWarningOpen(false)
setWarningModalOpen(false)
if (isOkay) save()
}}
state={warningModalState}
/>
<Button
disabled={Object.keys(fieldsWithErrors).length > 0}
@@ -156,14 +139,19 @@ export const WriteButton = <T,>({
}
const localityObject = editData as unknown as { dec_lat: number; dec_long: number; country: string }
if (
!isCoordinateOutOfBounds(localityObject['dec_lat'], localityObject['dec_long'], localityObject['country'])
) {
if (!isCoordinateOutOfBounds(localityObject.dec_lat, localityObject.dec_long, localityObject.country)) {
save()
return
}
setOutOfBoundsWarningOpen(true)
setWarningModalOpen(true)
setWarningModalState({
decLat: localityObject.dec_lat,
decLong: localityObject.dec_long,
// Guaranteed to exist by the isCoordinateOutOfBounds check above,
// as it will return false and return on no key found
boxes: boundingBoxSplit(countryBoundingBoxes[localityObject.country]),
})
}}
variant="contained"
>
@@ -4,23 +4,25 @@ import { countryPolygons } from '../../country_data/countryPolygons.ts'
import L, { LatLngExpression } from 'leaflet'
import '../../styles/SingleLocalityMap.css'
import northarrow from './images/north-arrow.png'
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes.ts'
interface Props {
dec_lat: number | undefined
dec_long: number | undefined
decLat?: number
decLong?: number
boxes?: CountryBoundingBox[]
}
export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
export const SingleLocalityMap = ({ decLat, decLong, boxes }: Props) => {
const mapRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<L.Map | null>(null)
useEffect(() => {
if (!mapRef.current || !dec_lat || !dec_long) return
if (!mapRef.current || !decLat || !decLong) return
const mapInstance = L.map(mapRef.current, { maxZoom: 16 })
setMap(mapInstance)
const coords: LatLngExpression = [dec_lat, dec_long]
const coords: LatLngExpression = [decLat, decLong]
mapInstance.setView(coords, 4)
// ---- Base maps ----
@@ -80,15 +82,29 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
return () => {
mapInstance.remove()
}
}, [dec_lat, dec_long])
}, [decLat, decLong])
useEffect(() => {
if (!map || !dec_lat || !dec_long) return
if (!map || !decLat || !decLong) return
if (boxes) {
boxes.forEach(box => {
L.polygon(
[
[box.top, box.left],
[box.top, box.right],
[box.bottom, box.right],
[box.bottom, box.left],
] as LatLngExpression[],
{ color: 'blue' }
).addTo(map)
})
}
const newMarker = L.layerGroup()
newMarker.addLayer(
L.circleMarker([dec_lat, dec_long], {
L.circleMarker([decLat, decLong], {
radius: 4,
color: '#db2c2c',
fillColor: '#d95050',
@@ -105,7 +121,7 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
return () => {
map.removeLayer(newMarker)
}
}, [map, dec_lat, dec_long])
}, [map, decLat, decLong])
document.title = 'Map'
File diff suppressed because one or more lines are too long
+23 -5
View File
@@ -48,6 +48,13 @@ function main() {
right: null,
}
const halvedBounds = {
top: null,
left: null,
bottom: null,
right: null,
}
feature.geometry.coordinates.forEach(poly => {
polygons.push(
poly[0].map(coord => {
@@ -58,13 +65,21 @@ function main() {
if (!bounds.bottom) bounds.bottom = lat
if (!bounds.left) bounds.left = long
if (!bounds.right) bounds.right = long
bounds.top = Math.min(bounds.top, lat)
bounds.bottom = Math.max(bounds.bottom, lat)
bounds.left = Math.min(bounds.left, long)
bounds.right = Math.max(bounds.right, long)
if (!halvedBounds.top) halvedBounds.top = lat
if (!halvedBounds.bottom) halvedBounds.bottom = lat
if (!halvedBounds.right) halvedBounds.right = -180
if (!halvedBounds.left) halvedBounds.left = long
halvedBounds.top = Math.min(halvedBounds.top, lat)
halvedBounds.bottom = Math.max(halvedBounds.bottom, lat)
if (long < 0) halvedBounds.right = Math.max(halvedBounds.right, long)
else halvedBounds.left = Math.min(halvedBounds.left, long)
if ((long < 0 && bounds.left < 0) || (long > 0 && bounds.left > 0)) bounds.left = Math.min(bounds.left, long)
if ((long < 0 && bounds.right < 0) || (long > 0 && bounds.right > 0))
bounds.right = Math.max(bounds.right, long)
return [lat, long]
})
)
@@ -73,7 +88,10 @@ function main() {
let country = feature.properties.name
if (country in nameMappings) country = nameMappings[country]
boundingBoxes[country] = bounds
const boundsWidth = bounds.right - bounds.left
const halvedBoundsWidth = halvedBounds.right + 180 + 180 - halvedBounds.left
const box = boundsWidth < halvedBoundsWidth ? bounds : halvedBounds
boundingBoxes[country] = box
})
const polygonString = 'export const countryPolygons: number[][][] = ' + JSON.stringify(polygons) + ';'
+29 -6
View File
@@ -1,11 +1,34 @@
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
// Checks whether or not given latitude longitude point is within a bounding box.
export const isPointInBox = (dec_lat: number, dec_long: number, box: CountryBoundingBox): boolean => {
if (dec_lat < box.top) return false
if (dec_lat > box.bottom) return false
if (dec_long < box.left) return false
if (dec_long > box.right) return false
export const isPointInBoxes = (dec_lat: number, dec_long: number, boxes: CountryBoundingBox[]): boolean => {
for (const box of boxes) {
if (dec_lat < box.top) continue
if (dec_lat > box.bottom) continue
if (dec_long < box.left) continue
if (dec_long > box.right) continue
return true
return true
}
return false
}
// Splits bounding box that wraps around the longitude
export const boundingBoxSplit = (box: CountryBoundingBox): CountryBoundingBox[] => {
if (box.right >= box.left) return [box]
return [
{
top: box.top,
bottom: box.bottom,
left: -180,
right: box.right,
},
{
top: box.top,
bottom: box.bottom,
left: box.left,
right: 180,
},
]
}