From 36b1b99c151b90cab10486f0af4166c669b4fa99 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:07:09 +0100 Subject: [PATCH 1/6] fix: TypeError when `updatePanel` attempts to access `map.getLayer(layer)` before the map is fully initialized --- .changeset/silly-shoes-shake.md | 5 +++++ fdm-app/app/components/blocks/atlas/atlas-panels.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/silly-shoes-shake.md diff --git a/.changeset/silly-shoes-shake.md b/.changeset/silly-shoes-shake.md new file mode 100644 index 000000000..9db570076 --- /dev/null +++ b/.changeset/silly-shoes-shake.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": patch +--- + +Fix TypeError when `updatePanel` attempts to access `map.getLayer(layer)` before the map is fully initialized 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], From 067c0de49c95f5da5d35c879f3e70c39bca44330 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:44:50 +0100 Subject: [PATCH 2/6] fix: AggregateError in Elevation Atlas by implementing chunked concurrency for sampling requests to avoid exceeding HTTP/1.1 connection limits. --- .changeset/yummy-wombats-live.md | 5 + ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 118 ++++++++---------- 2 files changed, 56 insertions(+), 67 deletions(-) create mode 100644 .changeset/yummy-wombats-live.md diff --git a/.changeset/yummy-wombats-live.md b/.changeset/yummy-wombats-live.md new file mode 100644 index 000000000..a3ee54d5e --- /dev/null +++ b/.changeset/yummy-wombats-live.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": patch +--- + +Fix AggregateError in Elevation Atlas by implementing chunked concurrency for sampling requests to avoid exceeding HTTP/1.1 connection limits 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..80316b1a5 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 @@ -87,7 +87,6 @@ interface ActiveTile { id: string url: string cogUrl: string | null - cogUrlHillshade: string | null } // Meta @@ -318,7 +317,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 +333,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 +431,6 @@ export default function FarmAtlasElevationBlock() { id, url, cogUrl: `cog://${url}${colorParam}`, - cogUrlHillshade: `cog://${url}#dem`, }) } @@ -601,31 +610,6 @@ export default function FarmAtlasElevationBlock() { } /> - - - ))} From 1274a327040bcba456670ca439864d72539ac0a8 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:21:24 +0100 Subject: [PATCH 3/6] refactor: Optimize Elevation Atlas stability and performance: implement chunked sampling concurrency, server-side AHN index caching, geometry simplification and WMS layer zoom constraints --- .changeset/witty-foxes-lose.md | 5 +++ fdm-app/app/integrations/ahn-cache.server.ts | 34 +++++++++++++++++++ fdm-app/app/routes/atlas.ahn-index.tsx | 11 ++++++ ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 15 +++++--- ..._id_farm.$calendar.atlas.fields._index.tsx | 6 +++- .../farm.$b_id_farm.$calendar.atlas.soil.tsx | 6 +++- ...farm.create.$b_id_farm.$calendar.atlas.tsx | 6 +++- 7 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 .changeset/witty-foxes-lose.md create mode 100644 fdm-app/app/integrations/ahn-cache.server.ts create mode 100644 fdm-app/app/routes/atlas.ahn-index.tsx diff --git a/.changeset/witty-foxes-lose.md b/.changeset/witty-foxes-lose.md new file mode 100644 index 000000000..bec69dfbf --- /dev/null +++ b/.changeset/witty-foxes-lose.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": patch +--- + +Optimize Elevation Atlas stability and performance: implement chunked sampling concurrency, server-side AHN index caching, geometry simplification and WMS layer zoom constraints 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..f95174617 --- /dev/null +++ b/fdm-app/app/integrations/ahn-cache.server.ts @@ -0,0 +1,34 @@ +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", + ) + + if (!response.ok) { + throw new Error(`Failed to fetch AHN index: ${response.statusText}`) + } + + const data = (await response.json()) as FeatureCollection + 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..dc044c2df --- /dev/null +++ b/fdm-app/app/routes/atlas.ahn-index.tsx @@ -0,0 +1,11 @@ +import { type LoaderFunctionArgs, 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 80316b1a5..f3d67c295 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,6 +4,7 @@ import { proj4, } from "@geomatico/maplibre-cog-protocol" import { getFields } from "@svenvw/fdm-core" +import { simplify } from "@turf/turf" import type { FeatureCollection } from "geojson" import throttle from "lodash.throttle" import maplibregl from "maplibre-gl" @@ -130,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 any, { + tolerance: 0.00001, + highQuality: true, + }), } return feature }) @@ -239,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) @@ -566,7 +570,7 @@ export default function FarmAtlasElevationBlock() { {/* WMS Overview Layer (Zoom < 13) */} - {showElevation && ( + {showElevation && viewState.zoom < 13 && ( Date: Mon, 22 Dec 2025 15:43:17 +0100 Subject: [PATCH 4/6] refactor: improve type --- .../app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx | 4 ++-- .../routes/farm.$b_id_farm.$calendar.atlas.fields._index.tsx | 4 ++-- fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil.tsx | 3 ++- fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) 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 f3d67c295..d7609ed7d 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 @@ -5,7 +5,7 @@ import { } from "@geomatico/maplibre-cog-protocol" import { getFields } from "@svenvw/fdm-core" import { simplify } from "@turf/turf" -import type { FeatureCollection } from "geojson" +import type { FeatureCollection, Geometry } from "geojson" import throttle from "lodash.throttle" import maplibregl from "maplibre-gl" import { @@ -131,7 +131,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_lu_name: field.b_lu_name, b_id_source: field.b_id_source, }, - geometry: simplify(field.b_geometry as any, { + geometry: simplify(field.b_geometry as Geometry, { tolerance: 0.00001, highQuality: true, }), 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 e806cb55e..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,6 +1,6 @@ import { getFields } from "@svenvw/fdm-core" import { simplify } from "@turf/turf" -import type { FeatureCollection } from "geojson" +import type { FeatureCollection, Geometry } from "geojson" import maplibregl from "maplibre-gl" import { useCallback, useEffect, useRef, useState } from "react" import { @@ -84,7 +84,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_lu_name: field.b_lu_name, b_id_source: field.b_id_source, }, - geometry: simplify(field.b_geometry as any, { + geometry: simplify(field.b_geometry as Geometry, { tolerance: 0.00001, highQuality: true, }), 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 70ee8300d..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,5 +1,6 @@ import { getFields } from "@svenvw/fdm-core" import { simplify } from "@turf/turf" +import { Geometry } from "geojson" import { data, type LoaderFunctionArgs, @@ -68,7 +69,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_name: field.b_name, b_area: Math.round(field.b_area * 10) / 10, }, - geometry: simplify(field.b_geometry as any, { + geometry: simplify(field.b_geometry as Geometry, { tolerance: 0.00001, highQuality: true, }), 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 8890134d7..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 @@ -12,6 +12,7 @@ import type { Feature, FeatureCollection, GeoJsonProperties, + Geometry, Polygon, } from "geojson" import maplibregl from "maplibre-gl" @@ -149,7 +150,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_lu_name: cultivation, b_id_source: field.b_id_source, }, - geometry: simplify(field.b_geometry as any, { + geometry: simplify(field.b_geometry as Geometry, { tolerance: 0.00001, highQuality: true, }), From 14350dfd5e14db09fcfa478411d11586800c7145 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:47:55 +0100 Subject: [PATCH 5/6] nitpicks --- fdm-app/app/integrations/ahn-cache.server.ts | 9 ++++++++- fdm-app/app/routes/atlas.ahn-index.tsx | 2 +- .../farm.$b_id_farm.$calendar.atlas.elevation.tsx | 10 +++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/integrations/ahn-cache.server.ts b/fdm-app/app/integrations/ahn-cache.server.ts index f95174617..e665b34fb 100644 --- a/fdm-app/app/integrations/ahn-cache.server.ts +++ b/fdm-app/app/integrations/ahn-cache.server.ts @@ -14,6 +14,7 @@ export async function getAhnIndex(): Promise { 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) { @@ -21,13 +22,19 @@ export async function getAhnIndex(): Promise { } 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) + 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 index dc044c2df..4ea54b58a 100644 --- a/fdm-app/app/routes/atlas.ahn-index.tsx +++ b/fdm-app/app/routes/atlas.ahn-index.tsx @@ -1,4 +1,4 @@ -import { type LoaderFunctionArgs, data } from "react-router" +import { data } from "react-router" import { getAhnIndex } from "@/app/integrations/ahn-cache.server" export async function loader() { 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 d7609ed7d..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 @@ -272,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 @@ -280,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 @@ -313,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") @@ -451,7 +455,7 @@ export default function FarmAtlasElevationBlock() { } clearTimeout(slowTimer) } - }, [indexData, activeTiles]) + }, [indexData]) // Throttle updates const updateRef = useRef(updateVisibleTiles) From c7b7d49003a7e20a5db50cfde357c38a3b38016b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Dec 2025 14:56:21 +0000 Subject: [PATCH 6/6] chore: bump version of packages for release --- .changeset/silly-shoes-shake.md | 5 ----- .changeset/witty-foxes-lose.md | 5 ----- .changeset/yummy-wombats-live.md | 5 ----- fdm-app/CHANGELOG.md | 8 ++++++++ fdm-app/package.json | 2 +- 5 files changed, 9 insertions(+), 16 deletions(-) delete mode 100644 .changeset/silly-shoes-shake.md delete mode 100644 .changeset/witty-foxes-lose.md delete mode 100644 .changeset/yummy-wombats-live.md diff --git a/.changeset/silly-shoes-shake.md b/.changeset/silly-shoes-shake.md deleted file mode 100644 index 9db570076..000000000 --- a/.changeset/silly-shoes-shake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@svenvw/fdm-app": patch ---- - -Fix TypeError when `updatePanel` attempts to access `map.getLayer(layer)` before the map is fully initialized diff --git a/.changeset/witty-foxes-lose.md b/.changeset/witty-foxes-lose.md deleted file mode 100644 index bec69dfbf..000000000 --- a/.changeset/witty-foxes-lose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@svenvw/fdm-app": patch ---- - -Optimize Elevation Atlas stability and performance: implement chunked sampling concurrency, server-side AHN index caching, geometry simplification and WMS layer zoom constraints diff --git a/.changeset/yummy-wombats-live.md b/.changeset/yummy-wombats-live.md deleted file mode 100644 index a3ee54d5e..000000000 --- a/.changeset/yummy-wombats-live.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@svenvw/fdm-app": patch ---- - -Fix AggregateError in Elevation Atlas by implementing chunked concurrency for sampling requests to avoid exceeding HTTP/1.1 connection limits 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/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",