diff --git a/fdm-app/CHANGELOG.md b/fdm-app/CHANGELOG.md index 26d07dfe5..9dc444af5 100644 --- a/fdm-app/CHANGELOG.md +++ b/fdm-app/CHANGELOG.md @@ -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 diff --git a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx index 6fc8d6155..f73d34ef2 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx @@ -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], diff --git a/fdm-app/app/integrations/ahn-cache.server.ts b/fdm-app/app/integrations/ahn-cache.server.ts new file mode 100644 index 000000000..e665b34fb --- /dev/null +++ b/fdm-app/app/integrations/ahn-cache.server.ts @@ -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 { + 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 + } +} diff --git a/fdm-app/app/routes/atlas.ahn-index.tsx b/fdm-app/app/routes/atlas.ahn-index.tsx new file mode 100644 index 000000000..4ea54b58a --- /dev/null +++ b/fdm-app/app/routes/atlas.ahn-index.tsx @@ -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", + }, + }) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx index 2d9ffd17f..bd0d7a3ad 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx @@ -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 { @@ -87,7 +88,6 @@ interface ActiveTile { id: string url: string cogUrl: string | null - cogUrlHillshade: string | null } // Meta @@ -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 }) @@ -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) @@ -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 @@ -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 @@ -310,7 +317,7 @@ 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") @@ -318,7 +325,7 @@ export default function FarmAtlasElevationBlock() { 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 }[] = [] @@ -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 @@ -421,7 +439,6 @@ export default function FarmAtlasElevationBlock() { id, url, cogUrl: `cog://${url}${colorParam}`, - cogUrlHillshade: `cog://${url}#dem`, }) } @@ -438,7 +455,7 @@ export default function FarmAtlasElevationBlock() { } clearTimeout(slowTimer) } - }, [indexData, activeTiles]) + }, [indexData]) // Throttle updates const updateRef = useRef(updateVisibleTiles) @@ -557,7 +574,7 @@ export default function FarmAtlasElevationBlock() { {/* WMS Overview Layer (Zoom < 13) */} - {showElevation && ( + {showElevation && viewState.zoom < 13 && ( - - - ))} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields._index.tsx index f4a08383c..101d46098 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields._index.tsx @@ -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 { @@ -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 }) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx index 127656dc4..de49aaec2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx @@ -1,4 +1,6 @@ import { getFields } from "@svenvw/fdm-core" +import { simplify } from "@turf/turf" +import { Geometry } from "geojson" import { data, type LoaderFunctionArgs, @@ -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 }) diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx index 532183564..075f8f753 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx @@ -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" @@ -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 }), diff --git a/fdm-app/package.json b/fdm-app/package.json index 9609c80e0..0f4c448c5 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -1,6 +1,6 @@ { "name": "@svenvw/fdm-app", - "version": "0.26.0", + "version": "0.26.1", "private": true, "sideEffects": false, "type": "module",