Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions fdm-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog fdm-app

## 0.26.1

### Patch Changes

- 36b1b99: Fix TypeError when `updatePanel` attempts to access `map.getLayer(layer)` before the map is fully initialized
- 1274a32: Optimize Elevation Atlas stability and performance: implement chunked sampling concurrency, server-side AHN index caching, geometry simplification and WMS layer zoom constraints
- 067c0de: Fix AggregateError in Elevation Atlas by implementing chunked concurrency for sampling requests to avoid exceeding HTTP/1.1 connection limits

## 0.26.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion fdm-app/app/components/blocks/atlas/atlas-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function FieldsPanelHover({
// Set message about zoom level
const zoom = map.getZoom()
if (zoom && zoom > zoomLevelFields) {
if (!map.getLayer(layer)) return
if (!map.getStyle() || !map.getLayer(layer)) return

const features = map.queryRenderedFeatures(evt.point, {
layers: [layer],
Expand Down
41 changes: 41 additions & 0 deletions fdm-app/app/integrations/ahn-cache.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { FeatureCollection } from "geojson"

let cache: { data: FeatureCollection; expires: number } | null = null
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours in milliseconds

export async function getAhnIndex(): Promise<FeatureCollection> {
const now = Date.now()

if (cache && cache.expires > now) {
return cache.data
}

try {
console.log("Fetching AHN index from PDOK...")
const response = await fetch(
"https://service.pdok.nl/rws/ahn/atom/downloads/dtm_05m/kaartbladindex.json",
{ signal: AbortSignal.timeout(30000) }, // 30 second timeout
)

if (!response.ok) {
throw new Error(`Failed to fetch AHN index: ${response.statusText}`)
}

const data = (await response.json()) as FeatureCollection
if (!data.features || !Array.isArray(data.features)) {
throw new Error("Invalid AHN index format")
}
cache = {
data,
expires: now + CACHE_TTL,
}
return data
} catch (error) {
console.error(
"Error fetching AHN index, serving stale cache if available",
error,
)
if (cache) return cache.data
throw error
}
}
11 changes: 11 additions & 0 deletions fdm-app/app/routes/atlas.ahn-index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { data } from "react-router"
import { getAhnIndex } from "@/app/integrations/ahn-cache.server"

export async function loader() {
const ahnIndex = await getAhnIndex()
return data(ahnIndex, {
headers: {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
})
}
145 changes: 69 additions & 76 deletions fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
proj4,
} from "@geomatico/maplibre-cog-protocol"
import { getFields } from "@svenvw/fdm-core"
import type { FeatureCollection } from "geojson"
import { simplify } from "@turf/turf"
import type { FeatureCollection, Geometry } from "geojson"
import throttle from "lodash.throttle"
import maplibregl from "maplibre-gl"
import {
Expand Down Expand Up @@ -87,7 +88,6 @@ interface ActiveTile {
id: string
url: string
cogUrl: string | null
cogUrlHillshade: string | null
}

// Meta
Expand Down Expand Up @@ -131,7 +131,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
b_lu_name: field.b_lu_name,
b_id_source: field.b_id_source,
},
geometry: field.b_geometry,
geometry: simplify(field.b_geometry as Geometry, {
tolerance: 0.00001,
highQuality: true,
}),
}
return feature
})
Expand Down Expand Up @@ -240,9 +243,9 @@ export default function FarmAtlasElevationBlock() {
}
}

const response = await fetch(
"https://service.pdok.nl/rws/ahn/atom/downloads/dtm_05m/kaartbladindex.json",
)
// Fetch from our server-side cache
const response = await fetch("/resources/ahn-index")

if (!response.ok) throw new Error("Failed to fetch COG index")
const data = (await response.json()) as FeatureCollection
setIndexData(data)
Expand All @@ -269,6 +272,10 @@ export default function FarmAtlasElevationBlock() {
const updateId = useRef(0)

// Function to update visible tiles
const activeTilesLengthRef = useRef(activeTiles.length)
useEffect(() => {
activeTilesLengthRef.current = activeTiles.length
}, [activeTiles])
const updateVisibleTiles = useCallback(async () => {
if (!mapRef.current || !indexData) return

Expand All @@ -277,7 +284,7 @@ export default function FarmAtlasElevationBlock() {

// If zoomed out, clear active tiles to save resources (WMS will take over)
if (zoom < 13) {
if (activeTiles.length > 0) {
if (activeTilesLengthRef.current > 0) {
setActiveTiles([])
}
return
Expand Down Expand Up @@ -310,15 +317,15 @@ export default function FarmAtlasElevationBlock() {
] as [number, number][]

// Find intersecting tiles
// Optimization: limit to e.g. 24 tiles to avoid overload
// Optimization: limit to 12 tiles to avoid overload
const visibleFeatures = indexData.features
.filter((f) => {
if (!f.geometry || f.geometry.type !== "Polygon")
return false
const ring = (f.geometry as any).coordinates[0]
return polygonIntersectsPolygon(rdCoords, ring)
})
.slice(0, 24)
.slice(0, 12)

// Calculate global min/max for the viewport by sampling
const samplePoints: { lng: number; lat: number }[] = []
Expand All @@ -334,49 +341,60 @@ export default function FarmAtlasElevationBlock() {
let min = 1000
let max = -1000

// Gather values for samples
const values = await Promise.all(
samplePoints.map(async (p) => {
try {
const rdP = proj4("EPSG:28992").forward([
p.lng,
p.lat,
]) as [number, number]
// Find which tile contains this point
const feature = visibleFeatures.find((f) => {
if (!f.geometry || f.geometry.type !== "Polygon")
return false
const ring = (f.geometry as any).coordinates[0]
return isPointInPolygon(rdP, ring)
})
if (feature?.properties) {
const url =
feature.properties.url ||
feature.properties.href ||
feature.properties.download_url
if (url) {
// Requesting location value
const vals = await locationValues(url, {
longitude: p.lng,
latitude: p.lat,
})
// Gather values for samples with concurrency limit
const results: (number | null)[] = []
const chunkSize = 4
for (let i = 0; i < samplePoints.length; i += chunkSize) {
const chunk = samplePoints.slice(i, i + chunkSize)
const chunkResults = await Promise.all(
chunk.map(async (p) => {
try {
const rdP = proj4("EPSG:28992").forward([
p.lng,
p.lat,
]) as [number, number]
// Find which tile contains this point
const feature = visibleFeatures.find((f) => {
if (
vals &&
vals.length > 0 &&
!Number.isNaN(vals[0]) &&
vals[0] > -100 &&
vals[0] < 1000
) {
return vals[0]
!f.geometry ||
f.geometry.type !== "Polygon"
)
return false
const ring = (f.geometry as any).coordinates[0]
return isPointInPolygon(rdP, ring)
})
if (feature?.properties) {
const url =
feature.properties.url ||
feature.properties.href ||
feature.properties.download_url
if (url) {
// Requesting location value
const vals = await locationValues(url, {
longitude: p.lng,
latitude: p.lat,
})
if (
vals &&
vals.length > 0 &&
!Number.isNaN(vals[0]) &&
vals[0] > -100 &&
vals[0] < 1000
) {
return vals[0]
}
}
}
} catch {
// Ignore errors for individual points
}
} catch {
// Ignore errors for individual points
}
return null
}),
)
return null
}),
)
results.push(...chunkResults)
}

const values = results

if (updateId.current !== currentId) return

Expand Down Expand Up @@ -421,7 +439,6 @@ export default function FarmAtlasElevationBlock() {
id,
url,
cogUrl: `cog://${url}${colorParam}`,
cogUrlHillshade: `cog://${url}#dem`,
})
}

Expand All @@ -438,7 +455,7 @@ export default function FarmAtlasElevationBlock() {
}
clearTimeout(slowTimer)
}
}, [indexData, activeTiles])
}, [indexData])

// Throttle updates
const updateRef = useRef(updateVisibleTiles)
Expand Down Expand Up @@ -557,14 +574,15 @@ export default function FarmAtlasElevationBlock() {
<MapTilerAttribution />

{/* WMS Overview Layer (Zoom < 13) */}
{showElevation && (
{showElevation && viewState.zoom < 13 && (
<Source
id="ahn-wms"
type="raster"
tiles={[
"https://service.pdok.nl/rws/ahn/wms/v1_0?service=WMS&request=GetMap&layers=dtm_05m&styles=&format=image/png&transparent=true&version=1.3.0&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}",
]}
tileSize={256}
maxzoom={13}
attribution="&copy; <a href='https://www.pdok.nl/'>PDOK</a>, <a href='https://www.ahn.nl/'>AHN</a>"
>
<Layer
Expand Down Expand Up @@ -601,31 +619,6 @@ export default function FarmAtlasElevationBlock() {
}
/>
</Source>
<Source
id={`ahn-dem-${tile.id}`}
type="raster-dem"
url={tile.cogUrlHillshade!}
tileSize={256}
bounds={[3.3, 50.7, 7.2, 53.7]}
minzoom={0}
maxzoom={16}
>
<Layer
id={`ahn-hillshade-${tile.id}`}
type="hillshade"
paint={{
"hillshade-exaggeration": 0.3,
"hillshade-shadow-color": "#000000",
"hillshade-highlight-color": "#ffffff",
"hillshade-accent-color": "#000000",
}}
beforeId={
fields
? "fieldsSavedOutline"
: undefined
}
/>
</Source>
</Fragment>
))}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getFields } from "@svenvw/fdm-core"
import type { FeatureCollection } from "geojson"
import { simplify } from "@turf/turf"
import type { FeatureCollection, Geometry } from "geojson"
import maplibregl from "maplibre-gl"
import { useCallback, useEffect, useRef, useState } from "react"
import {
Expand Down Expand Up @@ -83,7 +84,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
b_lu_name: field.b_lu_name,
b_id_source: field.b_id_source,
},
geometry: field.b_geometry,
geometry: simplify(field.b_geometry as Geometry, {
tolerance: 0.00001,
highQuality: true,
}),
}
return feature
})
Expand Down
7 changes: 6 additions & 1 deletion fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getFields } from "@svenvw/fdm-core"
import { simplify } from "@turf/turf"
import { Geometry } from "geojson"
import {
data,
type LoaderFunctionArgs,
Expand Down Expand Up @@ -67,7 +69,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
b_name: field.b_name,
b_area: Math.round(field.b_area * 10) / 10,
},
geometry: field.b_geometry,
geometry: simplify(field.b_geometry as Geometry, {
tolerance: 0.00001,
highQuality: true,
}),
}
return feature
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
getFarm,
getFields,
} from "@svenvw/fdm-core"
import { simplify } from "@turf/turf"
import type {
Feature,
FeatureCollection,
GeoJsonProperties,
Geometry,
Polygon,
} from "geojson"
import maplibregl from "maplibre-gl"
Expand Down Expand Up @@ -148,7 +150,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
b_lu_name: cultivation,
b_id_source: field.b_id_source,
},
geometry: field.b_geometry,
geometry: simplify(field.b_geometry as Geometry, {
tolerance: 0.00001,
highQuality: true,
}),
}
return feature
}),
Expand Down
2 changes: 1 addition & 1 deletion fdm-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@svenvw/fdm-app",
"version": "0.26.0",
"version": "0.26.1",
"private": true,
"sideEffects": false,
"type": "module",
Expand Down