From d9f67111ac658e1b9f49fe69e05811c7b545978f Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:36:34 +0100 Subject: [PATCH 01/13] feat: Add implementation of the AHN4 via the elevation layer in Atlas --- .changeset/jolly-ravens-shop.md | 5 + .../components/blocks/atlas/atlas-legend.tsx | 41 ++ fdm-app/app/lib/cache.server.ts | 4 +- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 382 ++++++++++++++++-- fdm-app/vite.config.ts | 2 +- pnpm-lock.yaml | 32 ++ 6 files changed, 423 insertions(+), 43 deletions(-) create mode 100644 .changeset/jolly-ravens-shop.md create mode 100644 fdm-app/app/components/blocks/atlas/atlas-legend.tsx diff --git a/.changeset/jolly-ravens-shop.md b/.changeset/jolly-ravens-shop.md new file mode 100644 index 000000000..5bbf1d864 --- /dev/null +++ b/.changeset/jolly-ravens-shop.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Add implementation of the AHN4 via the elevation layer in Atlas diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx new file mode 100644 index 000000000..e3e4a90b0 --- /dev/null +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -0,0 +1,41 @@ +import { Card, CardContent } from "~/components/ui/card" +import { LoadingSpinner } from "~/components/custom/loadingspinner" + +interface ElevationLegendProps { + min?: number + max?: number + loading?: boolean +} + +export function ElevationLegend({ min, max, loading }: ElevationLegendProps) { + return ( +
+ + +
+

+ Hoogte (NAP) +

+ {loading && } +
+ +
+
+
Red) + background: "linear-gradient(to right, #5e4fa2, #3288bd, #66c2a5, #abdda4, #e6f598, #ffffbf, #fee08b, #fdae61, #f46d43, #d53e4f, #9e0142)" + }} + /> +
+
+ {min !== undefined ? `${min.toFixed(1)}m` : "Laag"} + {max !== undefined ? `${max.toFixed(1)}m` : "Hoog"} +
+
+ + +
+ ) +} \ No newline at end of file diff --git a/fdm-app/app/lib/cache.server.ts b/fdm-app/app/lib/cache.server.ts index 04811cd39..4e2bf8d4c 100644 --- a/fdm-app/app/lib/cache.server.ts +++ b/fdm-app/app/lib/cache.server.ts @@ -137,8 +137,8 @@ export function addSecurityHeaders(headers: Headers): Headers { worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.posthog.com; font-src 'self' https://fonts.gstatic.com https://*.posthog.com data:; - img-src 'self' data: blob: https://*.maptiler.com https://*.openstreetmap.org https://*.public.blob.vercel-storage.com https://images.unsplash.com https://lh3.googleusercontent.com https://graph.microsoft.com https://*.posthog.com; - connect-src 'self' https://server.arcgisonline.com https://api.maptiler.com https://nominatim.openstreetmap.org https://sentry.io https://*.sentry.io https://*.nmi-agro.nl https://storage.googleapis.com/fdm-public-data/ https://*.posthog.com ws://localhost:* http://localhost:*; + img-src 'self' data: blob: https://service.pdok.nl https://*.maptiler.com https://*.openstreetmap.org https://*.public.blob.vercel-storage.com https://images.unsplash.com https://lh3.googleusercontent.com https://graph.microsoft.com https://*.posthog.com; + connect-src 'self' https://service.pdok.nl https://server.arcgisonline.com https://api.maptiler.com https://nominatim.openstreetmap.org https://sentry.io https://*.sentry.io https://*.nmi-agro.nl https://storage.googleapis.com/fdm-public-data/ https://*.posthog.com ws://localhost:* http://localhost:*; frame-src 'self'; media-src 'self' https://*.posthog.com; object-src 'none'; 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 f5bc6e003..39c2a9cc8 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 @@ -1,17 +1,82 @@ +import { + cogProtocol, + getCogMetadata, + locationValues, + proj4, +} from "@geomatico/maplibre-cog-protocol" import { getFields } from "@svenvw/fdm-core" +import throttle from "lodash.throttle" +import type { FeatureCollection } from "geojson" +import maplibregl from "maplibre-gl" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + Layer, + Map as MapGL, + Source, + type MapRef, + type ViewState, + type ViewStateChangeEvent, +} from "react-map-gl/maplibre" import { data, type LoaderFunctionArgs, type MetaFunction, - NavLink, + useLoaderData, } from "react-router" -import { Button } from "~/components/ui/button" +import { Controls } from "~/components/blocks/atlas/atlas-controls" +import { ElevationLegend } from "~/components/blocks/atlas/atlas-legend" +import { getViewState } from "~/components/blocks/atlas/atlas-viewstate" +import { LoadingSpinner } from "~/components/custom/loadingspinner" +import { getMapStyle } from "~/integrations/map" import { getSession } from "~/lib/auth.server" -import { getTimeframe } from "~/lib/calendar" +import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +// Register the projection for RD New (EPSG:28992) +proj4.defs( + "EPSG:28992", + "+proj=sterea +lat_0=52.15616055555555 +lon_0=5.38763888888889 +k=0.9999079 +x_0=155000 +y_0=463000 +ellps=bessel +towgs84=565.2369,50.0087,465.658,-0.406857330322398,0.350732676542563,-1.8703473836068,4.0812 +units=m +no_defs", +) + +// Register the COG protocol +maplibregl.addProtocol("cog", cogProtocol) + +// Helper: Simple Point in Polygon for RD coordinates (Ray Casting) +function isPointInPolygon(point: [number, number], vs: [number, number][]) { + const x = point[0] + const y = point[1] + let inside = false + for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) { + const xi = vs[i][0] + const yi = vs[i][1] + const xj = vs[j][0] + const yj = vs[j][1] + const intersect = + yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi + if (intersect) inside = !inside + } + return inside +} + +// Helper: Check if polygon intersects polygon (simple AABB check for index speed, then detail?) +// For now, we just check if any point of tile is in view or view in tile? +// Simpler: Convert Viewport to RD Polygon, check intersection with Tile Polygon (also RD). +function polygonIntersectsPolygon(poly1: [number, number][], poly2: [number, number][]) { + // Simplified: Check if any point of poly1 is in poly2 OR any point of poly2 is in poly1 + // This is not 100% robust for crossing polygons but good enough for tiles + for (const p of poly1) if (isPointInPolygon(p, poly2)) return true + for (const p of poly2) if (isPointInPolygon(p, poly1)) return true + return false +} + +interface ActiveTile { + id: string + url: string + cogUrl: string | null +} + // Meta export const meta: MetaFunction = () => { return [ @@ -25,14 +90,9 @@ export const meta: MetaFunction = () => { /** * Loads farm field data for the elevation feature. - * - * This asynchronous function checks for the presence of a farm ID in the route parameters. It retrieves the user session, fetches the fields associated with the specified farm, and maps them to a GeoJSON FeatureCollection. Errors during these processes, such as a missing farm ID or data retrieval issues, are caught and rethrown. - * @param {LoaderFunctionArgs} args - The arguments provided by the loader, including the request and parameters. - * @returns An object containing the GeoJSON FeatureCollection of farm fields */ export async function loader({ request, params }: LoaderFunctionArgs) { try { - // Get the farm id const b_id_farm = params.b_id_farm if (!b_id_farm) { throw data("Farm ID is required", { @@ -41,21 +101,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - // Get the session const session = await getSession(request) - - // Get timeframe from calendar store + const calendar = getCalendar(params) const timeframe = getTimeframe(params) - // Get the fields of the farm const fields = await getFields( fdm, session.principal_id, b_id_farm, timeframe, ) - const features = fields.map((field) => { - const feature = { + const featureCollection: FeatureCollection = { + type: "FeatureCollection", + features: fields.map((field) => ({ type: "Feature", properties: { b_id: field.b_id, @@ -63,44 +121,288 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_area: Math.round(field.b_area * 10) / 10, }, geometry: field.b_geometry, - } - return feature - }) - - const featureCollection = { - type: "FeatureCollection", - features: features, + })), } - // Return user information from loader + const mapStyle = getMapStyle("satellite") + return { fields: featureCollection, + mapStyle, + calendar, } } catch (error) { throw handleLoaderError(error) } } -/** - * Renders a placeholder UI for the farm elevation feature. - * - * This component displays a message informing the user that the elevation map is not yet available and provides a button that navigates to the field map. - */ export default function FarmAtlasElevationBlock() { + const loaderData = useLoaderData() + const fields = loaderData.fields + const mapStyle = loaderData.mapStyle + + const mapRef = useRef(null) + + // State + const [indexData, setIndexData] = useState(null) + const [activeTiles, setActiveTiles] = useState([]) + const [isLoadingCog, setIsLoadingCog] = useState(true) + const [isUpdating, setIsUpdating] = useState(false) + const [legendMin, setLegendMin] = useState(-5) + const [legendMax, setLegendMax] = useState(50) + + // ViewState logic + const initialViewState = getViewState(fields) + const [viewState, setViewState] = useState(() => { + if (typeof window !== "undefined") { + const savedViewState = sessionStorage.getItem( + "mapViewStateElevation", + ) + if (savedViewState) { + try { + return JSON.parse(savedViewState) + } catch { + sessionStorage.removeItem("mapViewStateElevation") + } + } + } + return initialViewState as ViewState + }) + + const onViewportChange = useCallback((event: ViewStateChangeEvent) => { + setViewState(event.viewState) + }, []) + + // Save viewState + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + sessionStorage.setItem( + "mapViewStateElevation", + JSON.stringify(viewState), + ) + }, [viewState]) + + // Fetch COG Index once + useEffect(() => { + async function fetchIndex() { + try { + 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 COG index") + const data = (await response.json()) as FeatureCollection + setIndexData(data) + } catch (e) { + console.error("Error fetching COG index:", e) + setIsLoadingCog(false) + } + } + fetchIndex() + }, []) + + // Function to update visible tiles + const updateVisibleTiles = useCallback(async () => { + if (!mapRef.current || !indexData) return + + setIsUpdating(true) + + const bounds = mapRef.current.getBounds() + const sw = bounds.getSouthWest() + const ne = bounds.getNorthEast() + const nw = bounds.getNorthWest() + const se = bounds.getSouthEast() + + // Convert viewport corners to RD (EPSG:28992) + // We catch projection errors if points are outside valid range + try { + const rdCoords = [ + proj4("EPSG:28992").forward([nw.lng, nw.lat]), + proj4("EPSG:28992").forward([ne.lng, ne.lat]), + proj4("EPSG:28992").forward([se.lng, se.lat]), + proj4("EPSG:28992").forward([sw.lng, sw.lat]), + ] as [number, number][] + + // Find intersecting tiles + // Optimization: limit to e.g. 6 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, 6) + + // Calculate global min/max for the viewport by sampling + const samplePoints: {lng: number, lat: number}[] = [] + const gridSize = 4 // 4x4 = 16 points + for (let i = 0; i <= gridSize; i++) { + for (let j = 0; j <= gridSize; j++) { + const lng = sw.lng + (ne.lng - sw.lng) * (i / gridSize) + const lat = sw.lat + (ne.lat - sw.lat) * (j / gridSize) + samplePoints.push({ lng, lat }) + } + } + + let min = 1000 + let max = -1000 + + // Gather values for samples + const values = await Promise.all(samplePoints.map(async (p) => { + 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 && feature.properties) { + const url = feature.properties.url || feature.properties.href || feature.properties.download_url + if (url) { + try { + // Requesting location value + // Note: locationValues caches internal resources so it's efficient + const vals = await locationValues(url, { longitude: p.lng, latitude: p.lat }) + if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { + return vals[0] + } + } catch {} + } + } + return null + })) + + const validValues = values.filter(v => v !== null) as number[] + if (validValues.length > 0) { + min = Math.min(...validValues) + max = Math.max(...validValues) + } else { + // Fallback if no data found (e.g. outside coverage or error) + min = -5 + max = 50 + } + + // Ensure minimum contrast + if (max - min < 1) { + min -= 0.5 + max += 0.5 + } + + // Pad range slightly + const range = max - min + min -= range * 0.05 + max += range * 0.05 + + setLegendMin(min) + setLegendMax(max) + + // Format for color scale + const colorParam = `#color:BrewerSpectral11,${min},${max},-` + + const newTiles: ActiveTile[] = [] + for (const feature of visibleFeatures) { + if (!feature.properties) continue + const url = + feature.properties.url || + feature.properties.href || + feature.properties.download_url + + if (!url) continue + const id = feature.properties.kaartbladNr || url + + newTiles.push({ id, url, cogUrl: `cog://${url}${colorParam}` }) + } + + // Update state + setActiveTiles(newTiles) + setIsLoadingCog(false) + + } catch (e) { + console.error("Error updating visible tiles:", e) + } finally { + setIsUpdating(false) + } + + }, [indexData, activeTiles]) + + // Throttle updates + // const throttledUpdate = useMemo(() => throttle(updateVisibleTiles, 500), [updateVisibleTiles]) + // Using throttle directly in render is tricky with deps. + // We will use a ref to store the latest updateVisibleTiles and throttle that. + + const updateRef = useRef(updateVisibleTiles) + useEffect(() => { updateRef.current = updateVisibleTiles }, [updateVisibleTiles]) + + const throttledUpdate = useMemo(() => throttle(() => updateRef.current(), 500, { leading: true, trailing: true }), []) + + // Initial update + useEffect(() => { + const timer = setTimeout(() => { + throttledUpdate() + }, 1000) + return () => clearTimeout(timer) + }, [indexData]) + return ( -
-
-

- Helaas, de hoogtekaart is nog niet beschikbaar :( -

-

- We proberen de hoogtekaart binnenkort toe te voegen. Hou de - website in de gaten. -

-
- +
+ {isLoadingCog && ( +
+
+ + Hoogtekaart laden... +
+
+ )} + + + + setViewState((currentViewState) => ({ + ...currentViewState, + longitude, + latitude, + zoom, + })) + } + /> + + {/* Render Active Tiles */} + {activeTiles.map((tile) => ( + + + + ))} + + +
) } diff --git a/fdm-app/vite.config.ts b/fdm-app/vite.config.ts index 1df0ef858..39ae0085b 100644 --- a/fdm-app/vite.config.ts +++ b/fdm-app/vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig((env) => { ].filter(Boolean), envPrefix: "PUBLIC_", ssr: { - noExternal: ["posthog-js", "posthog-js/react"], + noExternal: ["posthog-js", "posthog-js/react", "@geomatico/maplibre-cog-protocol"], }, build: { sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc39724ad..164fa6333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@date-fns/tz': specifier: ^1.4.1 version: 1.4.1 + '@geomatico/maplibre-cog-protocol': + specifier: file:C:/Users/sven.verweij/Applications/packages/maplibre-cog-protocol + version: file:../packages/maplibre-cog-protocol(maplibre-gl@5.13.0) '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.68.0(react@19.2.1)) @@ -154,6 +157,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@turf/boolean-intersects': + specifier: ^7.3.1 + version: 7.3.1 '@turf/centroid': specifier: ^7.3.1 version: 7.3.1 @@ -2224,6 +2230,11 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@geomatico/maplibre-cog-protocol@file:../packages/maplibre-cog-protocol': + resolution: {directory: ../packages/maplibre-cog-protocol, type: directory} + peerDependencies: + maplibre-gl: ^4.5.0 || ^5.0.0 + '@gerrit0/mini-shiki@3.15.0': resolution: {integrity: sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==} @@ -2362,6 +2373,10 @@ packages: '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + '@mapbox/sphericalmercator@1.2.0': + resolution: {integrity: sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==} + hasBin: true + '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} @@ -8871,6 +8886,10 @@ packages: resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} engines: {node: '>=12'} + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + quickselect@1.1.1: resolution: {integrity: sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==} @@ -12958,6 +12977,15 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@geomatico/maplibre-cog-protocol@file:../packages/maplibre-cog-protocol(maplibre-gl@5.13.0)': + dependencies: + '@mapbox/sphericalmercator': 1.2.0 + d3-scale: 4.0.2 + geotiff: 2.1.3 + maplibre-gl: 5.13.0 + proj4: 2.20.2 + quick-lru: 7.3.0 + '@gerrit0/mini-shiki@3.15.0': dependencies: '@shikijs/engine-oniguruma': 3.15.0 @@ -13120,6 +13148,8 @@ snapshots: '@mapbox/point-geometry@1.1.0': {} + '@mapbox/sphericalmercator@1.2.0': {} + '@mapbox/tiny-sdf@2.0.7': {} '@mapbox/unitbezier@0.0.1': {} @@ -21027,6 +21057,8 @@ snapshots: quick-lru@6.1.2: {} + quick-lru@7.3.0: {} + quickselect@1.1.1: {} quickselect@2.0.0: {} From 0faed6b494107bb974fa17c046d9e8c7b1b4ee47 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:42:55 +0100 Subject: [PATCH 02/13] feat: improve elevation map --- .../components/blocks/atlas/atlas-legend.tsx | 43 +++-- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 166 ++++++++++++++---- 2 files changed, 158 insertions(+), 51 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx index e3e4a90b0..1b5a4070c 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -5,35 +5,44 @@ interface ElevationLegendProps { min?: number max?: number loading?: boolean + hoverValue?: number | null + showScale?: boolean } -export function ElevationLegend({ min, max, loading }: ElevationLegendProps) { +export function ElevationLegend({ min, max, loading, hoverValue, showScale = true }: ElevationLegendProps) { return ( -
+

- Hoogte (NAP) + Hoogte (AHN4)

{loading && }
-
-
-
Red) - background: "linear-gradient(to right, #5e4fa2, #3288bd, #66c2a5, #abdda4, #e6f598, #ffffbf, #fee08b, #fdae61, #f46d43, #d53e4f, #9e0142)" - }} - /> + {showScale && ( +
+
+
Red) + background: "linear-gradient(to right, #5e4fa2, #3288bd, #66c2a5, #abdda4, #e6f598, #ffffbf, #fee08b, #fdae61, #f46d43, #d53e4f, #9e0142)" + }} + /> +
+
+ {min !== undefined ? `${min.toFixed(1)}m` : "Laag"} + {max !== undefined ? `${max.toFixed(1)}m` : "Hoog"} +
+ {hoverValue !== undefined && hoverValue !== null && ( +
+ Hoogte: {hoverValue.toFixed(2)} m NAP +
+ )}
-
- {min !== undefined ? `${min.toFixed(1)}m` : "Laag"} - {max !== undefined ? `${max.toFixed(1)}m` : "Hoog"} -
-
+ )}
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 39c2a9cc8..8c3e58a56 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 @@ -16,6 +16,7 @@ import { type MapRef, type ViewState, type ViewStateChangeEvent, + type MapLayerMouseEvent } from "react-map-gl/maplibre" import { data, @@ -75,6 +76,7 @@ interface ActiveTile { id: string url: string cogUrl: string | null + cogUrlHillshade: string | null } // Meta @@ -146,10 +148,10 @@ export default function FarmAtlasElevationBlock() { // State const [indexData, setIndexData] = useState(null) const [activeTiles, setActiveTiles] = useState([]) - const [isLoadingCog, setIsLoadingCog] = useState(true) const [isUpdating, setIsUpdating] = useState(false) const [legendMin, setLegendMin] = useState(-5) const [legendMax, setLegendMax] = useState(50) + const [hoverElevation, setHoverElevation] = useState(null) // ViewState logic const initialViewState = getViewState(fields) @@ -208,9 +210,19 @@ export default function FarmAtlasElevationBlock() { const updateVisibleTiles = useCallback(async () => { if (!mapRef.current || !indexData) return - setIsUpdating(true) - const bounds = mapRef.current.getBounds() + const zoom = mapRef.current.getZoom() + + // If zoomed out, clear active tiles to save resources (WMS will take over) + if (zoom < 13) { + if (activeTiles.length > 0) { + setActiveTiles([]) + setIsLoadingCog(false) + } + return + } + + setIsUpdating(true) const sw = bounds.getSouthWest() const ne = bounds.getNorthEast() const nw = bounds.getNorthWest() @@ -227,12 +239,12 @@ export default function FarmAtlasElevationBlock() { ] as [number, number][] // Find intersecting tiles - // Optimization: limit to e.g. 6 tiles to avoid overload + // Optimization: limit to e.g. 24 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, 6) + }).slice(0, 24) // Calculate global min/max for the viewport by sampling const samplePoints: {lng: number, lat: number}[] = [] @@ -257,14 +269,14 @@ export default function FarmAtlasElevationBlock() { const ring = (f.geometry as any).coordinates[0] return isPointInPolygon(rdP, ring) }) - if (feature && feature.properties) { + if (feature?.properties) { const url = feature.properties.url || feature.properties.href || feature.properties.download_url if (url) { try { // Requesting location value // Note: locationValues caches internal resources so it's efficient const vals = await locationValues(url, { longitude: p.lng, latitude: p.lat }) - if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { + if (vals && vals.length > 0 && !Number.isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { return vals[0] } } catch {} @@ -298,7 +310,8 @@ export default function FarmAtlasElevationBlock() { setLegendMax(max) // Format for color scale - const colorParam = `#color:BrewerSpectral11,${min},${max},-` + // Reverted to BrewerSpectral11 (Reversed, Continuous) + const colorParam = `#color:BrewerSpectral11,${min},${max},-c` const newTiles: ActiveTile[] = [] for (const feature of visibleFeatures) { @@ -311,10 +324,19 @@ export default function FarmAtlasElevationBlock() { if (!url) continue const id = feature.properties.kaartbladNr || url - newTiles.push({ id, url, cogUrl: `cog://${url}${colorParam}` }) + newTiles.push({ + id, + url, + cogUrl: `cog://${url}${colorParam}`, + cogUrlHillshade: `cog://${url}#dem` + }) } // Update state + // Simple diff to see if we need to update (comparing URLs including color params) + // If colors change, we want to update all tiles + const keyNew = newTiles.map(t => t.cogUrl).sort().join("|") + setActiveTiles(newTiles) setIsLoadingCog(false) @@ -327,16 +349,12 @@ export default function FarmAtlasElevationBlock() { }, [indexData, activeTiles]) // Throttle updates - // const throttledUpdate = useMemo(() => throttle(updateVisibleTiles, 500), [updateVisibleTiles]) - // Using throttle directly in render is tricky with deps. - // We will use a ref to store the latest updateVisibleTiles and throttle that. - const updateRef = useRef(updateVisibleTiles) useEffect(() => { updateRef.current = updateVisibleTiles }, [updateVisibleTiles]) const throttledUpdate = useMemo(() => throttle(() => updateRef.current(), 500, { leading: true, trailing: true }), []) - // Initial update + // Initial update when map loads or index loads useEffect(() => { const timer = setTimeout(() => { throttledUpdate() @@ -344,16 +362,52 @@ export default function FarmAtlasElevationBlock() { return () => clearTimeout(timer) }, [indexData]) + // Handle hover to show elevation value + const handleMouseMove = useCallback(throttle(async (event: MapLayerMouseEvent) => { + // If zoomed out (WMS visible), don't fetch values + if (!mapRef.current || mapRef.current.getZoom() < 13) { + setHoverElevation(null) + return + } + + if (!indexData || activeTiles.length === 0) return + + const { lng, lat } = event.lngLat + + try { + const rdP = proj4("EPSG:28992").forward([lng, lat]) as [number, number] + + // Find tile under mouse + // We check activeTiles first as they are already filtered + // But we need geometry. indexData has geometry. + // Find matching feature in indexData + const feature = indexData.features.find((f) => { + // Optimization: check if ID matches an active tile? + 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) { + // Use locationValues + const values = await locationValues(url, { longitude: lng, latitude: lat }) + if (values && values.length > 0 && !Number.isNaN(values[0])) { + setHoverElevation(values[0]) + return + } + } + } + setHoverElevation(null) + } catch (e) { + // console.warn("Error fetching hover elevation", e) + setHoverElevation(null) + } + }, 100), [indexData, activeTiles]) + return ( -
- {isLoadingCog && ( -
-
- - Hoogtekaart laden... -
-
- )} +
@@ -377,32 +432,75 @@ export default function FarmAtlasElevationBlock() { } /> - {/* Render Active Tiles */} - {activeTiles.map((tile) => ( + {/* WMS Overview Layer (Zoom < 13) */} + {viewState.zoom < 13 && ( + )} + + {/* Render Active Tiles (Zoom >= 13) */} + {activeTiles.map((tile) => ( + <> + + + + + + + ))} = 13} />
) -} +} \ No newline at end of file From 4bc694b422a82328df91558cfa0fd11e74aa556d Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:21:48 +0100 Subject: [PATCH 03/13] feat: make elevation layer available in app --- .../blocks/atlas/atlas-controls.tsx | 84 +++++++++++++--- .../components/blocks/atlas/atlas-legend.tsx | 2 +- .../app/components/blocks/header/atlas.tsx | 36 ++++++- .../app/components/blocks/sidebar/apps.tsx | 90 ++++++++++++----- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 99 +++++++++++-------- fdm-app/package.json | 1 + 6 files changed, 229 insertions(+), 83 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx index e3400b754..a68ee5ca1 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx @@ -1,4 +1,4 @@ -import { Layers } from "lucide-react" +import { Layers, Mountain } from "lucide-react" import type { ControlPosition, Map as MapLibreMap } from "maplibre-gl" import { useEffect } from "react" import { createRoot, type Root } from "react-dom/client" @@ -19,6 +19,8 @@ type ControlsProps = { }) => void showFields?: boolean onToggleFields?: () => void + showElevation?: boolean + onToggleElevation?: () => void } export function Controls(props: ControlsProps) { @@ -36,6 +38,12 @@ export function Controls(props: ControlsProps) { onToggle={props.onToggleFields} /> )} + {props.showElevation !== undefined && props.onToggleElevation && ( + + )} void + labelActive: string + labelInactive: string + Icon: React.ElementType } -function FieldsButton({ showFields, onToggle }: FieldsButtonProps) { +function ControlButton({ active, onToggle, labelActive, labelInactive, Icon }: ButtonProps) { return ( ) } -class CustomFieldsControl implements IControl { +class CustomControl implements IControl { _map: MapLibreMap | undefined _container: HTMLDivElement | undefined _root: Root | undefined - _props: FieldsButtonProps + _props: ButtonProps - constructor(initialProps: FieldsButtonProps) { + constructor(initialProps: ButtonProps) { this._props = initialProps } @@ -104,14 +115,14 @@ class CustomFieldsControl implements IControl { return "top-right" } - updateProps(newProps: FieldsButtonProps) { + updateProps(newProps: ButtonProps) { this._props = newProps this._render() } _render() { if (this._root) { - this._root.render() + this._root.render() } } } @@ -125,14 +136,57 @@ function FieldsControl({ showFields: boolean onToggle: () => void }) { - const control = useControl( - () => new CustomFieldsControl({ showFields, onToggle }), + const control = useControl( + () => new CustomControl({ + active: showFields, + onToggle, + labelActive: "Verberg percelen", + labelInactive: "Toon percelen", + Icon: Layers + }), CONTROL_OPTIONS, ) useEffect(() => { - control.updateProps({ showFields, onToggle }) + control.updateProps({ + active: showFields, + onToggle, + labelActive: "Verberg percelen", + labelInactive: "Toon percelen", + Icon: Layers + }) }, [control, showFields, onToggle]) return null } + +function ElevationControl({ + showElevation, + onToggle, +}: { + showElevation: boolean + onToggle: () => void +}) { + const control = useControl( + () => new CustomControl({ + active: showElevation, + onToggle, + labelActive: "Verberg hoogtekaart", + labelInactive: "Toon hoogtekaart", + Icon: Mountain + }), + CONTROL_OPTIONS, + ) + + useEffect(() => { + control.updateProps({ + active: showElevation, + onToggle, + labelActive: "Verberg hoogtekaart", + labelInactive: "Toon hoogtekaart", + Icon: Mountain + }) + }, [control, showElevation, onToggle]) + + return null +} diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx index 1b5a4070c..4748c1c4c 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -11,7 +11,7 @@ interface ElevationLegendProps { export function ElevationLegend({ min, max, loading, hoverValue, showScale = true }: ElevationLegendProps) { return ( -
+
diff --git a/fdm-app/app/components/blocks/header/atlas.tsx b/fdm-app/app/components/blocks/header/atlas.tsx index 51e2f8a81..cee2b2303 100644 --- a/fdm-app/app/components/blocks/header/atlas.tsx +++ b/fdm-app/app/components/blocks/header/atlas.tsx @@ -1,12 +1,25 @@ import { useCalendarStore } from "@/app/store/calendar" +import { ChevronDown } from "lucide-react" +import { useLocation, NavLink } from "react-router" import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, + BreadcrumbPage, } from "~/components/ui/breadcrumb" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) { const calendar = useCalendarStore((state) => state.calendar) + const location = useLocation() + + const isElevation = location.pathname.includes("/elevation") + const currentName = isElevation ? "Hoogtekaart" : "Gewaspercelen" return ( <> @@ -18,11 +31,24 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) { - - Percelen - + + + {currentName} + + + + + + Gewaspercelen + + + + + Hoogtekaart + + + + ) diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index d747db21e..d97ce6092 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -41,12 +41,20 @@ export function SidebarApps() { location.pathname === "/farm" || location.pathname === "/farm/" let atlasLink: string | undefined + let atlasFieldsLink: string | undefined + let atlasElevationLink: string | undefined if (isCreateFarmWizard) { atlasLink = undefined + atlasFieldsLink = undefined + atlasElevationLink = undefined } else if (farmId) { atlasLink = `/farm/${farmId}/${selectedCalendar}/atlas` + atlasFieldsLink = `/farm/${farmId}/${selectedCalendar}/atlas/fields` + atlasElevationLink = `/farm/${farmId}/${selectedCalendar}/atlas/elevation` } else { atlasLink = `/farm/undefined/${selectedCalendar}/atlas` + atlasFieldsLink = `/farm/undefined/${selectedCalendar}/atlas/fields` + atlasElevationLink = `/farm/undefined/${selectedCalendar}/atlas/elevation` } let nitrogenBalanceLink: string | undefined @@ -89,29 +97,65 @@ export function SidebarApps() { Apps - - {atlasLink ? ( - - - - Atlas - - - ) : ( - - - - Atlas - - - )} - + + + {atlasLink ? ( + + + + Atlas + + + + + ) : ( + + + + Atlas + + + )} + + + + {atlasFieldsLink ? ( + + + Gewaspercelen + + + ) : null} + + + {atlasElevationLink ? ( + + + Hoogtekaart + + + ) : null} + + + + + (-5) const [legendMax, setLegendMax] = useState(50) const [hoverElevation, setHoverElevation] = useState(null) + const [showFields, setShowFields] = useState(true) + const [showElevation, setShowElevation] = useState(true) + + const fieldsSavedId = "fieldsSaved" + const fieldsSavedStyle = getFieldsStyle(fieldsSavedId) + const layerLayout = { visibility: showFields ? "visible" : "none" } as const + + const onToggleElevation = useCallback(() => { + setShowElevation((prev) => !prev) + }, []) // ViewState logic const initialViewState = getViewState(fields) const [viewState, setViewState] = useState(() => { if (typeof window !== "undefined") { const savedViewState = sessionStorage.getItem( - "mapViewStateElevation", + "mapViewState", ) if (savedViewState) { try { return JSON.parse(savedViewState) } catch { - sessionStorage.removeItem("mapViewStateElevation") + sessionStorage.removeItem("mapViewState") } } } @@ -183,7 +196,7 @@ export default function FarmAtlasElevationBlock() { return } sessionStorage.setItem( - "mapViewStateElevation", + "mapViewState", JSON.stringify(viewState), ) }, [viewState]) @@ -200,7 +213,6 @@ export default function FarmAtlasElevationBlock() { setIndexData(data) } catch (e) { console.error("Error fetching COG index:", e) - setIsLoadingCog(false) } } fetchIndex() @@ -209,7 +221,7 @@ export default function FarmAtlasElevationBlock() { // Function to update visible tiles const updateVisibleTiles = useCallback(async () => { if (!mapRef.current || !indexData) return - + const bounds = mapRef.current.getBounds() const zoom = mapRef.current.getZoom() @@ -217,7 +229,6 @@ export default function FarmAtlasElevationBlock() { if (zoom < 13) { if (activeTiles.length > 0) { setActiveTiles([]) - setIsLoadingCog(false) } return } @@ -269,14 +280,13 @@ export default function FarmAtlasElevationBlock() { const ring = (f.geometry as any).coordinates[0] return isPointInPolygon(rdP, ring) }) - if (feature?.properties) { + if (feature && feature.properties) { const url = feature.properties.url || feature.properties.href || feature.properties.download_url if (url) { try { // Requesting location value - // Note: locationValues caches internal resources so it's efficient 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) { + if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { return vals[0] } } catch {} @@ -290,7 +300,6 @@ export default function FarmAtlasElevationBlock() { min = Math.min(...validValues) max = Math.max(...validValues) } else { - // Fallback if no data found (e.g. outside coverage or error) min = -5 max = 50 } @@ -310,7 +319,6 @@ export default function FarmAtlasElevationBlock() { setLegendMax(max) // Format for color scale - // Reverted to BrewerSpectral11 (Reversed, Continuous) const colorParam = `#color:BrewerSpectral11,${min},${max},-c` const newTiles: ActiveTile[] = [] @@ -332,13 +340,9 @@ export default function FarmAtlasElevationBlock() { }) } - // Update state - // Simple diff to see if we need to update (comparing URLs including color params) - // If colors change, we want to update all tiles const keyNew = newTiles.map(t => t.cogUrl).sort().join("|") setActiveTiles(newTiles) - setIsLoadingCog(false) } catch (e) { console.error("Error updating visible tiles:", e) @@ -360,11 +364,10 @@ export default function FarmAtlasElevationBlock() { throttledUpdate() }, 1000) return () => clearTimeout(timer) - }, [indexData]) + }, [throttledUpdate]) // Handle hover to show elevation value const handleMouseMove = useCallback(throttle(async (event: MapLayerMouseEvent) => { - // If zoomed out (WMS visible), don't fetch values if (!mapRef.current || mapRef.current.getZoom() < 13) { setHoverElevation(null) return @@ -377,23 +380,17 @@ export default function FarmAtlasElevationBlock() { try { const rdP = proj4("EPSG:28992").forward([lng, lat]) as [number, number] - // Find tile under mouse - // We check activeTiles first as they are already filtered - // But we need geometry. indexData has geometry. - // Find matching feature in indexData const feature = indexData.features.find((f) => { - // Optimization: check if ID matches an active tile? if (!f.geometry || f.geometry.type !== "Polygon") return false const ring = (f.geometry as any).coordinates[0] return isPointInPolygon(rdP, ring) }) - if (feature?.properties) { + if (feature && feature.properties) { const url = feature.properties.url || feature.properties.href || feature.properties.download_url if (url) { - // Use locationValues const values = await locationValues(url, { longitude: lng, latitude: lat }) - if (values && values.length > 0 && !Number.isNaN(values[0])) { + if (values && values.length > 0 && !isNaN(values[0])) { setHoverElevation(values[0]) return } @@ -401,13 +398,12 @@ export default function FarmAtlasElevationBlock() { } setHoverElevation(null) } catch (e) { - // console.warn("Error fetching hover elevation", e) setHoverElevation(null) } - }, 100), [indexData, activeTiles]) + }, 100), []) return ( -
+
@@ -430,10 +426,14 @@ export default function FarmAtlasElevationBlock() { zoom, })) } + showFields={showFields} + onToggleFields={() => setShowFields(!showFields)} + showElevation={showElevation} + onToggleElevation={onToggleElevation} /> {/* WMS Overview Layer (Zoom < 13) */} - {viewState.zoom < 13 && ( + {viewState.zoom < 13 && showElevation && ( = 13) */} - {activeTiles.map((tile) => ( + {viewState.zoom >= 13 && showElevation && activeTiles.map((tile) => ( <> ))} - = 13} - /> + {/* Fields Overlay (Saved Fields) */} + {fields && ( + + + + )} + +
+ = 13 && showElevation} + /> +
+ +
+
) diff --git a/fdm-app/package.json b/fdm-app/package.json index c81043483..561524ed6 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -41,6 +41,7 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-table": "^8.21.3", "@turf/centroid": "^7.3.1", + "@turf/boolean-intersects": "^7.3.1", "@turf/turf": "^7.3.1", "better-auth": "catalog:", "chrono-node": "^2.9.0", From 4d2d6188c727534e5bded306f9c81a11d42bb2a2 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:23:09 +0100 Subject: [PATCH 04/13] feat: add attribution of AHN --- .../routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 83670179b..ee3c73c53 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 @@ -280,7 +280,7 @@ export default function FarmAtlasElevationBlock() { const ring = (f.geometry as any).coordinates[0] return isPointInPolygon(rdP, ring) }) - if (feature && feature.properties) { + if (feature?.properties) { const url = feature.properties.url || feature.properties.href || feature.properties.download_url if (url) { try { @@ -386,7 +386,7 @@ export default function FarmAtlasElevationBlock() { return isPointInPolygon(rdP, ring) }) - if (feature && feature.properties) { + if (feature?.properties) { const url = feature.properties.url || feature.properties.href || feature.properties.download_url if (url) { const values = await locationValues(url, { longitude: lng, latitude: lat }) @@ -441,6 +441,7 @@ export default function FarmAtlasElevationBlock() { "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} + attribution="© PDOK, AHN" > Date: Mon, 8 Dec 2025 10:18:57 +0100 Subject: [PATCH 05/13] fix: errors in console --- .../blocks/atlas/atlas-controls.tsx | 5 +- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 56 +++++++++++-------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx index a68ee5ca1..3ab8e23cb 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx @@ -103,7 +103,10 @@ class CustomControl implements IControl { onRemove(): void { if (this._root) { - this._root.unmount() + const root = this._root + setTimeout(() => { + root.unmount() + }, 0) this._root = undefined } this._container?.parentNode?.removeChild(this._container) 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 ee3c73c53..a43ceba1f 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 @@ -8,7 +8,7 @@ import { getFields } from "@svenvw/fdm-core" import throttle from "lodash.throttle" import type { FeatureCollection } from "geojson" import maplibregl from "maplibre-gl" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState, Fragment } from "react" import { Layer, Map as MapGL, @@ -273,24 +273,26 @@ export default function FarmAtlasElevationBlock() { // Gather values for samples const values = await Promise.all(samplePoints.map(async (p) => { - 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) { - try { - // Requesting location value - const vals = await locationValues(url, { longitude: p.lng, latitude: p.lat }) - if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { - return vals[0] - } - } catch {} - } + 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 }) + if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { + return vals[0] + } + } + } + } catch { + // Ignore errors for individual points } return null })) @@ -366,8 +368,16 @@ export default function FarmAtlasElevationBlock() { return () => clearTimeout(timer) }, [throttledUpdate]) + // Refs for state accessible in throttled functions + const stateRef = useRef({ indexData, activeTiles }) + useEffect(() => { + stateRef.current = { indexData, activeTiles } + }, [indexData, activeTiles]) + // Handle hover to show elevation value - const handleMouseMove = useCallback(throttle(async (event: MapLayerMouseEvent) => { + const handleMouseMove = useMemo(() => throttle(async (event: MapLayerMouseEvent) => { + const { indexData, activeTiles } = stateRef.current + if (!mapRef.current || mapRef.current.getZoom() < 13) { setHoverElevation(null) return @@ -453,9 +463,8 @@ export default function FarmAtlasElevationBlock() { {/* Render Active Tiles (Zoom >= 13) */} {viewState.zoom >= 13 && showElevation && activeTiles.map((tile) => ( - <> + - + ))} {/* Fields Overlay (Saved Fields) */} From d0c88528ad05f2c1cc01b7dec89e0f036f0858a9 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:33:19 +0100 Subject: [PATCH 06/13] fix: show saved fields on top of elevation layer --- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 350 +++++++++++------- 1 file changed, 208 insertions(+), 142 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 a43ceba1f..df86d6808 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 @@ -8,7 +8,14 @@ import { getFields } from "@svenvw/fdm-core" import throttle from "lodash.throttle" import type { FeatureCollection } from "geojson" import maplibregl from "maplibre-gl" -import { useCallback, useEffect, useMemo, useRef, useState, Fragment } from "react" +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + Fragment, +} from "react" import { Layer, Map as MapGL, @@ -16,7 +23,7 @@ import { type MapRef, type ViewState, type ViewStateChangeEvent, - type MapLayerMouseEvent + type MapLayerMouseEvent, } from "react-map-gl/maplibre" import { data, @@ -67,7 +74,10 @@ function isPointInPolygon(point: [number, number], vs: [number, number][]) { // Helper: Check if polygon intersects polygon (simple AABB check for index speed, then detail?) // For now, we just check if any point of tile is in view or view in tile? // Simpler: Convert Viewport to RD Polygon, check intersection with Tile Polygon (also RD). -function polygonIntersectsPolygon(poly1: [number, number][], poly2: [number, number][]) { +function polygonIntersectsPolygon( + poly1: [number, number][], + poly2: [number, number][], +) { // Simplified: Check if any point of poly1 is in poly2 OR any point of poly2 is in poly1 // This is not 100% robust for crossing polygons but good enough for tiles for (const p of poly1) if (isPointInPolygon(p, poly2)) return true @@ -160,6 +170,7 @@ export default function FarmAtlasElevationBlock() { const fieldsSavedId = "fieldsSaved" const fieldsSavedStyle = getFieldsStyle(fieldsSavedId) + const fieldsSavedOutlineStyle = getFieldsStyle("fieldsSavedOutline") const layerLayout = { visibility: showFields ? "visible" : "none" } as const const onToggleElevation = useCallback(() => { @@ -170,9 +181,7 @@ export default function FarmAtlasElevationBlock() { const initialViewState = getViewState(fields) const [viewState, setViewState] = useState(() => { if (typeof window !== "undefined") { - const savedViewState = sessionStorage.getItem( - "mapViewState", - ) + const savedViewState = sessionStorage.getItem("mapViewState") if (savedViewState) { try { return JSON.parse(savedViewState) @@ -195,10 +204,7 @@ export default function FarmAtlasElevationBlock() { isFirstRender.current = false return } - sessionStorage.setItem( - "mapViewState", - JSON.stringify(viewState), - ) + sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) }, [viewState]) // Fetch COG Index once @@ -251,14 +257,17 @@ export default function FarmAtlasElevationBlock() { // Find intersecting tiles // Optimization: limit to e.g. 24 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) + 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) // Calculate global min/max for the viewport by sampling - const samplePoints: {lng: number, lat: number}[] = [] + const samplePoints: { lng: number; lat: number }[] = [] const gridSize = 4 // 4x4 = 16 points for (let i = 0; i <= gridSize; i++) { for (let j = 0; j <= gridSize; j++) { @@ -270,34 +279,52 @@ 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 }) - if (vals && vals.length > 0 && !isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000) { - return vals[0] + 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, + }) + if ( + vals && + vals.length > 0 && + !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 + }), + ) - const validValues = values.filter(v => v !== null) as number[] + const validValues = values.filter((v) => v !== null) as number[] if (validValues.length > 0) { min = Math.min(...validValues) max = Math.max(...validValues) @@ -311,12 +338,12 @@ export default function FarmAtlasElevationBlock() { min -= 0.5 max += 0.5 } - + // Pad range slightly const range = max - min min -= range * 0.05 max += range * 0.05 - + setLegendMin(min) setLegendMax(max) @@ -330,35 +357,45 @@ export default function FarmAtlasElevationBlock() { feature.properties.url || feature.properties.href || feature.properties.download_url - + if (!url) continue const id = feature.properties.kaartbladNr || url - - newTiles.push({ - id, - url, + + newTiles.push({ + id, + url, cogUrl: `cog://${url}${colorParam}`, - cogUrlHillshade: `cog://${url}#dem` + cogUrlHillshade: `cog://${url}#dem`, }) } - const keyNew = newTiles.map(t => t.cogUrl).sort().join("|") - + const keyNew = newTiles + .map((t) => t.cogUrl) + .sort() + .join("|") + setActiveTiles(newTiles) - } catch (e) { console.error("Error updating visible tiles:", e) } finally { setIsUpdating(false) } - }, [indexData, activeTiles]) // Throttle updates const updateRef = useRef(updateVisibleTiles) - useEffect(() => { updateRef.current = updateVisibleTiles }, [updateVisibleTiles]) - - const throttledUpdate = useMemo(() => throttle(() => updateRef.current(), 500, { leading: true, trailing: true }), []) + useEffect(() => { + updateRef.current = updateVisibleTiles + }, [updateVisibleTiles]) + + const throttledUpdate = useMemo( + () => + throttle(() => updateRef.current(), 500, { + leading: true, + trailing: true, + }), + [], + ) // Initial update when map loads or index loads useEffect(() => { @@ -375,46 +412,63 @@ export default function FarmAtlasElevationBlock() { }, [indexData, activeTiles]) // Handle hover to show elevation value - const handleMouseMove = useMemo(() => throttle(async (event: MapLayerMouseEvent) => { - const { indexData, activeTiles } = stateRef.current - - if (!mapRef.current || mapRef.current.getZoom() < 13) { - setHoverElevation(null) - return - } + const handleMouseMove = useMemo( + () => + throttle(async (event: MapLayerMouseEvent) => { + const { indexData, activeTiles } = stateRef.current + + if (!mapRef.current || mapRef.current.getZoom() < 13) { + setHoverElevation(null) + return + } - if (!indexData || activeTiles.length === 0) return - - const { lng, lat } = event.lngLat - - try { - const rdP = proj4("EPSG:28992").forward([lng, lat]) as [number, number] - - const feature = indexData.features.find((f) => { - if (!f.geometry || f.geometry.type !== "Polygon") return false - const ring = (f.geometry as any).coordinates[0] - return isPointInPolygon(rdP, ring) - }) + if (!indexData || activeTiles.length === 0) return + + const { lng, lat } = event.lngLat + + try { + const rdP = proj4("EPSG:28992").forward([lng, lat]) as [ + number, + number, + ] + + const feature = indexData.features.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) { - const values = await locationValues(url, { longitude: lng, latitude: lat }) - if (values && values.length > 0 && !isNaN(values[0])) { - setHoverElevation(values[0]) - return + if (feature?.properties) { + const url = + feature.properties.url || + feature.properties.href || + feature.properties.download_url + if (url) { + const values = await locationValues(url, { + longitude: lng, + latitude: lat, + }) + if ( + values && + values.length > 0 && + !isNaN(values[0]) + ) { + setHoverElevation(values[0]) + return + } + } } + setHoverElevation(null) + } catch (e) { + setHoverElevation(null) } - } - setHoverElevation(null) - } catch (e) { - setHoverElevation(null) - } - }, 100), []) + }, 100), + [], + ) return ( -
- +
- )} {/* Render Active Tiles (Zoom >= 13) */} - {viewState.zoom >= 13 && showElevation && activeTiles.map((tile) => ( - - - - - - - - - ))} + {viewState.zoom >= 13 && + showElevation && + activeTiles.map((tile) => ( + + + + + + + + + ))} {/* Fields Overlay (Saved Fields) */} {fields && ( - + + {/* Outline Layer - Visual */} + {/* Fill Layer - Invisible but Clickable/Hoverable */} + )}
- = 13 && showElevation} @@ -534,4 +600,4 @@ export default function FarmAtlasElevationBlock() {
) -} \ No newline at end of file +} From 36c04f019126d8a6411ae0ed5eab82d2c536baf7 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:43:50 +0100 Subject: [PATCH 07/13] feat: add message if connection is slow --- .../components/blocks/atlas/atlas-legend.tsx | 15 +++- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 70 ++++++++++++++++--- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx index 4748c1c4c..6aa9f17ff 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -7,9 +7,10 @@ interface ElevationLegendProps { loading?: boolean hoverValue?: number | null showScale?: boolean + networkStatus?: "idle" | "loading" | "slow" | "error" } -export function ElevationLegend({ min, max, loading, hoverValue, showScale = true }: ElevationLegendProps) { +export function ElevationLegend({ min, max, loading, hoverValue, showScale = true, networkStatus }: ElevationLegendProps) { return (
@@ -21,6 +22,18 @@ export function ElevationLegend({ min, max, loading, hoverValue, showScale = tru {loading && }
+ {networkStatus === "slow" && ( +
+ Trage verbinding... +
+ )} + + {networkStatus === "error" && ( +
+ Fout bij laden +
+ )} + {showScale && (
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 df86d6808..a18a51f79 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 @@ -167,6 +167,9 @@ export default function FarmAtlasElevationBlock() { const [hoverElevation, setHoverElevation] = useState(null) const [showFields, setShowFields] = useState(true) const [showElevation, setShowElevation] = useState(true) + const [networkStatus, setNetworkStatus] = useState< + "idle" | "loading" | "slow" | "error" + >("idle") const fieldsSavedId = "fieldsSaved" const fieldsSavedStyle = getFieldsStyle(fieldsSavedId) @@ -210,13 +213,45 @@ export default function FarmAtlasElevationBlock() { // Fetch COG Index once useEffect(() => { async function fetchIndex() { + const cacheKey = "ahn_kaartbladindex_v1" try { + // Try cache + if (typeof localStorage !== "undefined") { + const cached = localStorage.getItem(cacheKey) + if (cached) { + try { + const { timestamp, data } = JSON.parse(cached) + // Cache for 7 days + if ( + Date.now() - timestamp < + 7 * 24 * 60 * 60 * 1000 + ) { + setIndexData(data) + return + } + } catch { + localStorage.removeItem(cacheKey) + } + } + } + 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 COG index") const data = (await response.json()) as FeatureCollection setIndexData(data) + + if (typeof localStorage !== "undefined") { + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ timestamp: Date.now(), data }), + ) + } catch (e) { + console.warn("Cache storage failed", e) + } + } } catch (e) { console.error("Error fetching COG index:", e) } @@ -224,6 +259,8 @@ export default function FarmAtlasElevationBlock() { fetchIndex() }, []) + const updateId = useRef(0) + // Function to update visible tiles const updateVisibleTiles = useCallback(async () => { if (!mapRef.current || !indexData) return @@ -239,7 +276,17 @@ export default function FarmAtlasElevationBlock() { return } + const currentId = ++updateId.current setIsUpdating(true) + setNetworkStatus("loading") + + // Detect slow network + const slowTimer = setTimeout(() => { + if (updateId.current === currentId) { + setNetworkStatus("slow") + } + }, 2000) + const sw = bounds.getSouthWest() const ne = bounds.getNorthEast() const nw = bounds.getNorthWest() @@ -268,7 +315,7 @@ export default function FarmAtlasElevationBlock() { // Calculate global min/max for the viewport by sampling const samplePoints: { lng: number; lat: number }[] = [] - const gridSize = 4 // 4x4 = 16 points + const gridSize = 3 for (let i = 0; i <= gridSize; i++) { for (let j = 0; j <= gridSize; j++) { const lng = sw.lng + (ne.lng - sw.lng) * (i / gridSize) @@ -309,7 +356,7 @@ export default function FarmAtlasElevationBlock() { if ( vals && vals.length > 0 && - !isNaN(vals[0]) && + !Number.isNaN(vals[0]) && vals[0] > -100 && vals[0] < 1000 ) { @@ -324,6 +371,8 @@ export default function FarmAtlasElevationBlock() { }), ) + if (updateId.current !== currentId) return + const validValues = values.filter((v) => v !== null) as number[] if (validValues.length > 0) { min = Math.min(...validValues) @@ -369,16 +418,18 @@ export default function FarmAtlasElevationBlock() { }) } - const keyNew = newTiles - .map((t) => t.cogUrl) - .sort() - .join("|") - setActiveTiles(newTiles) + setNetworkStatus("idle") } catch (e) { console.error("Error updating visible tiles:", e) + if (updateId.current === currentId) { + setNetworkStatus("error") + } } finally { - setIsUpdating(false) + if (updateId.current === currentId) { + setIsUpdating(false) + } + clearTimeout(slowTimer) } }, [indexData, activeTiles]) @@ -463,7 +514,7 @@ export default function FarmAtlasElevationBlock() { } catch (e) { setHoverElevation(null) } - }, 100), + }, 200), [], ) @@ -589,6 +640,7 @@ export default function FarmAtlasElevationBlock() { loading={isUpdating} hoverValue={hoverElevation} showScale={viewState.zoom >= 13 && showElevation} + networkStatus={networkStatus} />
Date: Mon, 15 Dec 2025 10:47:52 +0100 Subject: [PATCH 08/13] fix: installing maplibre-cog-protocol from fork --- fdm-app/package.json | 3 ++- pnpm-lock.yaml | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/fdm-app/package.json b/fdm-app/package.json index 256a55206..b531239a9 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@date-fns/tz": "^1.4.1", + "@geomatico/maplibre-cog-protocol": "github:SvenVw/maplibre-cog-protocol#add-prepare", "@hookform/resolvers": "^5.2.2", "@lucide/lab": "^0.1.2", "@mapbox/geojson-extent": "^1.0.1", @@ -40,8 +41,8 @@ "@svenvw/fdm-data": "workspace:*", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-table": "^8.21.3", - "@turf/centroid": "^7.3.1", "@turf/boolean-intersects": "^7.3.1", + "@turf/centroid": "^7.3.1", "@turf/turf": "^7.3.1", "better-auth": "catalog:", "chrono-node": "^2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164fa6333..83a2197f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,8 +80,8 @@ importers: specifier: ^1.4.1 version: 1.4.1 '@geomatico/maplibre-cog-protocol': - specifier: file:C:/Users/sven.verweij/Applications/packages/maplibre-cog-protocol - version: file:../packages/maplibre-cog-protocol(maplibre-gl@5.13.0) + specifier: github:SvenVw/maplibre-cog-protocol#add-prepare + version: https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.13.0) '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.68.0(react@19.2.1)) @@ -2230,8 +2230,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@geomatico/maplibre-cog-protocol@file:../packages/maplibre-cog-protocol': - resolution: {directory: ../packages/maplibre-cog-protocol, type: directory} + '@geomatico/maplibre-cog-protocol@https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904': + resolution: {tarball: https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904} + version: 0.8.0 peerDependencies: maplibre-gl: ^4.5.0 || ^5.0.0 @@ -12977,7 +12978,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@geomatico/maplibre-cog-protocol@file:../packages/maplibre-cog-protocol(maplibre-gl@5.13.0)': + '@geomatico/maplibre-cog-protocol@https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.13.0)': dependencies: '@mapbox/sphericalmercator': 1.2.0 d3-scale: 4.0.2 From 39a5177f2f888d5d120f99a909c11cf1268ccdf2 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:04:49 +0100 Subject: [PATCH 09/13] fix: show elevation layer when no farm is selected --- ...m.$b_id_farm.$calendar.atlas.elevation.tsx | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 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 a18a51f79..daf01d80e 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 @@ -1,6 +1,5 @@ import { cogProtocol, - getCogMetadata, locationValues, proj4, } from "@geomatico/maplibre-cog-protocol" @@ -26,7 +25,6 @@ import { type MapLayerMouseEvent, } from "react-map-gl/maplibre" import { - data, type LoaderFunctionArgs, type MetaFunction, useLoaderData, @@ -37,7 +35,6 @@ import { FieldsPanelHover } from "~/components/blocks/atlas/atlas-panels" import { getFieldsStyle } from "~/components/blocks/atlas/atlas-styles" import { ZOOM_LEVEL_FIELDS } from "~/components/blocks/atlas/atlas" import { getViewState } from "~/components/blocks/atlas/atlas-viewstate" -import { LoadingSpinner } from "~/components/custom/loadingspinner" import { getMapStyle } from "~/integrations/map" import { getSession } from "~/lib/auth.server" import { getCalendar, getTimeframe } from "~/lib/calendar" @@ -109,34 +106,39 @@ export const meta: MetaFunction = () => { export async function loader({ request, params }: LoaderFunctionArgs) { try { const b_id_farm = params.b_id_farm - if (!b_id_farm) { - throw data("Farm ID is required", { - status: 400, - statusText: "Farm ID is required", - }) - } const session = await getSession(request) const calendar = getCalendar(params) const timeframe = getTimeframe(params) - const fields = await getFields( - fdm, - session.principal_id, - b_id_farm, - timeframe, - ) - const featureCollection: FeatureCollection = { - type: "FeatureCollection", - features: fields.map((field) => ({ - type: "Feature", - properties: { - b_id: field.b_id, - b_name: field.b_name, - b_area: Math.round(field.b_area * 10) / 10, - }, - geometry: field.b_geometry, - })), + // Get the fields of the farm + let featureCollection: FeatureCollection | undefined + if (b_id_farm && b_id_farm !== "undefined") { + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + const features = fields.map((field) => { + const feature = { + type: "Feature" as const, + properties: { + b_id: field.b_id, + b_name: field.b_name, + b_area: Math.round(field.b_area * 10) / 10, + b_lu_name: field.b_lu_name, + b_id_source: field.b_id_source, + }, + geometry: field.b_geometry, + } + return feature + }) + + featureCollection = { + type: "FeatureCollection", + features: features, + } } const mapStyle = getMapStyle("satellite") @@ -503,7 +505,7 @@ export default function FarmAtlasElevationBlock() { if ( values && values.length > 0 && - !isNaN(values[0]) + !Number.isNaN(values[0]) ) { setHoverElevation(values[0]) return @@ -562,7 +564,7 @@ export default function FarmAtlasElevationBlock() { id="ahn-wms-layer" type="raster" paint={{ "raster-opacity": 0.8 }} - beforeId="fieldsSavedOutline" + beforeId={fields ? "fieldsSavedOutline" : undefined} /> )} @@ -586,7 +588,11 @@ export default function FarmAtlasElevationBlock() { id={`ahn-layer-${tile.id}`} type="raster" paint={{ "raster-opacity": 1 }} - beforeId="fieldsSavedOutline" + beforeId={ + fields + ? "fieldsSavedOutline" + : undefined + } /> From da6a314b9e308ca4f651e3419267aa6b2eeb8a79 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:20:55 +0100 Subject: [PATCH 10/13] fix: broken lockfile --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84c1bff26..617eea9cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,7 +81,7 @@ importers: version: 1.4.1 '@geomatico/maplibre-cog-protocol': specifier: github:SvenVw/maplibre-cog-protocol#add-prepare - version: https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.13.0) + version: https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.14.0) '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.68.0(react@19.2.1)) @@ -11414,7 +11414,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)': + '@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 @@ -11425,9 +11425,9 @@ snapshots: nanostores: 1.1.0 zod: 4.1.13 - '@better-auth/telemetry@1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) + '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 @@ -12914,12 +12914,12 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@geomatico/maplibre-cog-protocol@https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.13.0)': + '@geomatico/maplibre-cog-protocol@https://codeload.github.com/SvenVw/maplibre-cog-protocol/tar.gz/fdb1f2505071f6bd3dc94fe07024d3ddfe826904(maplibre-gl@5.14.0)': dependencies: '@mapbox/sphericalmercator': 1.2.0 d3-scale: 4.0.2 geotiff: 2.1.3 - maplibre-gl: 5.13.0 + maplibre-gl: 5.14.0 proj4: 2.20.2 quick-lru: 7.3.0 @@ -17031,8 +17031,8 @@ snapshots: better-auth@1.4.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) + '@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 '@noble/ciphers': 2.0.1 From 3093c59ddf21e26467f13a1ea03bae5ba3a55ca0 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:55:37 +0100 Subject: [PATCH 11/13] refactor: use Atlas for page title --- .../routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx | 6 +++--- .../farm.$b_id_farm.$calendar.atlas.fields._index.tsx | 2 +- fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.tsx | 2 +- 3 files changed, 5 insertions(+), 5 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 daf01d80e..534464a05 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 @@ -68,8 +68,8 @@ function isPointInPolygon(point: [number, number], vs: [number, number][]) { return inside } -// Helper: Check if polygon intersects polygon (simple AABB check for index speed, then detail?) -// For now, we just check if any point of tile is in view or view in tile? +// Helper: Check if polygon intersects polygon (simple AABB check for index speed) +// For now, we just check if any point of tile is in view or view in tile // Simpler: Convert Viewport to RD Polygon, check intersection with Tile Polygon (also RD). function polygonIntersectsPolygon( poly1: [number, number][], @@ -92,7 +92,7 @@ interface ActiveTile { // Meta export const meta: MetaFunction = () => { return [ - { title: `Hoogte - Kaart | ${clientConfig.name}` }, + { title: `Hoogte - Atlas | ${clientConfig.name}` }, { name: "description", content: "Bekijk hoogtegegevens op de kaart.", 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 a4c66dc48..8ec49002f 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 @@ -28,7 +28,7 @@ import { fdm } from "~/lib/fdm.server" export const meta: MetaFunction = () => { return [ - { title: `Percelen - Kaart | ${clientConfig.name}` }, + { title: `Percelen - Atlas | ${clientConfig.name}` }, { name: "description", content: diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.tsx index 752724012..7ca36b82d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.tsx @@ -21,7 +21,7 @@ import { fdm } from "~/lib/fdm.server" // Meta export const meta: MetaFunction = () => { return [ - { title: `Kaarten | ${clientConfig.name}` }, + { title: `Atlas | ${clientConfig.name}` }, { name: "description", content: "Bekijk informatie op de kaart.", From ba8ef5ff2e9d3a3d1a5584a73c3b7b047faf25f8 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:12:07 +0100 Subject: [PATCH 12/13] refactor: improve transition between WMS and COG for elevation map --- fdm-app/app/components/blocks/atlas/atlas-legend.tsx | 9 ++++++++- .../farm.$b_id_farm.$calendar.atlas.elevation.tsx | 10 +++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx index 6aa9f17ff..d67af11b7 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -8,9 +8,10 @@ interface ElevationLegendProps { hoverValue?: number | null showScale?: boolean networkStatus?: "idle" | "loading" | "slow" | "error" + message?: string } -export function ElevationLegend({ min, max, loading, hoverValue, showScale = true, networkStatus }: ElevationLegendProps) { +export function ElevationLegend({ min, max, loading, hoverValue, showScale = true, networkStatus, message }: ElevationLegendProps) { return (
@@ -33,6 +34,12 @@ export function ElevationLegend({ min, max, loading, hoverValue, showScale = tru Fout bij laden
)} + + {message && ( +
+ {message} +
+ )} {showScale && (
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 534464a05..8dd43d602 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 @@ -550,7 +550,7 @@ export default function FarmAtlasElevationBlock() { /> {/* WMS Overview Layer (Zoom < 13) */} - {viewState.zoom < 13 && showElevation && ( + {showElevation && ( = 13) */} - {viewState.zoom >= 13 && - showElevation && + {showElevation && activeTiles.map((tile) => ( = 13 && showElevation} networkStatus={networkStatus} + message={ + showElevation && viewState.zoom < 13 + ? "Zoom in voor meer detail" + : undefined + } />
Date: Wed, 17 Dec 2025 10:29:49 +0100 Subject: [PATCH 13/13] refactor: Add user-facing feedback for index fetch failures --- .../app/routes/farm.$b_id_farm.$calendar.atlas.elevation.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 8dd43d602..0aed0eddb 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 @@ -216,6 +216,7 @@ export default function FarmAtlasElevationBlock() { useEffect(() => { async function fetchIndex() { const cacheKey = "ahn_kaartbladindex_v1" + setNetworkStatus("loading") try { // Try cache if (typeof localStorage !== "undefined") { @@ -229,6 +230,7 @@ export default function FarmAtlasElevationBlock() { 7 * 24 * 60 * 60 * 1000 ) { setIndexData(data) + setNetworkStatus("idle") return } } catch { @@ -243,6 +245,7 @@ export default function FarmAtlasElevationBlock() { if (!response.ok) throw new Error("Failed to fetch COG index") const data = (await response.json()) as FeatureCollection setIndexData(data) + setNetworkStatus("idle") if (typeof localStorage !== "undefined") { try { @@ -256,6 +259,7 @@ export default function FarmAtlasElevationBlock() { } } catch (e) { console.error("Error fetching COG index:", e) + setNetworkStatus("error") } } fetchIndex()