Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3168052
feat: add device location height
jona159 Aug 18, 2026
474c18a
fix: dont allow to overwrite an existing height with null
jona159 Aug 19, 2026
0f3ab8f
feat: add helper for geojson position
jona159 Aug 19, 2026
c21cdb9
fix: expect precise value
jona159 Aug 19, 2026
0deee57
feat: change height input to height above ground with automatic terra…
jona159 Aug 19, 2026
20d4679
fix: rm elevation service
jona159 Aug 20, 2026
60aba2c
feat: update readme, env with open topo data config
jona159 Aug 20, 2026
8d83d43
feat: elevation types, calculate height above sea level
jona159 Aug 20, 2026
609faae
feat: add deviceLocationInputSchema. simplify
jona159 Aug 20, 2026
dc13e63
feat: reuse new schema
jona159 Aug 20, 2026
e24d7a6
feat: calculate height when creating new device
jona159 Aug 20, 2026
c2f0cd2
feat: add openapi schema
jona159 Aug 20, 2026
a63a125
feat: add elevation service and resource route
jona159 Aug 20, 2026
080c3c2
feat: use elevation hook
jona159 Aug 20, 2026
a6fd0bf
feat: calculate height in location step and show in summary
jona159 Aug 20, 2026
4209a36
feat: exchange schema
jona159 Aug 20, 2026
fdbb0e8
feat: adjust update device height
jona159 Aug 20, 2026
e73aba7
feat: calculate height when editing device
jona159 Aug 20, 2026
900e51b
format adjustments
jona159 Aug 20, 2026
48c1b7a
feat: translations
jona159 Aug 20, 2026
e8b1eb5
fix: test
jona159 Aug 20, 2026
b795ab4
Merge branch 'dev' into feat/device-location-height
jona159 Aug 21, 2026
8dffece
fix: apply finite check, translation key
jona159 Aug 21, 2026
4de143b
fix: clear previous result before debounce
jona159 Aug 21, 2026
9c8ad07
fix: rm unused
jona159 Aug 21, 2026
99d8fc2
feat: outsource schemas and validate submission
jona159 Aug 21, 2026
46d1e5d
fix: authenticate before parsing
jona159 Aug 21, 2026
bf522b8
feat: bounded queue
jona159 Aug 21, 2026
ffde7d3
feat: improve accessibility
jona159 Aug 21, 2026
a65e6d7
fix: mock elevation service in tests
jona159 Aug 21, 2026
34261de
feat: improve readability
jona159 Aug 21, 2026
ce75850
feat: add comment
jona159 Aug 21, 2026
586f543
fix: rm section from readme
jona159 Aug 21, 2026
7432dab
fix: type
jona159 Aug 21, 2026
80a9083
feat: add and reuse longitude and latitude schemas for openapi docs
jona159 Aug 25, 2026
ca905d8
feat: translate location error messages
jona159 Aug 25, 2026
cc43d93
fix: parse watched values through zod schema
jona159 Aug 25, 2026
2a84b64
fix: check location issues seperately to keep returning 422 for inval…
jona159 Aug 25, 2026
3d38277
feat: add height above sea level to device detail box
jona159 Aug 25, 2026
d605705
fix: reversed test coordinates
jona159 Aug 25, 2026
4cd67fa
Merge branch 'dev' into feat/device-location-height
jona159 Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ REFRESH_TOKEN_VALIDITY_MS=604800000 # 1 week

NOMINATIM_SEARCH_API="https://nominatim.openstreetmap.org/search"

OPENTOPO_DATA_API_URL="https://api.opentopodata.org/v1"
# OpenTopoData returns the first non-null result from this ordered list.
OPENTOPO_DATA_DATASET="eudem25m,mapzen"
OPENTOPO_DATA_MIN_INTERVAL_MS="1100"

OSEM_GITHUB_URL="https://github.com/openSenseMap/frontend"
OSEM_API_URL="https://api.opensensemap.org/"
DIRECTUS_URL="https://coelho.opensensemap.org"
Expand Down
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ running as a public beta at

<img width="1510" height="858" alt="Screenshot OSeM" src="https://github.com/user-attachments/assets/70da4cff-1751-4a13-89e8-607a5198df37">


## Project setup

If you do need to set the project up locally yourself, feel free to follow these
Expand All @@ -24,14 +23,19 @@ instructions:

You can configure the API endpoint using the following environmental variables:

| ENV | Default value |
| ------------ | -------------------------------------------------------- |
| OSEM_API_URL | https://api.testing.opensensemap.org |
| DATABASE_URL | `postgresql://postgres:postgres@localhost:5432/postgres` |
| ENV | Default value |
| ----------------------------- | -------------------------------------------------------- |
| OSEM_API_URL | https://api.testing.opensensemap.org |
| DATABASE_URL | `postgresql://postgres:postgres@localhost:5432/postgres` |
| OPENTOPO_DATA_API_URL | `https://api.opentopodata.org/v1` |
| OPENTOPO_DATA_DATASET | `eudem25m,mapzen` |
| OPENTOPO_DATA_MIN_INTERVAL_MS | `1100` |

You can create a copy of `.env.example`, rename it to `.env` and set the values.
To run a local development version, you only need to adjust the `OSEM_API_URL`
to the one given above.
to the one given above. Configure `OPENTOPO_DATA_API_URL` explicitly in
production and point it to a self-hosted or managed instance; the default public
instance is intended for development and evaluation.

### Setup Steps

Expand Down Expand Up @@ -163,11 +167,10 @@ flexibility to adjust the outputs to the needs of the respective use case.

##### Documenting an API Route

API route documentation is generated from route-local `zod-openapi`
definitions. Each API route can export an `openapi` object that describes the
route's OpenAPI path item. Request bodies, response bodies, path parameters,
query parameters, and headers should be described with Zod schemas wherever
possible.
API route documentation is generated from route-local `zod-openapi` definitions.
Each API route can export an `openapi` object that describes the route's OpenAPI
path item. Request bodies, response bodies, path parameters, query parameters,
and headers should be described with Zod schemas wherever possible.

The main benefit of this approach is that schemas can be shared between
validation and documentation. This keeps the OpenAPI documentation closer to the
Expand Down
228 changes: 173 additions & 55 deletions app/components/device/new/location-info.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFormContext } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
Expand All @@ -11,6 +11,9 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '~/components/ui/label'
import { BaseMap } from '~/components/base-map'
import Spinner from '~/components/spinner'
import { useTerrainElevation } from '~/hooks/use-terrain-elevation'
import { calculateHeightAboveSeaLevel } from '~/lib/elevation'
import { LOCATION_LIMITS, isValidLocation } from '~/lib/location'

export function LocationStep() {
Expand All @@ -24,6 +27,7 @@ export function LocationStep() {
const { t } = useTranslation('newdevice')
const savedLatitude = watch('latitude')
const savedLongitude = watch('longitude')
const savedHeightAboveGround = watch('heightAboveGround')

const [marker, setMarker] = useState<{
latitude: number | string
Expand All @@ -35,108 +39,153 @@ export function LocationStep() {

useEffect(() => {
if (savedLatitude !== undefined && savedLongitude !== undefined) {
setMarker({
latitude: savedLatitude,
longitude: savedLongitude,
})
setMarker({ latitude: savedLatitude, longitude: savedLongitude })
}
}, [savedLatitude, savedLongitude])

const handleLatitudeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim()
const parsedValue = parseFloat(value)
const markerLocation = useMemo(() => {
if (marker.latitude === '' || marker.longitude === '') return null

setMarker((prev) => ({
...prev,
latitude: value === '' || isNaN(parsedValue) ? '' : parsedValue,
}))
const candidate = {
latitude: Number(marker.latitude),
longitude: Number(marker.longitude),
}

setValue(
'latitude',
value === '' || isNaN(parsedValue) ? undefined : parsedValue,
)
return isValidLocation(candidate) ? candidate : null
}, [marker.latitude, marker.longitude])

const elevation = useTerrainElevation({
latitude: markerLocation?.latitude,
longitude: markerLocation?.longitude,
})

const finalHeight =
elevation.result === null
? null
: calculateHeightAboveSeaLevel(
elevation.result.elevation,
savedHeightAboveGround === '' ||
savedHeightAboveGround === undefined ||
savedHeightAboveGround === null
? undefined
: Number(savedHeightAboveGround),
)

const handleLatitudeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
const parsedValue = Number(value)
const latitude =
value === '' || !Number.isFinite(parsedValue) ? '' : parsedValue

setMarker((current) => ({ ...current, latitude }))
setValue('latitude', latitude === '' ? undefined : latitude, {
shouldDirty: true,
shouldValidate: true,
})
}

const handleLongitudeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim()
const parsedValue = parseFloat(value)
const handleLongitudeChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.target.value.trim()
const parsedValue = Number(value)
const longitude =
value === '' || !Number.isFinite(parsedValue) ? '' : parsedValue

setMarker((prev) => ({
...prev,
longitude: value === '' || isNaN(parsedValue) ? '' : parsedValue,
}))
setMarker((current) => ({ ...current, longitude }))
setValue('longitude', longitude === '' ? undefined : longitude, {
shouldDirty: true,
shouldValidate: true,
})
}

const handleHeightChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
const parsedValue = Number(value)

setValue(
'longitude',
value === '' || isNaN(parsedValue) ? undefined : parsedValue,
'heightAboveGround',
value === '' || !Number.isFinite(parsedValue) ? undefined : parsedValue,
{ shouldDirty: true, shouldValidate: true },
)
}

const onMarkerDrag = useCallback(
(event: MarkerDragEvent) => {
const { lng, lat } = event.lngLat
const updateMarker = useCallback(
(longitude: number, latitude: number) => {
const roundedLatitude = Math.round(latitude * 1_000_000) / 1_000_000
const roundedLongitude = Math.round(longitude * 1_000_000) / 1_000_000

setMarker({
latitude: Math.round(lat * 1000000) / 1000000,
longitude: Math.round(lng * 1000000) / 1000000,
latitude: roundedLatitude,
longitude: roundedLongitude,
})
setValue('latitude', roundedLatitude, {
shouldDirty: true,
shouldValidate: true,
})
setValue('longitude', roundedLongitude, {
shouldDirty: true,
shouldValidate: true,
})
setValue('latitude', lat)
setValue('longitude', lng)
},
[setValue],
)

const onMarkerDragEnd = useCallback(
(event: MarkerDragEvent) => {
updateMarker(event.lngLat.lng, event.lngLat.lat)
},
[updateMarker],
)

const onMapClick = useCallback(
(event: any) => {
const { lng, lat } = event.lngLat
setMarker({
latitude: Math.round(lat * 1000000) / 1000000,
longitude: Math.round(lng * 1000000) / 1000000,
})
setValue('latitude', lat)
setValue('longitude', lng)
(event: { lngLat: { lng: number; lat: number } }) => {
updateMarker(event.lngLat.lng, event.lngLat.lat)
},
[setValue],
[updateMarker],
)

const displayHeightValue =
savedHeightAboveGround !== undefined &&
savedHeightAboveGround !== null &&
savedHeightAboveGround !== ''
? String(savedHeightAboveGround)
: ''

return (
<div className="flex h-full w-full flex-col">
<div className="grow">
<BaseMap
ref={mapRef}
initialViewState={{
latitude: marker.latitude ? Number(marker.latitude) : 51,
longitude: marker.longitude ? Number(marker.longitude) : 7,
latitude: markerLocation?.latitude ?? 51,
longitude: markerLocation?.longitude ?? 7,
zoom: 3.5,
}}
onClick={onMapClick}
>
{isValidLocation({
latitude: Number(marker.latitude),
longitude: Number(marker.longitude),
}) && (
{markerLocation ? (
<Marker
latitude={Number(marker.latitude)}
longitude={Number(marker.longitude)}
latitude={markerLocation.latitude}
longitude={markerLocation.longitude}
anchor="center"
draggable
onDrag={onMarkerDrag}
onDragEnd={onMarkerDragEnd}
/>
)}
) : null}
<NavigationControl position="top-right" showCompass={false} />
<GeolocateControl
position="bottom-right"
positionOptions={{
enableHighAccuracy: true,
timeout: 10_000,
}}
fitBoundsOptions={{
maxZoom: 14,
}}
fitBoundsOptions={{ maxZoom: 14 }}
/>
</BaseMap>
</div>

<div className="bg-background flex w-full items-center justify-around p-4">
<div className="bg-background grid w-full gap-4 p-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<Label htmlFor="latitude">{t('latitude')}</Label>
<Input
Expand Down Expand Up @@ -178,6 +227,75 @@ export function LocationStep() {
</p>
) : null}
</div>

<div>
<Label htmlFor="heightAboveGround">
{t('height_above_ground')} ({t('optional')})
</Label>
<Input
id="heightAboveGround"
type="number"
step="any"
{...register('heightAboveGround')}
value={displayHeightValue}
onChange={handleHeightChange}
placeholder={t('enter height above ground')}
aria-describedby="height-info height-error"
className="w-full rounded-md border p-2"
/>
<p id="height-info" className="text-muted-foreground mt-1 text-xs">
{t('height_info_text')}
</p>

{elevation.status === 'loading' ? (
<div className="mt-2 flex items-center gap-2">
<div className="h-4 w-4">
<Spinner />
</div>
<span className="text-muted-foreground text-sm">
{t('fetching_elevation')}
</span>
</div>
) : elevation.status === 'error' ? (
<div className="mt-2 text-sm text-amber-600">
<p>
{elevation.error === 'unavailable'
? t('elevation_unavailable')
: t('elevation_error')}
</p>
<button
type="button"
onClick={elevation.retry}
className="font-semibold underline"
>
{t('retry_elevation')}
</button>
</div>
) : elevation.result ? (
<div className="text-muted-foreground mt-2 text-sm">
<div>
{t('terrain_elevation')}:{' '}
{Math.round(elevation.result.elevation)} m
</div>
{finalHeight !== null ? (
<div>
{t('final_height')}: {Math.round(finalHeight)} m
</div>
) : null}
<div className="text-xs">
{t('elevation_source')}:{' '}
{elevation.result.attribution ?? elevation.result.dataset}
{elevation.result.datum ? ` (${elevation.result.datum})` : ''}
</div>
</div>
) : null}

{errors.heightAboveGround?.message ? (
<p id="height-error" className="mt-1 text-sm text-red-600">
{String(errors.heightAboveGround.message)}
</p>
) : null}
</div>
</div>
</div>
)
Expand Down
Loading