From 556668727b17377ad77ddc090c50fdccdfe67c65 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:45:39 +0100 Subject: [PATCH 1/5] feat: Integrated the BRO "Bodemkaart" (Soil Map) into the Atlas --- .changeset/atlas-soil-map-integration.md | 5 + .../blocks/atlas/atlas-controls.tsx | 42 +- .../app/components/blocks/header/atlas.tsx | 14 +- .../app/components/blocks/sidebar/apps.tsx | 18 + .../farm.$b_id_farm.$calendar.atlas.soil.tsx | 403 ++++++++++++++---- fdm-app/app/tailwind.css | 19 + 6 files changed, 425 insertions(+), 76 deletions(-) create mode 100644 .changeset/atlas-soil-map-integration.md diff --git a/.changeset/atlas-soil-map-integration.md b/.changeset/atlas-soil-map-integration.md new file mode 100644 index 000000000..071769eb8 --- /dev/null +++ b/.changeset/atlas-soil-map-integration.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Integrated the BRO "Bodemkaart" (Soil Map) into the Atlas \ No newline at end of file diff --git a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx index a1ff1e298..a451fffa7 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, Mountain } from "lucide-react" +import { Earth, 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" @@ -21,6 +21,8 @@ type ControlsProps = { onToggleFields?: () => void showElevation?: boolean onToggleElevation?: () => void + showSoil?: boolean + onToggleSoil?: () => void } export function Controls(props: ControlsProps) { @@ -44,6 +46,12 @@ export function Controls(props: ControlsProps) { onToggle={props.onToggleElevation} /> )} + {props.showSoil !== undefined && props.onToggleSoil && ( + + )} void +}) { + const control = useControl( + () => + new CustomControl({ + active: showSoil, + onToggle, + labelActive: "Verberg bodemkaart", + labelInactive: "Toon bodemkaart", + Icon: Earth, + }), + CONTROL_OPTIONS, + ) + + useEffect(() => { + control.updateProps({ + active: showSoil, + onToggle, + labelActive: "Verberg bodemkaart", + labelInactive: "Toon bodemkaart", + Icon: Earth, + }) + }, [control, showSoil, onToggle]) + + return null +} diff --git a/fdm-app/app/components/blocks/header/atlas.tsx b/fdm-app/app/components/blocks/header/atlas.tsx index ab80c5782..bdfb971c0 100644 --- a/fdm-app/app/components/blocks/header/atlas.tsx +++ b/fdm-app/app/components/blocks/header/atlas.tsx @@ -18,7 +18,12 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) { const location = useLocation() const isElevation = location.pathname.includes("/elevation") - const currentName = isElevation ? "Hoogtekaart" : "Gewaspercelen" + const isSoil = location.pathname.includes("/soil") + const currentName = isElevation + ? "Hoogtekaart" + : isSoil + ? "Bodemkaart" + : "Gewaspercelen" return ( <> @@ -50,6 +55,13 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) { Hoogtekaart + + + Bodemkaart + + diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 580640c47..0158b6e24 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -43,18 +43,22 @@ export function SidebarApps() { let atlasLink: string | undefined let atlasFieldsLink: string | undefined let atlasElevationLink: string | undefined + let atlasSoilLink: string | undefined if (isCreateFarmWizard) { atlasLink = undefined atlasFieldsLink = undefined atlasElevationLink = undefined + atlasSoilLink = undefined } else if (farmId) { atlasLink = `/farm/${farmId}/${selectedCalendar}/atlas` atlasFieldsLink = `/farm/${farmId}/${selectedCalendar}/atlas/fields` atlasElevationLink = `/farm/${farmId}/${selectedCalendar}/atlas/elevation` + atlasSoilLink = `/farm/${farmId}/${selectedCalendar}/atlas/soil` } else { atlasLink = `/farm/undefined/${selectedCalendar}/atlas` atlasFieldsLink = `/farm/undefined/${selectedCalendar}/atlas/fields` atlasElevationLink = `/farm/undefined/${selectedCalendar}/atlas/elevation` + atlasSoilLink = `/farm/undefined/${selectedCalendar}/atlas/soil` } let nitrogenBalanceLink: string | undefined @@ -158,6 +162,20 @@ export function SidebarApps() { ) : null} + + {atlasSoilLink ? ( + + + Bodemkaart + + + ) : null} + 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 de49aaec2..a7592356b 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,117 +1,372 @@ import { getFields } from "@svenvw/fdm-core" import { simplify } from "@turf/turf" -import { Geometry } from "geojson" +import type { Feature, FeatureCollection, Geometry } from "geojson" +import maplibregl from "maplibre-gl" +import proj4 from "proj4" +import { useCallback, useRef, useState } from "react" +import { + Layer, + Map as MapGL, + type MapLayerMouseEvent, + type MapRef, + Popup, + Source, + 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 { Badge } from "~/components/ui/badge" +import { ZOOM_LEVEL_FIELDS } from "~/components/blocks/atlas/atlas" +import { MapTilerAttribution } from "~/components/blocks/atlas/atlas-attribution" +import { Controls } from "~/components/blocks/atlas/atlas-controls" +import { FieldsPanelHover } from "~/components/blocks/atlas/atlas-panels" +import { getFieldsStyle } from "~/components/blocks/atlas/atlas-styles" +import { getViewState } from "~/components/blocks/atlas/atlas-viewstate" +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" -// Meta +// Ensure EPSG:3857 is available +if (!proj4.defs("EPSG:3857")) { + proj4.defs( + "EPSG:3857", + "+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs", + ) +} + +// Helper to recursively reproject coordinates +const transformCoords = (coords: any[]): any[] => { + if (typeof coords[0] === "number") { + return proj4("EPSG:3857", "EPSG:4326", coords as [number, number]) + } + return coords.map(transformCoords) +} + export const meta: MetaFunction = () => { return [ - { title: `Bodem - Kaart | ${clientConfig.name}` }, + { title: `Bodemkaart - Atlas | ${clientConfig.name}` }, { name: "description", - content: "Bekijk bodemgegevens op de kaart.", + content: "Bekijk de bodemkaart.", }, ] } -/** - * Loads farm fields as a GeoJSON FeatureCollection. - * - * This function retrieves the farm ID from the route parameters and ensures it is present. It then accesses the - * current session to fetch the fields associated with the farm for the specified timeframe. Each field is - * transformed into a GeoJSON feature, with properties including its ID, name, and area rounded to the nearest - * decimal place, and assembles these features into a FeatureCollection. The returned object provides the - * necessary data for rendering the farm's soil atlas. - * - * @param {LoaderFunctionArgs} args - The arguments containing the request and route parameters. - * @returns A promise that resolves to an object containing a 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", { - status: 400, - statusText: "Farm ID is required", - }) - } - // 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 = { - type: "Feature", - properties: { - b_id: field.b_id, - b_name: field.b_name, - b_area: Math.round(field.b_area * 10) / 10, - }, - geometry: simplify(field.b_geometry as Geometry, { - tolerance: 0.00001, - highQuality: true, - }), - } - return feature - }) + 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 ?? 0) * 10) / 10, + b_lu_name: (field as any).b_lu_name, + b_id_source: field.b_id_source, + }, + geometry: simplify(field.b_geometry as Geometry, { + tolerance: 0.00001, + highQuality: true, + }), + } + return feature + }) - const featureCollection = { - type: "FeatureCollection", - features: features, + 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 message indicating that the soil map is not available. - * - * This component displays a centered layout with an informative message and a navigation button - * linking to the field map. - */ export default function FarmAtlasSoilBlock() { + const loaderData = useLoaderData() + const fields = loaderData.fields + const mapStyle = loaderData.mapStyle + + const mapRef = useRef(null) + + // State + const [selectedSoilFeature, setSelectedSoilFeature] = + useState(null) + const [popupInfo, setPopupInfo] = useState<{ + longitude: number + latitude: number + properties: Record + } | null>(null) + const [showFields, setShowFields] = useState(true) + const [showSoil, setShowSoil] = useState(true) + + const fieldsSavedId = "fieldsSaved" + const fieldsSavedStyle = getFieldsStyle(fieldsSavedId) + const fieldsSavedOutlineStyle = getFieldsStyle("fieldsSavedOutline") + const fieldsSelectedStyle = getFieldsStyle("fieldsSelected") + const layerLayout = { visibility: showFields ? "visible" : "none" } as const + + // ViewState logic + const initialViewState = getViewState(fields) + const [viewState, setViewState] = useState(() => { + if (typeof window !== "undefined") { + const savedViewState = sessionStorage.getItem("mapViewState") + if (savedViewState) { + try { + return JSON.parse(savedViewState) + } catch { + sessionStorage.removeItem("mapViewState") + } + } + } + return initialViewState as ViewState + }) + + const onViewportChange = useCallback((event: ViewStateChangeEvent) => { + setViewState(event.viewState) + }, []) + + const onMapClick = useCallback( + async (event: MapLayerMouseEvent) => { + if (!showSoil || !mapRef.current) return + + // Clear previous popup/selection + setPopupInfo(null) + setSelectedSoilFeature(null) + + const map = mapRef.current.getMap() + const { point, lngLat } = event + + // Construct GetFeatureInfo request + const bounds = map.getBounds() + const width = map.getCanvas().width + const height = map.getCanvas().height + + // Use proj4 to get bounds in EPSG:3857 + const sw = proj4("EPSG:3857").forward([ + bounds.getWest(), + bounds.getSouth(), + ]) + const ne = proj4("EPSG:3857").forward([ + bounds.getEast(), + bounds.getNorth(), + ]) + const bbox = `${sw[0]},${sw[1]},${ne[0]},${ne[1]}` + + const params = new URLSearchParams({ + service: "WMS", + version: "1.3.0", + request: "GetFeatureInfo", + layers: "soilarea", + query_layers: "soilarea", + info_format: "application/json", + crs: "EPSG:3857", + bbox: bbox, + width: width.toString(), + height: height.toString(), + i: Math.round(point.x).toString(), + j: Math.round(point.y).toString(), + }) + + const url = `https://service.pdok.nl/bzk/bro-bodemkaart/wms/v1_0?${params.toString()}` + + try { + const response = await fetch(url) + if (response.ok) { + const data = (await response.json()) as FeatureCollection + if (data.features && data.features.length > 0) { + const feature = data.features[0] + const props = feature.properties || {} + + // Reproject geometry from EPSG:3857 to EPSG:4326 + if ( + feature.geometry && + (feature.geometry as any).coordinates + ) { + ;(feature.geometry as any).coordinates = + transformCoords( + (feature.geometry as any).coordinates, + ) + } + + setSelectedSoilFeature({ + type: "FeatureCollection", + features: [feature], + }) + setPopupInfo({ + longitude: lngLat.lng, + latitude: lngLat.lat, + properties: props, + }) + } + } + } catch (e) { + console.error("Failed to fetch soil info", e) + } + }, + [showSoil], + ) + + const onToggleSoil = useCallback(() => { + setShowSoil((prev) => { + if (prev) { + // Clearing selection when switching off + setSelectedSoilFeature(null) + setPopupInfo(null) + } + return !prev + }) + }, []) + return ( -
-
-

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

-

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

-
- +
+ + + setViewState((currentViewState) => ({ + ...currentViewState, + longitude, + latitude, + zoom, + })) + } + showFields={showFields} + onToggleFields={() => setShowFields(!showFields)} + showSoil={showSoil} + onToggleSoil={onToggleSoil} + /> + + + + {/* Soil WMS Layer */} + {showSoil && ( + + + + )} + + {/* Selected Soil Feature Highlight */} + {showSoil && selectedSoilFeature && ( + + + + )} + + {/* Fields Overlay */} + {fields && ( + + + + + )} + + {/* Popup */} + {showSoil && popupInfo && ( + setPopupInfo(null)} + anchor="bottom" + maxWidth="350px" + > +
+
+

+ {popupInfo.properties.first_soilname || + popupInfo.properties + .normal_soilprofile_name || + "Onbekende bodem"} +

+ {popupInfo.properties.soilcode && ( + + {popupInfo.properties.soilcode} + + )} +
+
+
+ )} + +
+
+ +
+
+
) } diff --git a/fdm-app/app/tailwind.css b/fdm-app/app/tailwind.css index c9b8a0c16..46a084c42 100644 --- a/fdm-app/app/tailwind.css +++ b/fdm-app/app/tailwind.css @@ -190,3 +190,22 @@ display: none; } +/* MapLibre Popup Close Button Fix */ +.maplibregl-popup-close-button { + font-size: 24px; + padding: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-foreground); + border-radius: 0 0.5rem 0 0.5rem; +} +.maplibregl-popup-close-button:hover { + background-color: var(--color-muted); +} +.maplibregl-popup-content { + border-radius: 0.5rem; + padding: 0; +} \ No newline at end of file From e4d7d8c33e5602f4d26c2a2718b280b67aa54212 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:49:14 +0100 Subject: [PATCH 2/5] refactor: use better layer icons --- fdm-app/app/components/blocks/atlas/atlas-controls.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/atlas/atlas-controls.tsx b/fdm-app/app/components/blocks/atlas/atlas-controls.tsx index a451fffa7..2eb887dbc 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 { Earth, Layers, Mountain } from "lucide-react" +import { Layers, Mountain, PanelsRightBottom } from "lucide-react" import type { ControlPosition, Map as MapLibreMap } from "maplibre-gl" import { useEffect } from "react" import { createRoot, type Root } from "react-dom/client" @@ -160,7 +160,7 @@ function FieldsControl({ onToggle, labelActive: "Verberg percelen", labelInactive: "Toon percelen", - Icon: Layers, + Icon: PanelsRightBottom, }), CONTROL_OPTIONS, ) @@ -171,7 +171,7 @@ function FieldsControl({ onToggle, labelActive: "Verberg percelen", labelInactive: "Toon percelen", - Icon: Layers, + Icon: PanelsRightBottom, }) }, [control, showFields, onToggle]) @@ -224,7 +224,7 @@ function SoilControl({ onToggle, labelActive: "Verberg bodemkaart", labelInactive: "Toon bodemkaart", - Icon: Earth, + Icon: Layers, }), CONTROL_OPTIONS, ) @@ -235,7 +235,7 @@ function SoilControl({ onToggle, labelActive: "Verberg bodemkaart", labelInactive: "Toon bodemkaart", - Icon: Earth, + Icon: Layers, }) }, [control, showSoil, onToggle]) From 386a29eeba5683d8e3d5ece2dc1ab80cb2d7c400 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:10:07 +0100 Subject: [PATCH 3/5] refactor: improvements --- .../farm.$b_id_farm.$calendar.atlas.soil.tsx | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) 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 a7592356b..ebd39c9b9 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 @@ -194,8 +194,8 @@ export default function FarmAtlasSoilBlock() { bbox: bbox, width: width.toString(), height: height.toString(), - i: Math.round(point.x).toString(), - j: Math.round(point.y).toString(), + I: Math.round(point.x).toString(), + J: Math.round(point.y).toString(), }) const url = `https://service.pdok.nl/bzk/bro-bodemkaart/wms/v1_0?${params.toString()}` @@ -248,6 +248,26 @@ export default function FarmAtlasSoilBlock() { }) }, []) + const onToggleFields = useCallback(() => { + setShowFields((prev) => !prev) + }, []) + + const onControlsViewportChange = useCallback( + ({ + longitude, + latitude, + zoom, + }: { longitude: number; latitude: number; zoom: number }) => { + setViewState((current) => ({ + ...current, + longitude, + latitude, + zoom, + })) + }, + [], + ) + return (
- setViewState((currentViewState) => ({ - ...currentViewState, - longitude, - latitude, - zoom, - })) - } + onViewportChange={onControlsViewportChange} showFields={showFields} - onToggleFields={() => setShowFields(!showFields)} + onToggleFields={onToggleFields} showSoil={showSoil} onToggleSoil={onToggleSoil} /> From 48879b29fbf7eb8c3a79432004e8cb0506b6d5af Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:07:00 +0100 Subject: [PATCH 4/5] refactor: guard sessionStorage acces --- ...farm.$b_id_farm.$calendar.atlas.elevation.tsx | 15 +++++++-------- ....$b_id_farm.$calendar.atlas.fields._index.tsx | 13 ++++++------- .../farm.$b_id_farm.$calendar.atlas.soil.tsx | 16 ++++++++++------ 3 files changed, 23 insertions(+), 21 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 7c8939489..26ec9bff1 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 @@ -190,18 +190,18 @@ export default function FarmAtlasElevationBlock() { const initialViewState = getViewState(fields) const [viewState, setViewState] = useState(() => { if (typeof window !== "undefined") { - const savedViewState = sessionStorage.getItem("mapViewState") - if (savedViewState) { - try { + try { + const savedViewState = sessionStorage.getItem("mapViewState") + if (savedViewState) { return JSON.parse(savedViewState) - } catch { - sessionStorage.removeItem("mapViewState") } + } catch { + // ignore storage errors (e.g., private mode) } } return initialViewState as ViewState }) - + const onViewportChange = useCallback((event: ViewStateChangeEvent) => { setViewState(event.viewState) }, []) @@ -231,8 +231,7 @@ export default function FarmAtlasElevationBlock() { // Cache for 24 hours and ensure data is valid if ( data?.features?.length > 0 && - Date.now() - timestamp < - 24 * 60 * 60 * 1000 + Date.now() - timestamp < 24 * 60 * 60 * 1000 ) { setIndexData(data) setNetworkStatus("idle") 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 101d46098..85bfc1db0 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 @@ -127,18 +127,17 @@ export default function FarmAtlasFieldsBlock() { const fieldsAvailableId = "fieldsAvailable" const fieldsAvailableStyle = getFieldsStyle(fieldsAvailableId) const fieldsSavedOutlineStyle = getFieldsStyle("fieldsSavedOutline") + // ViewState logic const initialViewState = getViewState(fields) - - // Create a sessionStorage to store the latest viewstate const [viewState, setViewState] = useState(() => { if (typeof window !== "undefined") { - const savedViewState = sessionStorage.getItem("mapViewState") - if (savedViewState) { - try { + try { + const savedViewState = sessionStorage.getItem("mapViewState") + if (savedViewState) { return JSON.parse(savedViewState) - } catch { - sessionStorage.removeItem("mapViewState") } + } catch { + // ignore storage errors (e.g., private mode) } } return initialViewState as ViewState 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 ebd39c9b9..874b36b79 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 @@ -140,13 +140,13 @@ export default function FarmAtlasSoilBlock() { const initialViewState = getViewState(fields) const [viewState, setViewState] = useState(() => { if (typeof window !== "undefined") { - const savedViewState = sessionStorage.getItem("mapViewState") - if (savedViewState) { - try { + try { + const savedViewState = sessionStorage.getItem("mapViewState") + if (savedViewState) { return JSON.parse(savedViewState) - } catch { - sessionStorage.removeItem("mapViewState") } + } catch { + // ignore storage errors (e.g., private mode) } } return initialViewState as ViewState @@ -257,7 +257,11 @@ export default function FarmAtlasSoilBlock() { longitude, latitude, zoom, - }: { longitude: number; latitude: number; zoom: number }) => { + }: { + longitude: number + latitude: number + zoom: number + }) => { setViewState((current) => ({ ...current, longitude, From 52dd9049367ad3225b712a41af99d2aa7ff4f843 Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:49:01 +0100 Subject: [PATCH 5/5] fix: persist viewstate --- .../farm.$b_id_farm.$calendar.atlas.elevation.tsx | 12 ++++++------ ...arm.$b_id_farm.$calendar.atlas.fields._index.tsx | 13 ++++++------- .../routes/farm.$b_id_farm.$calendar.atlas.soil.tsx | 12 +++++++++++- 3 files changed, 23 insertions(+), 14 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 26ec9bff1..3b54e0791 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 @@ -206,14 +206,14 @@ export default function FarmAtlasElevationBlock() { setViewState(event.viewState) }, []) - // Save viewState - const isFirstRender = useRef(true) useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return + if (typeof window !== "undefined") { + try { + sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) + } catch { + // ignore storage errors (e.g., private mode) + } } - sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) }, [viewState]) // Fetch COG Index once 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 85bfc1db0..65f90d3fa 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 @@ -149,15 +149,14 @@ export default function FarmAtlasFieldsBlock() { setViewState(event.viewState) }, []) - const isFirstRender = useRef(true) - - // If latest viewstate is available use that one useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return + if (typeof window !== "undefined") { + try { + sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) + } catch { + // ignore storage errors (e.g., private mode) + } } - sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) }, [viewState]) const layerLayout = { visibility: showFields ? "visible" : "none" } as const 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 874b36b79..96caf590b 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 @@ -3,7 +3,7 @@ import { simplify } from "@turf/turf" import type { Feature, FeatureCollection, Geometry } from "geojson" import maplibregl from "maplibre-gl" import proj4 from "proj4" -import { useCallback, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { Layer, Map as MapGL, @@ -152,6 +152,16 @@ export default function FarmAtlasSoilBlock() { return initialViewState as ViewState }) + useEffect(() => { + if (typeof window !== "undefined") { + try { + sessionStorage.setItem("mapViewState", JSON.stringify(viewState)) + } catch { + // ignore storage errors (e.g., private mode) + } + } + }, [viewState]) + const onViewportChange = useCallback((event: ViewStateChangeEvent) => { setViewState(event.viewState) }, [])