From f269f44a3f6c556e91f49bbd407e2d903e8ecb04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Tue, 5 May 2026 00:08:44 +0200
Subject: [PATCH 01/27] Add working color ramp and legend for the soil analysis
atlas
---
.../blocks/atlas/atlas-soil-analysis.tsx | 458 ++++++++++++++++++
.../components/blocks/atlas/atlas-sources.tsx | 58 ++-
.../components/blocks/atlas/atlas-styles.tsx | 20 +-
..._id_farm.$calendar.atlas.soil-analysis.tsx | 378 +++++++++++++++
4 files changed, 903 insertions(+), 11 deletions(-)
create mode 100644 fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
new file mode 100644
index 000000000..450a2ce54
--- /dev/null
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
@@ -0,0 +1,458 @@
+import type { SoilParameterDescription } from "@nmi-agro/fdm-core"
+import type { ExpressionSpecification } from "maplibre-gl"
+import { useId } from "react"
+import type { LayerProps } from "react-map-gl"
+import {
+ Bar,
+ BarChart,
+ type BarShapeProps,
+ Rectangle,
+ XAxis,
+ YAxis,
+} from "recharts"
+import { ChartContainer } from "~/components/ui/chart"
+
+/* ================ SHADING DEFINITIONS ================ */
+
+function evenlySpaced(...args: string[]) {
+ return args.flatMap((item, i) => [i / (args.length - 1), item])
+}
+
+const COLORBREWER_REDS = evenlySpaced(
+ "#fff5f0",
+ "#fee0d2",
+ "#fcbba1",
+ "#fc9272",
+ "#fb6a4a",
+ "#ef3b2c",
+ "#cb181d",
+ "#99000d",
+)
+const COLORBREWER_ORANGES = evenlySpaced(
+ "#fff5eb",
+ "#fee6ce",
+ "#fdd0a2",
+ "#fdae6b",
+ "#fd8d3c",
+ "#f16913",
+ "#d94801",
+ "#a63603",
+ "#7f2704",
+)
+const COLORBREWER_GREENS = evenlySpaced(
+ "#f7fcf5",
+ "#e5f5e0",
+ "#c7e9c0",
+ "#a1d99b",
+ "#74c476",
+ "#41ab5d",
+ "#238b45",
+ "#005a32",
+)
+const COLORBREWER_BLUES = evenlySpaced(
+ "#f7fbff",
+ "#deebf7",
+ "#c6dbef",
+ "#9ecae1",
+ "#6baed6",
+ "#4292c6",
+ "#2171b5",
+ "#08519c",
+ "#08306b",
+)
+const COLORBREWER_GREYS = evenlySpaced(
+ "#f7f7f7",
+ "#cccccc",
+ "#969696",
+ "#636363",
+ "#252525",
+)
+const COLORBREWER_YLORBR = evenlySpaced(
+ "#ffffe5",
+ "#fff7bc",
+ "#fee391",
+ "#fec44f",
+ "#fe9929",
+ "#ec7014",
+ "#cc4c02",
+ "#8c2d04",
+)
+const COLORBREWER_BUGN = evenlySpaced(
+ "#f7fcfd",
+ "#e5f5f9",
+ "#ccece6",
+ "#99d8c9",
+ "#66c2a4",
+ "#41ae76",
+ "#238b45",
+ "#006d2c",
+ "#00441b",
+)
+
+const COLORBREWER_GNBU = evenlySpaced(
+ "#f7fcf0",
+ "#e0f3db",
+ "#ccebc5",
+ "#a8ddb5",
+ "#7bccc4",
+ "#4eb3d3",
+ "#2b8cbe",
+ "#08589e",
+)
+const COLORBREWER_RDBU = evenlySpaced(
+ "#b2182b",
+ "#d6604d",
+ "#f4a582",
+ "#fddbc7",
+ "#d1e5f0",
+ "#92c5de",
+ "#4393c3",
+ "#2166ac",
+)
+const COLORBREWER_RDPU = evenlySpaced(
+ "#fff7f3",
+ "#fde0dd",
+ "#fcc5c0",
+ "#fa9fb5",
+ "#f768a1",
+ "#dd3497",
+ "#ae017e",
+ "#7a0177",
+)
+const CUSTOM_SILVER = evenlySpaced("#f7f7f7", "#cccccc", "#969696", "#636363")
+
+const SHADED_SOIL_TYPES = [
+ { value: "moerige_klei", label: "Moerige klei", fill: "#d9d9d9" },
+ { value: "rivierklei", label: "Rivierklei", fill: "#8dd3c7" },
+ { value: "dekzand", label: "Dekzand", fill: "#bebada" },
+ { value: "zeeklei", label: "Zeeklei", fill: "#fb8072" },
+ { value: "dalgrond", label: "Dalgrond", fill: "#fccde5" },
+ { value: "veen", label: "Veen", fill: "#b3de69" },
+ { value: "loess", label: "Löss", fill: "#fdb462" },
+ { value: "duinzand", label: "Duinzand", fill: "#ffffb3" },
+ { value: "maasklei", label: "Maasklei", fill: "#80b1d3" },
+]
+
+/** Which gradient definition to use for gradient-shaded parameters.
+ * Add items here to let the user select other parameters.
+ */
+const GRADIENT_SHADED_SOIL_PARAMETERS = {
+ a_al_ox: "aluminum",
+ a_c_of: "carbon",
+ a_ca_co: "calcium",
+ a_ca_co_po: "calcium",
+ a_cao3_if: "calcium",
+ a_cec_co: "earth_light",
+ a_clay_mi: "earth_heavy",
+ a_cn_fr: "carbon_ratio",
+ a_com_fr: "carbon_ratio",
+ a_cu_cc: "copper",
+ a_density_sa: "earth_heavy",
+ a_fe_ox: "iron",
+ a_k_cc: "potassium",
+ a_k_co: "potassium",
+ a_k_co_po: "potassium",
+ a_mg_cc: "magnesium",
+ a_mg_co: "magnesium",
+ a_mg_co_po: "magnesium",
+ a_n_pmn: "bacterium",
+ a_n_rt: "nitrogen",
+ a_nh4_cc: "nitrogen",
+ a_nmin_cc: "nitrogen",
+ a_no3_cc: "nitrogen",
+ a_p_al: "phosphorus",
+ a_p_cc: "phosphorus",
+ a_p_ox: "phosphorus",
+ a_p_rt: "phosphorus",
+ a_p_sg: "phosphorus",
+ a_p_wa: "phosphorus",
+ a_ph_cc: "ph",
+ a_s_rt: "sulfur",
+ a_sand_mi: "sand_light",
+ a_silt_mi: "sand_dark",
+ a_som_loi: "carbon",
+ a_zn_cc: "zinc",
+} as const
+type GradientShadedSoilParameters = keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
+
+/** Actual gradient definitions */
+const GRADIENT_DEFINITIONS: {
+ [k in (typeof GRADIENT_SHADED_SOIL_PARAMETERS)[GradientShadedSoilParameters]]: {
+ gradient: (string | number)[]
+ center?: number
+ }
+} = {
+ aluminum: { gradient: CUSTOM_SILVER },
+ bacterium: { gradient: COLORBREWER_GNBU },
+ calcium: { gradient: COLORBREWER_BUGN },
+ carbon: { gradient: COLORBREWER_GREYS },
+ carbon_ratio: { gradient: COLORBREWER_GREYS },
+ copper: { gradient: COLORBREWER_REDS },
+ earth_heavy: { gradient: COLORBREWER_YLORBR },
+ earth_light: { gradient: COLORBREWER_ORANGES },
+ nitrogen: { gradient: COLORBREWER_BLUES },
+ iron: { gradient: COLORBREWER_ORANGES },
+ magnesium: { gradient: COLORBREWER_GREENS },
+ phosphorus: { gradient: COLORBREWER_RDPU },
+ potassium: { gradient: COLORBREWER_RDPU },
+ ph: { gradient: COLORBREWER_RDBU, center: 7 },
+ sand_dark: { gradient: COLORBREWER_YLORBR },
+ sand_light: { gradient: COLORBREWER_ORANGES },
+ sulfur: { gradient: COLORBREWER_YLORBR },
+ zinc: { gradient: CUSTOM_SILVER },
+}
+
+const ENUM_SHADED_SOIL_PARAMETERS = {
+ b_soiltype_agr: SHADED_SOIL_TYPES.flatMap(({ value, fill }) => [
+ value,
+ fill,
+ ]).concat(["#777777"]),
+} as const
+type EnumShadedSoilParameters = keyof typeof ENUM_SHADED_SOIL_PARAMETERS
+
+export type ShadedSoilParameters =
+ | GradientShadedSoilParameters
+ | EnumShadedSoilParameters
+
+export function getShadedSoilParameters() {
+ return [
+ ...Object.keys(GRADIENT_SHADED_SOIL_PARAMETERS),
+ ...Object.keys(ENUM_SHADED_SOIL_PARAMETERS),
+ ] as ShadedSoilParameters[]
+}
+
+export function getSoilAnalysisLayerStyle(
+ dataPath: string[],
+ min: number,
+ max: number,
+): { paint: LayerProps["paint"]; type: "fill" } {
+ if (dataPath.length === 0) {
+ throw new Error("dataPath needs to contain at least one item")
+ }
+ const key = dataPath[dataPath.length - 1]
+ // MapLibreGL expression to get the data path out of the input object (which is the feature properties)
+ const dataGetter = getShadingParameterMapper(
+ key as ShadedSoilParameters,
+ ).paint(
+ dataPath.reduce(
+ (acc, current) =>
+ acc !== null ? ["get", current, acc] : ["get", current],
+ null as unknown[] | null,
+ ) as ExpressionSpecification,
+ )
+
+ if (key in ENUM_SHADED_SOIL_PARAMETERS) {
+ const fillColor =
+ ENUM_SHADED_SOIL_PARAMETERS[key as EnumShadedSoilParameters]
+ return {
+ type: "fill",
+ paint: {
+ "fill-color": ["match", dataGetter, ...fillColor],
+ },
+ }
+ }
+
+ if (key in GRADIENT_SHADED_SOIL_PARAMETERS) {
+ const gradientName =
+ GRADIENT_SHADED_SOIL_PARAMETERS[key as GradientShadedSoilParameters]
+ const fillColor = GRADIENT_DEFINITIONS[gradientName]
+ function transparentIfUndefined(
+ expr: ExpressionSpecification,
+ ): ["match", ...unknown[]] {
+ return [
+ "match",
+ ["typeof", dataGetter],
+ "number",
+ expr,
+ "string",
+ expr,
+ "transparent",
+ ]
+ }
+ if (typeof fillColor.center !== "undefined") {
+ // Cover as much range as needed but still keep the center (for example for pH display, where 7 is the center)
+ const radius = Math.max(
+ max - fillColor.center,
+ fillColor.center - min,
+ )
+ const newMin = fillColor.center - radius
+ const newMax = fillColor.center + radius
+ return {
+ type: "fill",
+ paint: {
+ "fill-color": transparentIfUndefined([
+ "interpolate",
+ ["linear"],
+ dataGetter,
+ ...fillColor.gradient.map((item) =>
+ typeof item === "string"
+ ? item
+ : (newMax - newMin) * item + newMin,
+ ),
+ ]),
+ },
+ }
+ }
+
+ return {
+ type: "fill",
+ paint: {
+ "fill-color": transparentIfUndefined([
+ "interpolate",
+ ["linear"],
+ dataGetter,
+ ...fillColor.gradient.map((item) =>
+ typeof item === "string"
+ ? item
+ : (max - min) * item + min,
+ ),
+ ]),
+ },
+ }
+ }
+
+ return {
+ type: "fill",
+ paint: {
+ "fill-color": "#ff00ff",
+ },
+ }
+}
+
+/* ================ SHADING-TO-LEGEND LOGIC ================ */
+
+/** Value mappers that can be used in vanilla JS and MapGL paint expressions. */
+interface ValueMapper {
+ /** Used when finding the min and max */
+ forward(x: number): number
+ /** Used when converting legend color stop position to the tick display value */
+ inverse(x: number): number
+ /** Used to get the interpolation factor during map draw */
+ paint(expr: ExpressionSpecification): ExpressionSpecification
+}
+const SHADING_VALUE_MAPPERS = {
+ potential: {
+ forward(x) {
+ return -Math.log10(x)
+ },
+ inverse(x) {
+ return 10 ** -x
+ },
+ paint(expr) {
+ return ["-", ["log10", expr]]
+ },
+ } as ValueMapper,
+} as const
+
+/** Parameters to use a custom mapping for
+ *
+ * Do NOT use custom mappers for enum etc. parameters. Only use them for gradient-shaded parameters
+ */
+export const SHADING_PARAMETER_MAPPERS: Partial<
+ Record
+> = {
+ // a_cn_fr is a ratio so making the coloring linear like with pH is useful
+ a_cn_fr: SHADING_VALUE_MAPPERS.potential,
+ // a_com_fr is a ratio so making the coloring linear like with pH is useful
+ a_com_fr: SHADING_VALUE_MAPPERS.potential,
+}
+const DEFAULT_PARAMETER_MAPPER: ValueMapper = {
+ forward(x) {
+ return x
+ },
+ inverse(x) {
+ return x
+ },
+ paint(x) {
+ return x
+ },
+}
+/**Gets the forward and inverse mappings if a different mapping than linear is used for chromatic shading
+ *
+ * @param parameter parameter to get the mappings for
+ * @returns object containing mapping functions
+ */
+export function getShadingParameterMapper(parameter: ShadedSoilParameters) {
+ if (!(parameter in GRADIENT_SHADED_SOIL_PARAMETERS)) {
+ console.warn(
+ `Custom value mapper used for non-gradient-shaded parameter: ${parameter}`,
+ )
+ }
+ return SHADING_PARAMETER_MAPPERS[parameter] ?? DEFAULT_PARAMETER_MAPPER
+}
+
+interface SoilAnalysisLegendProps {
+ parameter: ShadedSoilParameters
+ soilParametersDescriptions: SoilParameterDescription
+ min?: number
+ max?: number
+}
+
+export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
+ if (props.parameter in ENUM_SHADED_SOIL_PARAMETERS) {
+ return
+ }
+
+ return
+}
+
+function EnumSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
+ return null
+}
+
+function GradientSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
+ const gradDef =
+ GRADIENT_DEFINITIONS[
+ GRADIENT_SHADED_SOIL_PARAMETERS[
+ props.parameter as GradientShadedSoilParameters
+ ]
+ ]
+
+ let min = props.min as number
+ let max = props.max as number
+ if (typeof gradDef.center === "number") {
+ const radius = Math.max(max - gradDef.center, gradDef.center - min)
+ min = gradDef.center - radius
+ max = gradDef.center + radius
+ }
+
+ const chartData = [{ name: "Legenda", min: min, max: max }]
+ const gradient = gradDef.gradient
+
+ const gradientId = useId()
+
+ const gradientSvg: React.ReactNode[] = []
+ for (let i = 0; i < gradient.length; i += 2) {
+ gradientSvg.push(
+ ,
+ )
+ }
+ return (
+
+
+
+
+ {gradientSvg}
+
+
+
+
+ [entry.min, entry.max]}
+ shape={(props: BarShapeProps) => (
+
+ )}
+ />
+
+
+ )
+}
diff --git a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
index e36922dec..1fdf6efaf 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
@@ -1,9 +1,15 @@
+import type { Field } from "@nmi-agro/fdm-core"
import {
type CatalogueCultivationItem,
getCultivationCatalogue,
} from "@nmi-agro/fdm-data"
import centroid from "@turf/centroid"
-import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson"
+import type {
+ Feature,
+ FeatureCollection,
+ GeoJsonProperties,
+ Geometry,
+} from "geojson"
import throttle from "lodash.throttle"
import type { MapLayerMouseEvent } from "maplibre-gl"
import {
@@ -306,3 +312,53 @@ export function FieldsSourceAvailable({
)
}
+
+export function FieldSourceClickable({
+ id,
+ fieldsData,
+ children,
+ onFieldClick,
+}: {
+ id: string
+ fieldsData: FeatureCollection
+ children: ReactNode
+ onFieldClick: (feature: Feature) => unknown
+}) {
+ const { current: map } = useMap()
+
+ useEffect(() => {
+ function clickOnMap(evt: MapLayerMouseEvent) {
+ if (!map) return
+
+ if (
+ id &&
+ map.queryRenderedFeatures(evt.point, {
+ layers: [id],
+ }).length
+ ) {
+ return
+ }
+
+ const features = map.queryRenderedFeatures(evt.point, {
+ layers: [id],
+ })
+
+ if (features.length > 0 && features[0].layer.id === id) {
+ onFieldClick(features[0] as unknown as Feature)
+ }
+ }
+
+ if (map) {
+ map.on("click", clickOnMap)
+ return () => {
+ map.off("click", clickOnMap)
+ }
+ }
+ }, [map, id, onFieldClick])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
index 7b2d82932..4cd58b7f7 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
@@ -12,16 +12,6 @@ export function getFieldsStyle(layerId: string): LayerProps {
}
function getFieldsStyleInner(layerId: string): LayerProps {
- const baseFieldsFillColorExpr: ExpressionSpecification = [
- "match",
- ["get", "b_lu_croprotation"],
- ...getCultivationTypesHavingColors().flatMap((k) => [
- k,
- getCultivationColor(k),
- ]),
- getCultivationColor("other"),
- ] as any
-
const baseFillStyles = {}
const baseLineStyles = {
@@ -70,6 +60,16 @@ function getFieldsStyleInner(layerId: string): LayerProps {
}
}
+ const baseFieldsFillColorExpr: ExpressionSpecification = [
+ "match",
+ ["get", "b_lu_croprotation"],
+ ...getCultivationTypesHavingColors().flatMap((k) => [
+ k,
+ getCultivationColor(k),
+ ]),
+ getCultivationColor("other"),
+ ] as any
+
// default styles
return {
type: "fill",
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx
new file mode 100644
index 000000000..1926f1348
--- /dev/null
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx
@@ -0,0 +1,378 @@
+import {
+ type CurrentSoilData,
+ getCurrentSoilDataForFarm,
+ getFields,
+ getSoilParametersDescription,
+} from "@nmi-agro/fdm-core"
+import { simplify } from "@turf/simplify"
+import type { FeatureCollection, Geometry } from "geojson"
+import maplibregl from "maplibre-gl"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import {
+ Layer,
+ Map as MapGL,
+ type MapRef,
+ type ViewState,
+ type ViewStateChangeEvent,
+} from "react-map-gl/maplibre"
+import type { MetaFunction } from "react-router"
+import { type LoaderFunctionArgs, useLoaderData } from "react-router"
+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 {
+ getShadedSoilParameters,
+ getShadingParameterMapper,
+ getSoilAnalysisLayerStyle,
+ type ShadedSoilParameters,
+ SoilAnalysisLegend,
+} from "~/components/blocks/atlas/atlas-soil-analysis"
+import { FieldSourceClickable } from "~/components/blocks/atlas/atlas-sources"
+import { getViewState } from "~/components/blocks/atlas/atlas-viewstate"
+import { Card } from "~/components/ui/card"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from "~/components/ui/select"
+import { getMapStyle } from "~/integrations/map"
+import { getSession } from "~/lib/auth.server"
+import { getCalendar, getTimeframe } from "~/lib/calendar"
+import { clientConfig } from "~/lib/config"
+import { handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+
+export const meta: MetaFunction = () => {
+ return [
+ { title: `Percelen - Atlas | ${clientConfig.name}` },
+ {
+ name: "description",
+ content:
+ "Bekijk alle percelen van uw bedrijf op één interactieve kaart. Visualiseer de geografische spreiding en onderlinge relaties tussen uw percelen.",
+ },
+ ]
+}
+
+/**
+ * Loads and processes farm field data along with Maplibre configuration for rendering the farm atlas.
+ *
+ * This loader function extracts the farm ID from the route parameters and validates its presence,
+ * retrieves the current user session, and fetches fields associated with the specified farm.
+ * It converts these fields into a GeoJSON FeatureCollection—rounding the field area values for precision—
+ * and obtains the Maplibre access token and style configuration for map rendering.
+ *
+ * @returns An object containing:
+ * - savedFields: A GeoJSON FeatureCollection of the farm fields.
+ * - MapStyle: The Maplibre style configuration.
+ *
+ * @throws {Response} If the farm ID is missing or if an error occurs during data retrieval and processing.
+ */
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ try {
+ // Get the farm id
+ const b_id_farm = params.b_id_farm
+
+ // 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
+ let fieldsData: FeatureCollection | undefined
+ const currentSoilData: {
+ b_id: string
+ currentSoilData: CurrentSoilData
+ }[] = []
+ if (b_id_farm && b_id_farm !== "undefined") {
+ const fields = await getFields(
+ fdm,
+ session.principal_id,
+ b_id_farm,
+ timeframe,
+ )
+
+ const currentSoilDataForFarm = await getCurrentSoilDataForFarm(
+ fdm,
+ session.principal_id,
+ b_id_farm,
+ timeframe,
+ )
+
+ const features = fields.map((field) => {
+ const fieldCurrentSoilData =
+ currentSoilDataForFarm.get(field.b_id) ?? []
+ currentSoilData.push({
+ b_id: field.b_id,
+ currentSoilData: fieldCurrentSoilData,
+ })
+ 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,
+ // For efficient access
+ currentSoilData: fieldCurrentSoilData.reduce(
+ (acc, data) => {
+ if (data.value !== null)
+ acc[data.parameter] = data.value
+ return acc
+ },
+ {} as Record<
+ CurrentSoilData[number]["parameter"],
+ string | number
+ >,
+ ),
+ },
+ geometry: simplify(field.b_geometry as Geometry, {
+ tolerance: 0.00001,
+ highQuality: true,
+ }),
+ }
+ return feature
+ })
+
+ fieldsData = {
+ type: "FeatureCollection",
+ features: features,
+ }
+ }
+
+ // Get Map Style
+ const mapStyle = getMapStyle("satellite")
+
+ const soilParametersDescriptions = getSoilParametersDescription()
+
+ // Return user information from loader
+ return {
+ calendar: calendar,
+ fieldsData: fieldsData,
+ mapStyle: mapStyle,
+ currentSoilData: currentSoilData,
+ soilParametersDescriptions: soilParametersDescriptions,
+ }
+ } catch (error) {
+ throw handleLoaderError(error)
+ }
+}
+
+/**
+ * Renders a Maplibre map displaying farm fields soil analysis data with interactive controls.
+ *
+ * This component consumes preloaded farm field data to compute the map's view state and stylize the field boundaries.
+ * It integrates geolocation and navigation controls, wraps the field layer in a non-interactive source, and includes a panel for displaying additional field details on hover.
+ */
+export default function FarmAtlasFieldSoilBlock() {
+ const {
+ mapStyle,
+ fieldsData,
+ soilParametersDescriptions,
+ currentSoilData,
+ } = useLoaderData()
+
+ const heatmapLayerId = "fieldsSavedHeatmap"
+ const heatmapStrokeLayerId = "fieldsSavedHeatmapStroke"
+ const [selectedParameter, setSelectedParameter] =
+ useState("a_som_loi")
+ const shadedSoilParameters = new Set(getShadedSoilParameters())
+
+ const [min, max] = useMemo(() => {
+ if (currentSoilData.length === 0) {
+ return [0, 1]
+ }
+ const parameterDescription = soilParametersDescriptions.find(
+ (item) => item.parameter === selectedParameter,
+ )
+ if (parameterDescription?.type !== "numeric") return [0, 1]
+ let min: number | null = null
+ let max: number | null = null
+ for (const field of currentSoilData) {
+ const parameterValue = field.currentSoilData.find(
+ (item) => item.parameter === selectedParameter,
+ )
+ if (parameterValue) {
+ const mappedValue = getShadingParameterMapper(
+ selectedParameter,
+ ).forward(parameterValue.value as number)
+ min = min === null ? mappedValue : Math.min(min, mappedValue)
+ max = max === null ? mappedValue : Math.max(max, mappedValue)
+ }
+ }
+ const defaultedMin = min ?? 0
+ const defaultedMax = max ?? 1
+ return defaultedMin === defaultedMax
+ ? [defaultedMin - 0.01, defaultedMin + 0.01]
+ : [defaultedMin, defaultedMax]
+ }, [selectedParameter, currentSoilData, soilParametersDescriptions])
+
+ const parameterDescription = soilParametersDescriptions.find(
+ (item) => item.parameter === selectedParameter,
+ )
+
+ const heatmapLayerStyle = getSoilAnalysisLayerStyle(
+ ["currentSoilData", selectedParameter],
+ min,
+ max,
+ )
+ // ViewState logic
+ const initialViewState = getViewState(fieldsData)
+ const [viewState, setViewState] = useState(() => {
+ if (typeof window !== "undefined") {
+ try {
+ const savedViewState = sessionStorage.getItem("mapViewState")
+ if (savedViewState) {
+ return JSON.parse(savedViewState)
+ }
+ } catch {
+ // ignore storage errors (e.g., private mode)
+ }
+ }
+ return initialViewState as ViewState
+ })
+
+ const [showFields, setShowFields] = useState(true)
+
+ const onViewportChange = useCallback((event: ViewStateChangeEvent) => {
+ setViewState(event.viewState)
+ }, [])
+
+ const mapRef = useRef(null)
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ try {
+ sessionStorage.setItem(
+ "mapViewState",
+ JSON.stringify(viewState),
+ )
+ } catch {
+ // ignore storage errors (e.g., private mode)
+ }
+ }
+ }, [viewState])
+
+ const layerLayout = { visibility: showFields ? "visible" : "none" } as const
+ return (
+
+
+ setViewState((currentViewState) => ({
+ ...currentViewState,
+ longitude,
+ latitude,
+ zoom,
+ pitch: currentViewState.pitch, // Ensure pitch is carried over
+ bearing: currentViewState.bearing, // Ensure bearing is carried over
+ }))
+ }
+ showFields={showFields}
+ onToggleFields={() => setShowFields(!showFields)}
+ showFlyToFields={
+ fieldsData && fieldsData.features.length > 0
+ ? true
+ : undefined
+ }
+ onFlyToFields={() => {
+ setViewState({ ...initialViewState })
+ if (initialViewState.bounds) {
+ mapRef.current?.fitBounds(
+ initialViewState.bounds,
+ initialViewState.fitBoundsOptions,
+ )
+ }
+ }}
+ />
+
+
+
+ {fieldsData && (
+ {}}
+ >
+
+
+
+ )}
+
+
+
+
+ setSelectedParameter(val as ShadedSoilParameters)
+ }
+ >
+
+ {parameterDescription?.name}
+
+
+ {soilParametersDescriptions
+ .filter((item) =>
+ shadedSoilParameters.has(
+ item.parameter as ShadedSoilParameters,
+ ),
+ )
+ .map((opt) => {
+ return (
+
+
+
+ {opt.name}
+
+
+ {opt.description}
+
+ {opt.parameter}
+
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ )
+}
From f0862091d1d24268116a0c2f4496def894e9752d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 10:39:51 +0200
Subject: [PATCH 02/27] Add atlas field soil analysis detail pages
---
.../app/components/blocks/fields-new/soil.tsx | 1 +
fdm-app/app/components/blocks/soil/cards.tsx | 97 ++++-----
fdm-app/app/components/blocks/soil/list.tsx | 6 +-
fdm-app/app/components/blocks/soil/types.d.ts | 2 +-
....atlas.soil-analysis.$b_id.soil._index.tsx | 184 ++++++++++++++++++
...oil-analysis.$b_id.soil.analysis.$a_id.tsx | 149 ++++++++++++++
....$calendar.atlas.soil-analysis._index.tsx} | 0
...farm.$calendar.field.$b_id.soil._index.tsx | 3 +
8 files changed, 396 insertions(+), 46 deletions(-)
create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx => farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx} (100%)
diff --git a/fdm-app/app/components/blocks/fields-new/soil.tsx b/fdm-app/app/components/blocks/fields-new/soil.tsx
index 832fcf8fd..b79b04adb 100644
--- a/fdm-app/app/components/blocks/fields-new/soil.tsx
+++ b/fdm-app/app/components/blocks/fields-new/soil.tsx
@@ -95,6 +95,7 @@ export function NewFieldSoilAnalysisBlock({
diff --git a/fdm-app/app/components/blocks/soil/cards.tsx b/fdm-app/app/components/blocks/soil/cards.tsx
index e2ede9d24..6e6f28fdc 100644
--- a/fdm-app/app/components/blocks/soil/cards.tsx
+++ b/fdm-app/app/components/blocks/soil/cards.tsx
@@ -82,17 +82,19 @@ function SoilDataCard({
date,
source,
sourceLabel,
+ canModify,
}: {
title: string
description: string
- value: number | string
+ value: number | string | null
label: string | undefined
unit: string
type: "numeric" | "enum"
link: string
- date: Date
- source: string
+ date: Date | null
+ source: string | null
sourceLabel: string
+ canModify: boolean
}) {
return (
@@ -111,7 +113,7 @@ function SoilDataCard({
- {source !== "nl-other-nmi" ? (
+ {source !== "nl-other-nmi" && canModify ? (
@@ -126,7 +128,9 @@ function SoilDataCard({
- {type === "enum" ? (
+ {value === null ? (
+ "Onbekend"
+ ) : type === "enum" ? (
{label && type === "enum" ? label : value}
@@ -191,9 +195,13 @@ function SoilDataCard({
export function SoilDataCards({
currentSoilData,
soilParameterDescription,
+ canModifyAllSoilAnalyses = false,
+ canModifySoilAnalysis = {},
}: {
currentSoilData: CurrentSoilData
soilParameterDescription: SoilParameterDescription
+ canModifyAllSoilAnalyses?: boolean
+ canModifySoilAnalysis?: Record
}) {
const cards = constructSoilDataCards(
currentSoilData,
@@ -248,6 +256,10 @@ export function SoilDataCards({
date={card.date}
source={card.source}
sourceLabel={sourceLabel}
+ canModify={
+ canModifyAllSoilAnalyses ||
+ canModifySoilAnalysis[card.a_id]
+ }
/>
)
})}
@@ -264,47 +276,44 @@ function constructSoilDataCards(
soilParameterDescription: SoilParameterDescription,
) {
// Construct the soil data cards
- const cardValues = currentSoilData.map(
- (item: {
- parameter: string
- value: string | number | undefined
- a_id: string
- b_sampling_date: Date
- a_source: string
- }) => {
- const description = soilParameterDescription.find(
- (x: { parameter: string }) => {
- return x.parameter === item.parameter
- },
+ const cardValues = currentSoilData.map((item) => {
+ const description = soilParameterDescription.find(
+ (x: { parameter: string }) => {
+ return x.parameter === item.parameter
+ },
+ )
+
+ if (!description) {
+ console.warn(
+ `No description found for parameter: ${item.parameter}`,
)
+ return null
+ }
- if (!description) {
- console.warn(
- `No description found for parameter: ${item.parameter}`,
- )
- return null
- }
+ if (description.type !== "numeric" && description.type !== "enum") {
+ return null
+ }
- let label
- if (description.type === "enum") {
- label = description.options?.find(
- (option: { value: string }) => option.value === item.value,
- )?.label
- }
+ let label: string | undefined
+ if (description.type === "enum") {
+ label = description.options?.find(
+ (option: { value: string }) => option.value === item.value,
+ )?.label
+ }
- return {
- parameter: item.parameter,
- title: description.name,
- description: description.description,
- value: item.value,
- label: label,
- unit: description.unit,
- type: description.type,
- link: `./analysis/${item.a_id}`,
- date: item.b_sampling_date,
- source: item.a_source,
- }
- },
- )
- return cardValues.filter(Boolean) as any[]
+ return {
+ parameter: item.parameter,
+ title: description.name,
+ description: description.description,
+ value: item.value,
+ label: label,
+ unit: description.unit,
+ type: description.type,
+ a_id: item.a_id,
+ link: `./analysis/${item.a_id}`,
+ date: item.b_sampling_date,
+ source: item.a_source,
+ }
+ })
+ return cardValues.filter((x) => x !== null)
}
diff --git a/fdm-app/app/components/blocks/soil/list.tsx b/fdm-app/app/components/blocks/soil/list.tsx
index 9aa6976ee..6075a3f27 100644
--- a/fdm-app/app/components/blocks/soil/list.tsx
+++ b/fdm-app/app/components/blocks/soil/list.tsx
@@ -90,7 +90,11 @@ export function SoilAnalysesList({
"nl-other-nmi"
}
>
- Bewerk
+ {canModifySoilAnalysis[
+ analysis.a_id
+ ]
+ ? "Bewerk"
+ : "Bekijk"}
()
+ const fetcher = useFetcher()
+
+ return (
+
+
+
+
+
+ Bodem - {loaderData.field.b_name}
+
+
+ In de gegevens hieronder vind je de meest recente
+ waarde gemeten voor elke bodemparameter
+
+
{" "}
+
+
+
+ Terug naar kaart
+
+
+
+
+
+
+ Parameters
+ Analyses
+
+
+
+
+
+ {loaderData.soilAnalyses.length === 0 ? (
+
+
+
+ Dit perceel heeft nog geen bodemanalyse
+
+
+ Voeg een analyse toe om gegevens over de bodem
+ bij te houden
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
new file mode 100644
index 000000000..12b4d2460
--- /dev/null
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
@@ -0,0 +1,149 @@
+import {
+ getField,
+ getSoilAnalysis,
+ getSoilParametersDescription,
+} from "@nmi-agro/fdm-core"
+import { ArrowLeft } from "lucide-react"
+import {
+ data,
+ type LoaderFunctionArgs,
+ NavLink,
+ useLoaderData,
+} from "react-router"
+import { SoilAnalysisForm } from "~/components/blocks/soil/form"
+import { Button } from "~/components/ui/button"
+import { Separator } from "~/components/ui/separator"
+import { getSession } from "~/lib/auth.server"
+import { getCalendar } from "~/lib/calendar"
+import { handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+
+/**
+ * Loader function for the soil data page of a specific farm field.
+ *
+ * This function fetches the necessary data for rendering the soil data page, including
+ * field details, soil analyses, current soil data, and soil parameter descriptions.
+ * It validates the presence of the farm ID (`b_id_farm`) and field ID (`b_id`) in the
+ * route parameters and retrieves the user session.
+ *
+ * @param request - The HTTP request object.
+ * @param params - The route parameters, including `b_id_farm` and `b_id`.
+ * @returns An object containing the field details, current soil data, soil parameter descriptions, and soil analyses.
+ *
+ * @throws {Response} If the farm ID is missing (HTTP 400).
+ * @throws {Error} If the field ID is missing (HTTP 400).
+ * @throws {Error} If the field is not found (HTTP 404).
+ */
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ try {
+ // Get the calendar
+ const calendar = getCalendar(params)
+
+ // 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 field id
+ const b_id = params.b_id
+ if (!b_id) {
+ throw data("Field ID is required", {
+ status: 400,
+ statusText: "Field ID is required",
+ })
+ }
+
+ // Get the analysis id
+ const a_id = params.a_id
+ if (!a_id) {
+ throw data("Analysis ID is required", {
+ status: 400,
+ statusText: "Analysis ID is required",
+ })
+ }
+
+ // Get the session
+ const session = await getSession(request)
+
+ // Get details of field
+ const field = await getField(fdm, session.principal_id, b_id)
+ if (!field) {
+ throw data("Field is not found", {
+ status: 404,
+ statusText: "Field is not found",
+ })
+ }
+
+ // Get the soil analyses
+ const soilAnalysis = await getSoilAnalysis(
+ fdm,
+ session.principal_id,
+ a_id,
+ )
+
+ if (!soilAnalysis) {
+ throw data("Soil analysis not found", {
+ status: 404,
+ statusText: "Soil analysis not found",
+ })
+ }
+
+ // Get soil parameter descriptions and filter on the available soil parameters
+ const soilParameterDescription = getSoilParametersDescription().filter(
+ (item) => soilAnalysis[item.parameter],
+ )
+
+ // Return user information from loader
+ return {
+ calendar: calendar,
+ field: field,
+ soilParameterDescription: soilParameterDescription,
+ soilAnalysis: soilAnalysis,
+ }
+ } catch (error) {
+ throw handleLoaderError(error)
+ }
+}
+
+/**
+ * Component that renders the soil analysis form.
+ *
+ * This component displays the soil analysis form
+ *
+ */
+export default function FarmFieldSoilOverviewBlock() {
+ const loaderData = useLoaderData()
+ const field = loaderData.field
+
+ return (
+
+
+
+
Bodem
+
+ Bekijk en bewerk de gegevens van deze bodemanalyse
+
+
+
+
+
+ Terug
+
+
+
+
+
+
+ )
+}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
similarity index 100%
rename from fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.tsx
rename to fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
index f65d1e79b..aff91cfed 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx
@@ -215,6 +215,9 @@ export default function FarmFieldSoilOverviewBlock() {
soilParameterDescription={
loaderData.soilParameterDescription
}
+ canModifySoilAnalysis={
+ loaderData.soilAnalysisWritePermissions
+ }
/>
)}
From 1480bec7dfb2814a4e451713ea504f56d37ad281 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 11:28:50 +0200
Subject: [PATCH 03/27] Add hover panel
---
.../components/blocks/atlas/atlas-panels.tsx | 15 ++++-
.../blocks/atlas/atlas-soil-analysis.tsx | 31 ++++-----
...m.$calendar.atlas.soil-analysis._index.tsx | 67 ++++++++++++++++---
3 files changed, 81 insertions(+), 32 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
index 564e742e8..ddd6921c4 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
@@ -1,8 +1,12 @@
import type { FeatureCollection } from "geojson"
import throttle from "lodash.throttle"
import { Check, ChevronDown, ChevronUp, Info } from "lucide-react"
-import type { MapGeoJSONFeature, MapLibreZoomEvent } from "maplibre-gl"
-import { useCallback, useEffect, useRef, useState } from "react"
+import type {
+ GeoJSONFeature,
+ MapGeoJSONFeature,
+ MapLibreZoomEvent,
+} from "maplibre-gl"
+import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
import type { MapLayerMouseEvent as MapMouseEvent } from "react-map-gl/maplibre"
import { useMap } from "react-map-gl/maplibre"
import { data, NavLink, useFetcher } from "react-router"
@@ -25,11 +29,13 @@ export function FieldsPanelHover({
zoomLevelFields,
layer,
layerExclude,
+ render,
clickRedirectsToDetailsPage = false,
}: {
zoomLevelFields: number
layer: string[] | string
layerExclude?: string[] | string
+ render: (feature: GeoJSONFeature) => ReactNode
clickRedirectsToDetailsPage?: boolean
}) {
const { current: map } = useMap()
@@ -108,7 +114,9 @@ export function FieldsPanelHover({
? feature.properties.b_name
: feature.properties.b_lu_name
: "Naam"
- return (
+ return active && render ? (
+ render(feature)
+ ) : (
@@ -179,6 +187,7 @@ export function FieldsPanelHover({
zoomLevelFields,
layerIdsKey,
excludedLayerIdsKey,
+ render,
clickRedirectsToDetailsPage,
])
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
index 450a2ce54..f4f40511a 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
@@ -121,7 +121,7 @@ const COLORBREWER_RDPU = evenlySpaced(
)
const CUSTOM_SILVER = evenlySpaced("#f7f7f7", "#cccccc", "#969696", "#636363")
-const SHADED_SOIL_TYPES = [
+export const SHADED_SOIL_TYPES = [
{ value: "moerige_klei", label: "Moerige klei", fill: "#d9d9d9" },
{ value: "rivierklei", label: "Rivierklei", fill: "#8dd3c7" },
{ value: "dekzand", label: "Dekzand", fill: "#bebada" },
@@ -222,28 +222,19 @@ export function getShadedSoilParameters() {
}
export function getSoilAnalysisLayerStyle(
- dataPath: string[],
+ parameter: ShadedSoilParameters,
min: number,
max: number,
): { paint: LayerProps["paint"]; type: "fill" } {
- if (dataPath.length === 0) {
- throw new Error("dataPath needs to contain at least one item")
- }
- const key = dataPath[dataPath.length - 1]
// MapLibreGL expression to get the data path out of the input object (which is the feature properties)
- const dataGetter = getShadingParameterMapper(
- key as ShadedSoilParameters,
- ).paint(
- dataPath.reduce(
- (acc, current) =>
- acc !== null ? ["get", current, acc] : ["get", current],
- null as unknown[] | null,
- ) as ExpressionSpecification,
- )
+ const dataGetter = getShadingParameterMapper(parameter).paint([
+ "get",
+ parameter,
+ ])
- if (key in ENUM_SHADED_SOIL_PARAMETERS) {
+ if (parameter in ENUM_SHADED_SOIL_PARAMETERS) {
const fillColor =
- ENUM_SHADED_SOIL_PARAMETERS[key as EnumShadedSoilParameters]
+ ENUM_SHADED_SOIL_PARAMETERS[parameter as EnumShadedSoilParameters]
return {
type: "fill",
paint: {
@@ -252,9 +243,11 @@ export function getSoilAnalysisLayerStyle(
}
}
- if (key in GRADIENT_SHADED_SOIL_PARAMETERS) {
+ if (parameter in GRADIENT_SHADED_SOIL_PARAMETERS) {
const gradientName =
- GRADIENT_SHADED_SOIL_PARAMETERS[key as GradientShadedSoilParameters]
+ GRADIENT_SHADED_SOIL_PARAMETERS[
+ parameter as GradientShadedSoilParameters
+ ]
const fillColor = GRADIENT_DEFINITIONS[gradientName]
function transparentIfUndefined(
expr: ExpressionSpecification,
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 1926f1348..34995e14d 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -5,8 +5,10 @@ import {
getSoilParametersDescription,
} from "@nmi-agro/fdm-core"
import { simplify } from "@turf/simplify"
+import { formatDate } from "date-fns"
+import { nl } from "date-fns/locale"
import type { FeatureCollection, Geometry } from "geojson"
-import maplibregl from "maplibre-gl"
+import maplibregl, { type GeoJSONFeature } from "maplibre-gl"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Layer,
@@ -25,12 +27,13 @@ import {
getShadedSoilParameters,
getShadingParameterMapper,
getSoilAnalysisLayerStyle,
+ SHADED_SOIL_TYPES,
type ShadedSoilParameters,
SoilAnalysisLegend,
} from "~/components/blocks/atlas/atlas-soil-analysis"
import { FieldSourceClickable } from "~/components/blocks/atlas/atlas-sources"
import { getViewState } from "~/components/blocks/atlas/atlas-viewstate"
-import { Card } from "~/components/ui/card"
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import {
Select,
SelectContent,
@@ -112,13 +115,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
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,
- // For efficient access
- currentSoilData: fieldCurrentSoilData.reduce(
+ ...fieldCurrentSoilData.reduce(
(acc, data) => {
if (data.value !== null)
acc[data.parameter] = data.value
@@ -129,6 +126,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
string | number
>,
),
+ 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: simplify(field.b_geometry as Geometry, {
tolerance: 0.00001,
@@ -216,7 +218,7 @@ export default function FarmAtlasFieldSoilBlock() {
)
const heatmapLayerStyle = getSoilAnalysisLayerStyle(
- ["currentSoilData", selectedParameter],
+ selectedParameter,
min,
max,
)
@@ -258,6 +260,50 @@ export default function FarmAtlasFieldSoilBlock() {
}, [viewState])
const layerLayout = { visibility: showFields ? "visible" : "none" } as const
+
+ const renderHoverPanel = useCallback(
+ (feature: GeoJSONFeature) => {
+ const value = feature.properties[selectedParameter]
+ return (
+
+
+
+ {feature.properties.b_name}
+
+ {feature.properties.b_area} ha
+
+
+
+
+
+ {parameterDescription?.name}
+
+ {typeof value === "undefined" ? (
+ Geen data
+ ) : parameterDescription?.type === "date" ? (
+
+ {formatDate(value, "PP", {
+ locale: nl,
+ })}
+
+ ) : selectedParameter === "b_soiltype_agr" ? (
+
+ {SHADED_SOIL_TYPES.find(
+ (item) => item.value === value,
+ )?.label ?? value}
+
+ ) : (
+
+ {value} {parameterDescription?.unit}
+
+ )}
+
+
+ )
+ },
+ [selectedParameter, parameterDescription],
+ )
+
return (
From b1234961c868aa2cb9fa9a6ab0f1598fd256cbc8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 12:15:29 +0200
Subject: [PATCH 04/27] Clean-up
---
.../components/blocks/atlas/atlas-sources.tsx | 8 ++-
...m.$calendar.atlas.soil-analysis._index.tsx | 59 +++++++++++--------
fdm-app/app/store/selected-soil-parameter.ts | 24 ++++++++
3 files changed, 62 insertions(+), 29 deletions(-)
create mode 100644 fdm-app/app/store/selected-soil-parameter.ts
diff --git a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
index 1fdf6efaf..a19f0c2b0 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
@@ -315,11 +315,13 @@ export function FieldsSourceAvailable({
export function FieldSourceClickable({
id,
+ excludedLayerId,
fieldsData,
children,
onFieldClick,
}: {
id: string
+ excludedLayerId?: string
fieldsData: FeatureCollection
children: ReactNode
onFieldClick: (feature: Feature) => unknown
@@ -331,9 +333,9 @@ export function FieldSourceClickable({
if (!map) return
if (
- id &&
+ excludedLayerId &&
map.queryRenderedFeatures(evt.point, {
- layers: [id],
+ layers: [excludedLayerId],
}).length
) {
return
@@ -354,7 +356,7 @@ export function FieldSourceClickable({
map.off("click", clickOnMap)
}
}
- }, [map, id, onFieldClick])
+ }, [map, id, excludedLayerId, onFieldClick])
return (
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 34995e14d..5a17c7dd5 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -18,7 +18,11 @@ import {
type ViewStateChangeEvent,
} from "react-map-gl/maplibre"
import type { MetaFunction } from "react-router"
-import { type LoaderFunctionArgs, useLoaderData } from "react-router"
+import {
+ type LoaderFunctionArgs,
+ useLoaderData,
+ useNavigate,
+} from "react-router"
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"
@@ -46,6 +50,7 @@ import { getCalendar, getTimeframe } from "~/lib/calendar"
import { clientConfig } from "~/lib/config"
import { handleLoaderError } from "~/lib/error"
import { fdm } from "~/lib/fdm.server"
+import { useSelectedAtlasSoilParameterStore } from "~/store/selected-soil-parameter"
export const meta: MetaFunction = () => {
return [
@@ -86,10 +91,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
// Get the fields of the farm
let fieldsData: FeatureCollection | undefined
- const currentSoilData: {
- b_id: string
- currentSoilData: CurrentSoilData
- }[] = []
if (b_id_farm && b_id_farm !== "undefined") {
const fields = await getFields(
fdm,
@@ -108,10 +109,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const features = fields.map((field) => {
const fieldCurrentSoilData =
currentSoilDataForFarm.get(field.b_id) ?? []
- currentSoilData.push({
- b_id: field.b_id,
- currentSoilData: fieldCurrentSoilData,
- })
const feature = {
type: "Feature" as const,
properties: {
@@ -154,9 +151,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
// Return user information from loader
return {
calendar: calendar,
+ b_id_farm: b_id_farm,
fieldsData: fieldsData,
mapStyle: mapStyle,
- currentSoilData: currentSoilData,
soilParametersDescriptions: soilParametersDescriptions,
}
} catch (error) {
@@ -172,36 +169,42 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
*/
export default function FarmAtlasFieldSoilBlock() {
const {
+ calendar,
+ b_id_farm,
mapStyle,
fieldsData,
soilParametersDescriptions,
- currentSoilData,
} = useLoaderData()
+ const navigate = useNavigate()
- const heatmapLayerId = "fieldsSavedHeatmap"
- const heatmapStrokeLayerId = "fieldsSavedHeatmapStroke"
- const [selectedParameter, setSelectedParameter] =
- useState("a_som_loi")
+ const heatmapLayerId = "fieldsSaved"
+ const heatmapStrokeLayerId = "fieldsSavedStroke"
+ const selectedParameter = useSelectedAtlasSoilParameterStore(
+ (store) => store.selectedParameter,
+ )
+ const setSelectedParameter = useSelectedAtlasSoilParameterStore(
+ (store) => store.setSelectedParameter,
+ )
const shadedSoilParameters = new Set(getShadedSoilParameters())
const [min, max] = useMemo(() => {
- if (currentSoilData.length === 0) {
+ if (!fieldsData || fieldsData?.features.length === 0) {
return [0, 1]
}
const parameterDescription = soilParametersDescriptions.find(
(item) => item.parameter === selectedParameter,
)
if (parameterDescription?.type !== "numeric") return [0, 1]
+ const parameterMapper = getShadingParameterMapper(selectedParameter)
let min: number | null = null
let max: number | null = null
- for (const field of currentSoilData) {
- const parameterValue = field.currentSoilData.find(
- (item) => item.parameter === selectedParameter,
- )
- if (parameterValue) {
- const mappedValue = getShadingParameterMapper(
- selectedParameter,
- ).forward(parameterValue.value as number)
+ for (const field of fieldsData.features) {
+ if (!field.properties) continue
+ const parameterValue = field.properties[selectedParameter]
+ if (typeof parameterValue !== "undefined") {
+ const mappedValue = parameterMapper.forward(
+ parameterValue as number,
+ )
min = min === null ? mappedValue : Math.min(min, mappedValue)
max = max === null ? mappedValue : Math.max(max, mappedValue)
}
@@ -211,7 +214,7 @@ export default function FarmAtlasFieldSoilBlock() {
return defaultedMin === defaultedMax
? [defaultedMin - 0.01, defaultedMin + 0.01]
: [defaultedMin, defaultedMax]
- }, [selectedParameter, currentSoilData, soilParametersDescriptions])
+ }, [selectedParameter, fieldsData, soilParametersDescriptions])
const parameterDescription = soilParametersDescriptions.find(
(item) => item.parameter === selectedParameter,
@@ -350,7 +353,11 @@ export default function FarmAtlasFieldSoilBlock() {
{}}
+ onFieldClick={(feature) => {
+ navigate(
+ `/farm/${b_id_farm}/${calendar}/atlas/soil-analysis/${feature.properties.b_id}/soil`,
+ )
+ }}
>
void
+}
+
+export const useSelectedAtlasSoilParameterStore =
+ create()(
+ persist(
+ (set) => ({
+ selectedParameter: "a_som_loi",
+ setSelectedParameter: (selectedParameter) =>
+ set({ selectedParameter: selectedParameter }),
+ }),
+ {
+ name: "selected-atlas-soil-parameter-storage",
+ storage: createJSONStorage(() => ssrSafeSessionJSONStorage),
+ },
+ ),
+ )
From 66d20d3072b2ef382965ff3d11ed4e888d5fdb53 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 13:11:33 +0200
Subject: [PATCH 05/27] Add navigation to the soil analysis atlas
---
.../app/components/blocks/header/atlas.tsx | 72 ++++++++++++-------
.../app/components/blocks/sidebar/apps.tsx | 59 ++++++++++++---
2 files changed, 98 insertions(+), 33 deletions(-)
diff --git a/fdm-app/app/components/blocks/header/atlas.tsx b/fdm-app/app/components/blocks/header/atlas.tsx
index bdfb971c0..19fe3faec 100644
--- a/fdm-app/app/components/blocks/header/atlas.tsx
+++ b/fdm-app/app/components/blocks/header/atlas.tsx
@@ -9,7 +9,10 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
@@ -18,12 +21,15 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) {
const location = useLocation()
const isElevation = location.pathname.includes("/elevation")
+ const isSoilAnalysis = location.pathname.includes("/soil-analysis")
const isSoil = location.pathname.includes("/soil")
const currentName = isElevation
? "Hoogtekaart"
- : isSoil
- ? "Bodemkaart"
- : "Gewaspercelen"
+ : isSoilAnalysis
+ ? "Bodemanalyses"
+ : isSoil
+ ? "Bodemkaart"
+ : "Gewaspercelen"
return (
<>
@@ -41,27 +47,45 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) {
-
-
- Gewaspercelen
-
-
-
-
- Hoogtekaart
-
-
-
-
- Bodemkaart
-
-
+
+
+ Bedrijf
+
+
+
+ Gewaspercelen
+
+
+
+
+ Bodemanalyses
+
+
+
+
+
+
+ Overig
+
+
+
+ Hoogtekaart
+
+
+
+
+ Bodemkaart
+
+
+
diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx
index 179fa4813..50eed097a 100644
--- a/fdm-app/app/components/blocks/sidebar/apps.tsx
+++ b/fdm-app/app/components/blocks/sidebar/apps.tsx
@@ -50,23 +50,40 @@ export function SidebarApps() {
let atlasFieldsLink: string | undefined
let atlasElevationLink: string | undefined
let atlasSoilLink: string | undefined
+ let atlasSoilAnalysisLink: string | undefined
if (isCreateFarmWizard) {
atlasLink = undefined
atlasFieldsLink = undefined
atlasElevationLink = undefined
atlasSoilLink = undefined
+ atlasSoilAnalysisLink = 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`
+ atlasSoilAnalysisLink = `/farm/${farmId}/${selectedCalendar}/atlas/soil-analysis`
} else {
atlasLink = `/farm/undefined/${selectedCalendar}/atlas`
atlasFieldsLink = `/farm/undefined/${selectedCalendar}/atlas/fields`
atlasElevationLink = `/farm/undefined/${selectedCalendar}/atlas/elevation`
atlasSoilLink = `/farm/undefined/${selectedCalendar}/atlas/soil`
+ atlasSoilAnalysisLink = `/farm/undefined/${selectedCalendar}/atlas/soil-analysis`
}
+ const activeAtlasTab =
+ atlasFieldsLink && location.pathname.includes(atlasFieldsLink)
+ ? "fields"
+ : atlasElevationLink &&
+ location.pathname.includes(atlasElevationLink)
+ ? "elevation"
+ : atlasSoilAnalysisLink &&
+ location.pathname.includes(atlasSoilAnalysisLink)
+ ? "soil-analysis"
+ : atlasSoilLink && location.pathname.includes(atlasSoilLink)
+ ? "soil"
+ : undefined
+
let nitrogenBalanceLink: string | undefined
if (isCreateFarmWizard || isFarmOverview) {
nitrogenBalanceLink = undefined
@@ -152,9 +169,10 @@ export function SidebarApps() {
{atlasFieldsLink ? (
) : null}
+
+ {atlasSoilAnalysisLink ? (
+
+
+
+ Bodemanalyses
+
+
+
+ ) : null}
+
{atlasElevationLink ? (
Bodemkaart
From 3f3376b0438982c293092bd4bdb899a196828ba5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 14:25:28 +0200
Subject: [PATCH 06/27] Move soil analysis legend to atlas-legend.tsx
---
.../components/blocks/atlas/atlas-legend.tsx | 198 ++++++++++++++++++
.../blocks/atlas/atlas-soil-analysis.tsx | 103 +--------
...m.$calendar.atlas.soil-analysis._index.tsx | 65 +-----
3 files changed, 218 insertions(+), 148 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index e76f9e5f5..c5a3c010b 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -1,5 +1,30 @@
+import type { SoilParameterDescription } from "@nmi-agro/fdm-core"
+import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson"
+import { useId, useMemo } from "react"
+import {
+ Bar,
+ BarChart,
+ type BarShapeProps,
+ Rectangle,
+ XAxis,
+ YAxis,
+} from "recharts"
import { Card, CardContent } from "~/components/ui/card"
+import { ChartContainer } from "~/components/ui/chart"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from "~/components/ui/select"
import { Spinner } from "~/components/ui/spinner"
+import {
+ GRADIENT_DEFINITIONS,
+ GRADIENT_SHADED_SOIL_PARAMETERS,
+ getShadedSoilParameters,
+ SHADED_SOIL_TYPES,
+ type ShadedSoilParameters,
+} from "./atlas-soil-analysis"
interface ElevationLegendProps {
min?: number
@@ -86,3 +111,176 @@ export function ElevationLegend({
)
}
+interface SoilAnalysisLegendProps {
+ fieldsData?: FeatureCollection
+ selectedParameter: ShadedSoilParameters
+ setSelectedParameter: (parameter: ShadedSoilParameters) => void
+ soilParametersDescriptions: SoilParameterDescription
+ min?: number
+ max?: number
+}
+
+export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
+ const {
+ selectedParameter,
+ setSelectedParameter,
+ soilParametersDescriptions,
+ } = props
+
+ // Parameter shading config
+ const shadingConfig = Object.fromEntries(
+ getShadedSoilParameters().map((item) => [item.parameter, item]),
+ )
+
+ // Parameter description
+ const soilParameterOptions = soilParametersDescriptions.filter(
+ (item) => item.parameter in shadingConfig,
+ )
+ const parameterDescription = soilParametersDescriptions.find(
+ (opt) => opt.parameter === selectedParameter,
+ )
+
+ return (
+
+
+ setSelectedParameter(val as ShadedSoilParameters)
+ }
+ >
+
+ {parameterDescription?.name}
+
+
+ {soilParameterOptions.map((opt) => {
+ return (
+
+
+
+ {opt.name}
+
+
+ {opt.description}
+
+
+
+ )
+ })}
+
+
+ {shadingConfig[selectedParameter].shading === "enum" ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function EnumSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
+ const displayedOptions = useMemo(() => {
+ if (props.selectedParameter !== "b_soiltype_agr") return []
+ if (!props.fieldsData) return SHADED_SOIL_TYPES
+
+ const found = new Set()
+
+ for (const feature of props.fieldsData.features) {
+ const value = feature.properties?.[props.selectedParameter]
+ if (typeof value !== "undefined") {
+ found.add(value as string)
+ }
+ }
+
+ return SHADED_SOIL_TYPES.filter((item) => found.has(item.value))
+ }, [props.selectedParameter, props.fieldsData])
+
+ return (
+
+ {displayedOptions.map((opt) => (
+
+
+
+
+ {opt.label}
+
+ ))}
+
+ )
+}
+
+function GradientSoilAnalysisLegend(
+ props: SoilAnalysisLegendProps & {
+ selectedParameter: ShadedSoilParameters
+ },
+) {
+ const gradDef =
+ GRADIENT_DEFINITIONS[
+ GRADIENT_SHADED_SOIL_PARAMETERS[
+ props.selectedParameter as keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
+ ]
+ ]
+
+ let min = props.min as number
+ let max = props.max as number
+ if (typeof gradDef.center === "number") {
+ const radius = Math.max(max - gradDef.center, gradDef.center - min)
+ min = gradDef.center - radius
+ max = gradDef.center + radius
+ }
+
+ const chartData = [{ name: "Legenda", min: min, max: max }]
+ const gradient = gradDef.gradient
+
+ const gradientId = useId()
+
+ const gradientSvg: React.ReactNode[] = []
+ for (let i = 0; i < gradient.length; i += 2) {
+ gradientSvg.push(
+ ,
+ )
+ }
+ return (
+
+
+
+
+ {gradientSvg}
+
+
+
+
+ [entry.min, entry.max]}
+ shape={(props: BarShapeProps) => (
+
+ )}
+ />
+
+
+ )
+}
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
index f4f40511a..4239cf220 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
@@ -1,16 +1,5 @@
-import type { SoilParameterDescription } from "@nmi-agro/fdm-core"
import type { ExpressionSpecification } from "maplibre-gl"
-import { useId } from "react"
import type { LayerProps } from "react-map-gl"
-import {
- Bar,
- BarChart,
- type BarShapeProps,
- Rectangle,
- XAxis,
- YAxis,
-} from "recharts"
-import { ChartContainer } from "~/components/ui/chart"
/* ================ SHADING DEFINITIONS ================ */
@@ -136,7 +125,7 @@ export const SHADED_SOIL_TYPES = [
/** Which gradient definition to use for gradient-shaded parameters.
* Add items here to let the user select other parameters.
*/
-const GRADIENT_SHADED_SOIL_PARAMETERS = {
+export const GRADIENT_SHADED_SOIL_PARAMETERS = {
a_al_ox: "aluminum",
a_c_of: "carbon",
a_ca_co: "calcium",
@@ -176,7 +165,7 @@ const GRADIENT_SHADED_SOIL_PARAMETERS = {
type GradientShadedSoilParameters = keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
/** Actual gradient definitions */
-const GRADIENT_DEFINITIONS: {
+export const GRADIENT_DEFINITIONS: {
[k in (typeof GRADIENT_SHADED_SOIL_PARAMETERS)[GradientShadedSoilParameters]]: {
gradient: (string | number)[]
center?: number
@@ -216,9 +205,15 @@ export type ShadedSoilParameters =
export function getShadedSoilParameters() {
return [
- ...Object.keys(GRADIENT_SHADED_SOIL_PARAMETERS),
- ...Object.keys(ENUM_SHADED_SOIL_PARAMETERS),
- ] as ShadedSoilParameters[]
+ ...Object.keys(GRADIENT_SHADED_SOIL_PARAMETERS).map((parameter) => ({
+ parameter,
+ shading: "gradient",
+ })),
+ ...Object.keys(ENUM_SHADED_SOIL_PARAMETERS).map((parameter) => ({
+ parameter,
+ shading: "enum",
+ })),
+ ] as { parameter: ShadedSoilParameters; shading: "gradient" | "enum" }[]
}
export function getSoilAnalysisLayerStyle(
@@ -373,79 +368,3 @@ export function getShadingParameterMapper(parameter: ShadedSoilParameters) {
}
return SHADING_PARAMETER_MAPPERS[parameter] ?? DEFAULT_PARAMETER_MAPPER
}
-
-interface SoilAnalysisLegendProps {
- parameter: ShadedSoilParameters
- soilParametersDescriptions: SoilParameterDescription
- min?: number
- max?: number
-}
-
-export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
- if (props.parameter in ENUM_SHADED_SOIL_PARAMETERS) {
- return
- }
-
- return
-}
-
-function EnumSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
- return null
-}
-
-function GradientSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
- const gradDef =
- GRADIENT_DEFINITIONS[
- GRADIENT_SHADED_SOIL_PARAMETERS[
- props.parameter as GradientShadedSoilParameters
- ]
- ]
-
- let min = props.min as number
- let max = props.max as number
- if (typeof gradDef.center === "number") {
- const radius = Math.max(max - gradDef.center, gradDef.center - min)
- min = gradDef.center - radius
- max = gradDef.center + radius
- }
-
- const chartData = [{ name: "Legenda", min: min, max: max }]
- const gradient = gradDef.gradient
-
- const gradientId = useId()
-
- const gradientSvg: React.ReactNode[] = []
- for (let i = 0; i < gradient.length; i += 2) {
- gradientSvg.push(
- ,
- )
- }
- return (
-
-
-
-
- {gradientSvg}
-
-
-
-
- [entry.min, entry.max]}
- shape={(props: BarShapeProps) => (
-
- )}
- />
-
-
- )
-}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 5a17c7dd5..80596f6c8 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -26,24 +26,16 @@ import {
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 { SoilAnalysisLegend } from "~/components/blocks/atlas/atlas-legend"
import { FieldsPanelHover } from "~/components/blocks/atlas/atlas-panels"
import {
- getShadedSoilParameters,
getShadingParameterMapper,
getSoilAnalysisLayerStyle,
SHADED_SOIL_TYPES,
- type ShadedSoilParameters,
- SoilAnalysisLegend,
} from "~/components/blocks/atlas/atlas-soil-analysis"
import { FieldSourceClickable } from "~/components/blocks/atlas/atlas-sources"
import { getViewState } from "~/components/blocks/atlas/atlas-viewstate"
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
-} from "~/components/ui/select"
import { getMapStyle } from "~/integrations/map"
import { getSession } from "~/lib/auth.server"
import { getCalendar, getTimeframe } from "~/lib/calendar"
@@ -185,7 +177,6 @@ export default function FarmAtlasFieldSoilBlock() {
const setSelectedParameter = useSelectedAtlasSoilParameterStore(
(store) => store.setSelectedParameter,
)
- const shadedSoilParameters = new Set(getShadedSoilParameters())
const [min, max] = useMemo(() => {
if (!fieldsData || fieldsData?.features.length === 0) {
@@ -374,52 +365,14 @@ export default function FarmAtlasFieldSoilBlock() {
)}
-
-
- setSelectedParameter(val as ShadedSoilParameters)
- }
- >
-
- {parameterDescription?.name}
-
-
- {soilParametersDescriptions
- .filter((item) =>
- shadedSoilParameters.has(
- item.parameter as ShadedSoilParameters,
- ),
- )
- .map((opt) => {
- return (
-
-
-
- {opt.name}
-
-
- {opt.description}
-
- {opt.parameter}
-
-
-
-
- )
- })}
-
-
-
-
+
Date: Wed, 6 May 2026 14:46:17 +0200
Subject: [PATCH 07/27] Improve sidebar and header behavior
---
.../app/components/blocks/header/atlas.tsx | 60 ++++++++++++-------
.../app/components/blocks/sidebar/apps.tsx | 36 +++++------
2 files changed, 55 insertions(+), 41 deletions(-)
diff --git a/fdm-app/app/components/blocks/header/atlas.tsx b/fdm-app/app/components/blocks/header/atlas.tsx
index 19fe3faec..abfa0789e 100644
--- a/fdm-app/app/components/blocks/header/atlas.tsx
+++ b/fdm-app/app/components/blocks/header/atlas.tsx
@@ -30,6 +30,7 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) {
: isSoil
? "Bodemkaart"
: "Gewaspercelen"
+ const isFarmSelected = b_id_farm && b_id_farm !== "undefined"
return (
<>
@@ -47,30 +48,43 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) {
+ {isFarmSelected && (
+
+
+ Bedrijf
+
+
+
+ Gewaspercelen
+
+
+
+
+ Bodemanalyses
+
+
+
+ )}
+ {isFarmSelected && }
-
- Bedrijf
-
-
-
- Gewaspercelen
-
-
-
-
- Bodemanalyses
-
-
-
-
-
-
- Overig
-
+ {isFarmSelected && (
+
+ Overig
+
+ )}
+ {!isFarmSelected && (
+
+
+ Gewaspercelen
+
+
+ )}
-
- {atlasFieldsLink ? (
+ {atlasFieldsLink ? (
+
- ) : null}
-
-
- {atlasSoilAnalysisLink ? (
+
+ ) : null}
+ {atlasSoilAnalysisLink ? (
+
- ) : null}
-
-
- {atlasElevationLink ? (
+
+ ) : null}
+ {atlasElevationLink ? (
+
Hoogtekaart
- ) : null}
-
-
- {atlasSoilLink ? (
+
+ ) : null}
+ {atlasSoilLink ? (
+
Bodemkaart
- ) : null}
-
+
+ ) : null}
From eeadc0499290e67bf6d94b7617a3064c4ae51c96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 16:00:07 +0200
Subject: [PATCH 08/27] Improve colors
---
.../blocks/atlas/atlas-soil-analysis.tsx | 59 ++++++++-----------
1 file changed, 25 insertions(+), 34 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
index 4239cf220..4aa130851 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
@@ -7,16 +7,6 @@ function evenlySpaced(...args: string[]) {
return args.flatMap((item, i) => [i / (args.length - 1), item])
}
-const COLORBREWER_REDS = evenlySpaced(
- "#fff5f0",
- "#fee0d2",
- "#fcbba1",
- "#fc9272",
- "#fb6a4a",
- "#ef3b2c",
- "#cb181d",
- "#99000d",
-)
const COLORBREWER_ORANGES = evenlySpaced(
"#fff5eb",
"#fee6ce",
@@ -28,16 +18,6 @@ const COLORBREWER_ORANGES = evenlySpaced(
"#a63603",
"#7f2704",
)
-const COLORBREWER_GREENS = evenlySpaced(
- "#f7fcf5",
- "#e5f5e0",
- "#c7e9c0",
- "#a1d99b",
- "#74c476",
- "#41ab5d",
- "#238b45",
- "#005a32",
-)
const COLORBREWER_BLUES = evenlySpaced(
"#f7fbff",
"#deebf7",
@@ -108,18 +88,28 @@ const COLORBREWER_RDPU = evenlySpaced(
"#ae017e",
"#7a0177",
)
-const CUSTOM_SILVER = evenlySpaced("#f7f7f7", "#cccccc", "#969696", "#636363")
+const COLORBREWER_PUBUGN = evenlySpaced(
+ "#fff7fb",
+ "#ece2f0",
+ "#d0d1e6",
+ "#a6bddb",
+ "#67a9cf",
+ "#3690c0",
+ "#02818a",
+ "#016c59",
+ "#014636",
+)
export const SHADED_SOIL_TYPES = [
- { value: "moerige_klei", label: "Moerige klei", fill: "#d9d9d9" },
- { value: "rivierklei", label: "Rivierklei", fill: "#8dd3c7" },
- { value: "dekzand", label: "Dekzand", fill: "#bebada" },
- { value: "zeeklei", label: "Zeeklei", fill: "#fb8072" },
- { value: "dalgrond", label: "Dalgrond", fill: "#fccde5" },
- { value: "veen", label: "Veen", fill: "#b3de69" },
- { value: "loess", label: "Löss", fill: "#fdb462" },
- { value: "duinzand", label: "Duinzand", fill: "#ffffb3" },
- { value: "maasklei", label: "Maasklei", fill: "#80b1d3" },
+ { value: "moerige_klei", label: "Moerige klei", fill: "rgb(45, 0, 168)" },
+ { value: "rivierklei", label: "Rivierklei", fill: "rgb(112, 194, 0)" },
+ { value: "dekzand", label: "Dekzand", fill: "rgb(244, 244, 70)" },
+ { value: "zeeklei", label: "Zeeklei", fill: "rgb(6, 158, 200)" },
+ { value: "dalgrond", label: "Dalgrond", fill: "rgb(195, 195, 195)" },
+ { value: "veen", label: "Veen", fill: "rgb(223, 115, 255)" },
+ { value: "loess", label: "Löss", fill: "rgb(255, 255, 255)" },
+ { value: "duinzand", label: "Duinzand", fill: "rgb(255, 100, 0)" },
+ { value: "maasklei", label: "Maasklei", fill: "rgb(152, 158, 0)" },
]
/** Which gradient definition to use for gradient-shaded parameters.
@@ -171,24 +161,24 @@ export const GRADIENT_DEFINITIONS: {
center?: number
}
} = {
- aluminum: { gradient: CUSTOM_SILVER },
+ aluminum: { gradient: COLORBREWER_GREYS },
bacterium: { gradient: COLORBREWER_GNBU },
calcium: { gradient: COLORBREWER_BUGN },
carbon: { gradient: COLORBREWER_GREYS },
carbon_ratio: { gradient: COLORBREWER_GREYS },
- copper: { gradient: COLORBREWER_REDS },
+ copper: { gradient: COLORBREWER_BLUES },
earth_heavy: { gradient: COLORBREWER_YLORBR },
earth_light: { gradient: COLORBREWER_ORANGES },
nitrogen: { gradient: COLORBREWER_BLUES },
iron: { gradient: COLORBREWER_ORANGES },
- magnesium: { gradient: COLORBREWER_GREENS },
+ magnesium: { gradient: COLORBREWER_PUBUGN },
phosphorus: { gradient: COLORBREWER_RDPU },
potassium: { gradient: COLORBREWER_RDPU },
ph: { gradient: COLORBREWER_RDBU, center: 7 },
sand_dark: { gradient: COLORBREWER_YLORBR },
sand_light: { gradient: COLORBREWER_ORANGES },
sulfur: { gradient: COLORBREWER_YLORBR },
- zinc: { gradient: CUSTOM_SILVER },
+ zinc: { gradient: COLORBREWER_GREYS },
}
const ENUM_SHADED_SOIL_PARAMETERS = {
@@ -233,6 +223,7 @@ export function getSoilAnalysisLayerStyle(
return {
type: "fill",
paint: {
+ "fill-opacity": 0.8,
"fill-color": ["match", dataGetter, ...fillColor],
},
}
From 9a0c3c95d66590ab667f1e73b271f2d141557002 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 16:19:31 +0200
Subject: [PATCH 09/27] Improve gradient legend
---
.../components/blocks/atlas/atlas-legend.tsx | 26 ++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index c5a3c010b..6b3fe8672 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -1,5 +1,6 @@
import type { SoilParameterDescription } from "@nmi-agro/fdm-core"
import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson"
+import { TriangleAlert } from "lucide-react"
import { useId, useMemo } from "react"
import {
Bar,
@@ -22,6 +23,7 @@ import {
GRADIENT_DEFINITIONS,
GRADIENT_SHADED_SOIL_PARAMETERS,
getShadedSoilParameters,
+ getShadingParameterMapper,
SHADED_SOIL_TYPES,
type ShadedSoilParameters,
} from "./atlas-soil-analysis"
@@ -122,6 +124,7 @@ interface SoilAnalysisLegendProps {
export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
const {
+ fieldsData,
selectedParameter,
setSelectedParameter,
soilParametersDescriptions,
@@ -140,6 +143,11 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
(opt) => opt.parameter === selectedParameter,
)
+ const anyDataAvailable = fieldsData?.features.some(
+ (feature) =>
+ feature.properties && selectedParameter in feature.properties,
+ )
+
return (
)}
+ {fieldsData && !anyDataAvailable && (
+
+
+ Geen data op hele bedrijf
+
+ )}
)
}
@@ -228,6 +242,7 @@ function GradientSoilAnalysisLegend(
props.selectedParameter as keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
]
]
+ const parameterMapper = getShadingParameterMapper(props.selectedParameter)
let min = props.min as number
let max = props.max as number
@@ -269,7 +284,16 @@ function GradientSoilAnalysisLegend(
{gradientSvg}
-
+
+ (
+ Math.round(parameterMapper.inverse(n) * 100) / 100
+ ).toString()
+ }
+ />
Date: Wed, 6 May 2026 16:23:41 +0200
Subject: [PATCH 10/27] Add changeset
---
.changeset/orange-yaks-refuse.md | 5 +++++
.changeset/ready-clowns-pick.md | 5 +++++
2 files changed, 10 insertions(+)
create mode 100644 .changeset/orange-yaks-refuse.md
create mode 100644 .changeset/ready-clowns-pick.md
diff --git a/.changeset/orange-yaks-refuse.md b/.changeset/orange-yaks-refuse.md
new file mode 100644
index 000000000..b414ce789
--- /dev/null
+++ b/.changeset/orange-yaks-refuse.md
@@ -0,0 +1,5 @@
+---
+"@nmi-agro/fdm-app": minor
+---
+
+Added the soil analysis atlas. Users can select different soil parameters which will show each field on the atlas coloured according the parameter's value in its soil analyses.
diff --git a/.changeset/ready-clowns-pick.md b/.changeset/ready-clowns-pick.md
new file mode 100644
index 000000000..cdb8ea728
--- /dev/null
+++ b/.changeset/ready-clowns-pick.md
@@ -0,0 +1,5 @@
+---
+"@nmi-agro/fdm-app": minor
+---
+
+Users can no longer edit soil analyses by clicking the pencil icon on the parameter card's corner, if they don't have the right to edit the analysis that is the source of the value shown on the card.
From de5c89fad194970ee28fb4f2d96d9f8b6452ae0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 16:30:45 +0200
Subject: [PATCH 11/27] Resolve CodeQL finding
---
..._id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
index c6e114498..c5e4563bd 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
@@ -94,8 +94,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
// Get soil parameter descriptions
const soilParameterDescription = getSoilParametersDescription()
- const pathname = new URL(request.url).pathname
-
// Return user information from loader
return {
field: field,
From 1476dc59414a1736ea2b8b52d7950953c9cf742f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 6 May 2026 17:45:47 +0200
Subject: [PATCH 12/27] Address nitpicks
---
.../components/blocks/atlas/atlas-legend.tsx | 24 ++++++++++---------
.../components/blocks/atlas/atlas-panels.tsx | 17 ++++++++++++-
.../blocks/atlas/atlas-soil-analysis.tsx | 7 ++++--
.../components/blocks/atlas/atlas-sources.tsx | 13 ++++------
fdm-app/app/components/blocks/soil/cards.tsx | 18 ++++++++++----
....atlas.soil-analysis.$b_id.soil._index.tsx | 24 ++++++-------------
...oil-analysis.$b_id.soil.analysis.$a_id.tsx | 8 ++++---
...m.$calendar.atlas.soil-analysis._index.tsx | 1 +
8 files changed, 65 insertions(+), 47 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 6b3fe8672..9bdad6c63 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -216,17 +216,19 @@ function EnumSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
return (
- {displayedOptions.map((opt) => (
-
-
-
-
- {opt.label}
-
- ))}
+
+ {displayedOptions.map((opt) => (
+
+
+
+
+ {opt.label}
+
+ ))}
+
)
}
diff --git a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
index ddd6921c4..68106c0fb 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
@@ -25,6 +25,21 @@ import { Separator } from "~/components/ui/separator"
import { Spinner } from "~/components/ui/spinner"
import { cn } from "~/lib/utils"
+/**
+ * Renders a panel showing the field name or the cultivation name and the corresponding area,
+ * for the farm or cultivation field that is currently hovered on with the mouse pointer.
+ * It can also include contextual information if the field IDs "fieldsSaved", "fieldsAvailable" etc. are used.
+ *
+ * This component will always contain some HTML, but it will have hidden visibility if there is no intersected feature
+ *
+ * - `zoomLevelFields` is the zoom threshold after which no panel is shown
+ * - `layer` is a layer ID or an array of IDs for which the panel is shown
+ * - `layerExclude` can be a layerId or an array of IDs which block the panel from being shown
+ * - `render` can be used to render a custom panel instead of the default one.
+ * It **SHOULD** be a stable reference since when it changes the event handlers on the map are reinstantiated.
+ * - `clickRedirectsToDetailsPage`, if set to true, causes the default panel to tell the user that clicking will navigate to a different page
+ * @returns the output of the render function, or a Card containing the information mentioned above.
+ */
export function FieldsPanelHover({
zoomLevelFields,
layer,
@@ -35,7 +50,7 @@ export function FieldsPanelHover({
zoomLevelFields: number
layer: string[] | string
layerExclude?: string[] | string
- render: (feature: GeoJSONFeature) => ReactNode
+ render?: (feature: GeoJSONFeature) => ReactNode
clickRedirectsToDetailsPage?: boolean
}) {
const { current: map } = useMap()
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
index 4aa130851..ac782085d 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
@@ -120,7 +120,7 @@ export const GRADIENT_SHADED_SOIL_PARAMETERS = {
a_c_of: "carbon",
a_ca_co: "calcium",
a_ca_co_po: "calcium",
- a_cao3_if: "calcium",
+ a_caco3_if: "calcium",
a_cec_co: "earth_light",
a_clay_mi: "earth_heavy",
a_cn_fr: "carbon_ratio",
@@ -352,7 +352,10 @@ const DEFAULT_PARAMETER_MAPPER: ValueMapper = {
* @returns object containing mapping functions
*/
export function getShadingParameterMapper(parameter: ShadedSoilParameters) {
- if (!(parameter in GRADIENT_SHADED_SOIL_PARAMETERS)) {
+ if (
+ parameter in SHADING_PARAMETER_MAPPERS &&
+ !(parameter in GRADIENT_SHADED_SOIL_PARAMETERS)
+ ) {
console.warn(
`Custom value mapper used for non-gradient-shaded parameter: ${parameter}`,
)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
index a19f0c2b0..2f1a467fc 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx
@@ -331,6 +331,7 @@ export function FieldSourceClickable({
useEffect(() => {
function clickOnMap(evt: MapLayerMouseEvent) {
if (!map) return
+ if (!(evt.features && evt.features.length > 0)) return
if (
excludedLayerId &&
@@ -341,19 +342,13 @@ export function FieldSourceClickable({
return
}
- const features = map.queryRenderedFeatures(evt.point, {
- layers: [id],
- })
-
- if (features.length > 0 && features[0].layer.id === id) {
- onFieldClick(features[0] as unknown as Feature)
- }
+ onFieldClick(evt.features[0] as unknown as Feature)
}
if (map) {
- map.on("click", clickOnMap)
+ map.on("click", id, clickOnMap)
return () => {
- map.off("click", clickOnMap)
+ map.off("click", id, clickOnMap)
}
}
}, [map, id, excludedLayerId, onFieldClick])
diff --git a/fdm-app/app/components/blocks/soil/cards.tsx b/fdm-app/app/components/blocks/soil/cards.tsx
index 6e6f28fdc..cbc6b6389 100644
--- a/fdm-app/app/components/blocks/soil/cards.tsx
+++ b/fdm-app/app/components/blocks/soil/cards.tsx
@@ -4,7 +4,14 @@ import type {
} from "@nmi-agro/fdm-core"
import { format } from "date-fns/format"
import { nl } from "date-fns/locale/nl"
-import { Calendar, Microscope, Pencil, Sparkles, User } from "lucide-react"
+import {
+ Calendar,
+ ExternalLink,
+ Microscope,
+ Pencil,
+ Sparkles,
+ User,
+} from "lucide-react"
import { NavLink } from "react-router"
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import { Separator } from "~/components/ui/separator"
@@ -96,6 +103,7 @@ function SoilDataCard({
sourceLabel: string
canModify: boolean
}) {
+ const EditIcon = canModify ? Pencil : ExternalLink
return (
@@ -113,15 +121,17 @@ function SoilDataCard({
- {source !== "nl-other-nmi" && canModify ? (
+ {source !== "nl-other-nmi" ? (
-
+
- Bewerken
+
+ {canModify ? "Bewerken" : "Bekijken"}
+
) : null}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
index c5e4563bd..5fcf506f8 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
@@ -72,24 +72,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
})
}
- // Get the soil analyses
- const soilAnalyses = await getSoilAnalyses(
- fdm,
- session.principal_id,
- b_id,
- {
+ // Get the current soil data (for the parameters tab) and soil analyses (for the analyses tab)
+ const [soilAnalyses, currentSoilData] = await Promise.all([
+ getSoilAnalyses(fdm, session.principal_id, b_id, {
start: null,
end: timeframe.end,
- },
- )
-
- // Get current soil data
- const currentSoilData = await getCurrentSoilData(
- fdm,
- session.principal_id,
- b_id,
- timeframe,
- )
+ }),
+ getCurrentSoilData(fdm, session.principal_id, b_id, timeframe),
+ ])
// Get soil parameter descriptions
const soilParameterDescription = getSoilParametersDescription()
@@ -129,7 +119,7 @@ export default function FarmFieldSoilOverviewBlock() {
In de gegevens hieronder vind je de meest recente
waarde gemeten voor elke bodemparameter
- {" "}
+
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
index 12b4d2460..352f16f76 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
@@ -28,7 +28,7 @@ import { fdm } from "~/lib/fdm.server"
*
* @param request - The HTTP request object.
* @param params - The route parameters, including `b_id_farm` and `b_id`.
- * @returns An object containing the field details, current soil data, soil parameter descriptions, and soil analyses.
+ * @returns An object containing the calendar, field details, soil parameter descriptions, and soil analyses.
*
* @throws {Response} If the farm ID is missing (HTTP 400).
* @throws {Error} If the field ID is missing (HTTP 400).
@@ -94,7 +94,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
// Get soil parameter descriptions and filter on the available soil parameters
const soilParameterDescription = getSoilParametersDescription().filter(
- (item) => soilAnalysis[item.parameter],
+ (item) =>
+ typeof soilAnalysis[item.parameter] !== "undefined" &&
+ soilAnalysis[item.parameter] !== null,
)
// Return user information from loader
@@ -125,7 +127,7 @@ export default function FarmFieldSoilOverviewBlock() {
Bodem
- Bekijk en bewerk de gegevens van deze bodemanalyse
+ Bekijk de gegevens van deze bodemanalyse
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 80596f6c8..90b6178e8 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -360,6 +360,7 @@ export default function FarmAtlasFieldSoilBlock() {
id={heatmapStrokeLayerId}
type="line"
paint={{ "line-color": "#ffffff", "line-width": 1.5 }}
+ layout={layerLayout}
/>
)}
From dec3d0a566f66905b28f28ae30ea6566b02a9a16 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 09:36:36 +0200
Subject: [PATCH 13/27] Handle undefined date
---
fdm-app/app/components/blocks/soil/list.tsx | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/fdm-app/app/components/blocks/soil/list.tsx b/fdm-app/app/components/blocks/soil/list.tsx
index 6075a3f27..84458d0e4 100644
--- a/fdm-app/app/components/blocks/soil/list.tsx
+++ b/fdm-app/app/components/blocks/soil/list.tsx
@@ -50,13 +50,15 @@ export function SoilAnalysesList({
{analysis.a_source === "nl-other-nmi"
? "Geschat met NMI BodemSchat"
- : format(
- analysis.b_sampling_date,
- "PP",
- {
- locale: nl,
- },
- )}
+ : analysis.b_sampling_date
+ ? format(
+ analysis.b_sampling_date,
+ "PP",
+ {
+ locale: nl,
+ },
+ )
+ : "Onbekende datum"}
{analysis.a_source === "nl-other-nmi"
From d32e34545a677ba9dd044f87fffc31b89a449d8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 10:12:23 +0200
Subject: [PATCH 14/27] Polish up some parts
---
.../components/blocks/atlas/atlas-legend.tsx | 14 ++++++-----
...m.$calendar.atlas.soil-analysis._index.tsx | 4 ++--
fdm-app/app/store/selected-soil-parameter.ts | 23 ++++++++++++++++++-
3 files changed, 32 insertions(+), 9 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 9bdad6c63..3e6e49ebf 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -187,12 +187,14 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
selectedParameter={selectedParameter}
/>
)}
- {fieldsData && !anyDataAvailable && (
-
-
- Geen data op hele bedrijf
-
- )}
+ {fieldsData &&
+ fieldsData.features.length > 0 &&
+ !anyDataAvailable && (
+
+
+ Geen data op hele bedrijf
+
+ )}
)
}
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 90b6178e8..89c3f9062 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -46,11 +46,11 @@ import { useSelectedAtlasSoilParameterStore } from "~/store/selected-soil-parame
export const meta: MetaFunction = () => {
return [
- { title: `Percelen - Atlas | ${clientConfig.name}` },
+ { title: `Bodemanalyses - Atlas | ${clientConfig.name}` },
{
name: "description",
content:
- "Bekijk alle percelen van uw bedrijf op één interactieve kaart. Visualiseer de geografische spreiding en onderlinge relaties tussen uw percelen.",
+ "Bekijk alle percelen van uw bedrijf op één interactieve kaart en vergelijk bodemanalyses ruimtelijk per perceel.",
},
]
}
diff --git a/fdm-app/app/store/selected-soil-parameter.ts b/fdm-app/app/store/selected-soil-parameter.ts
index 319281f3d..adabb514f 100644
--- a/fdm-app/app/store/selected-soil-parameter.ts
+++ b/fdm-app/app/store/selected-soil-parameter.ts
@@ -1,6 +1,9 @@
import { create } from "zustand"
import { createJSONStorage, persist } from "zustand/middleware"
-import type { ShadedSoilParameters } from "~/components/blocks/atlas/atlas-soil-analysis"
+import {
+ getShadedSoilParameters,
+ type ShadedSoilParameters,
+} from "~/components/blocks/atlas/atlas-soil-analysis"
import { ssrSafeSessionJSONStorage } from "./storage"
interface SelectedAtlasSoilParameterState {
@@ -19,6 +22,24 @@ export const useSelectedAtlasSoilParameterStore =
{
name: "selected-atlas-soil-parameter-storage",
storage: createJSONStorage(() => ssrSafeSessionJSONStorage),
+ onRehydrateStorage: () => {
+ // Returned function will be called after rehydration
+ return (state, error) => {
+ if (error) {
+ console.warn(error)
+ }
+ // If the stored soil parameter is invalid, set default
+ if (
+ state &&
+ !getShadedSoilParameters().some(
+ (item) =>
+ item.parameter === state.selectedParameter,
+ )
+ ) {
+ state.setSelectedParameter("a_som_loi")
+ }
+ }
+ },
},
),
)
From e71c4557a98cada751269aa39a7fd71443e8e16a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 12:07:50 +0200
Subject: [PATCH 15/27] Nitpicks
---
fdm-app/app/components/blocks/atlas/atlas-legend.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 3e6e49ebf..01720c9d4 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -135,6 +135,13 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
getShadedSoilParameters().map((item) => [item.parameter, item]),
)
+ if (!shadingConfig[selectedParameter]) {
+ console.warn(
+ `${selectedParameter} not found in shaded soil parameters.`,
+ )
+ return null
+ }
+
// Parameter description
const soilParameterOptions = soilParametersDescriptions.filter(
(item) => item.parameter in shadingConfig,
@@ -248,8 +255,8 @@ function GradientSoilAnalysisLegend(
]
const parameterMapper = getShadingParameterMapper(props.selectedParameter)
- let min = props.min as number
- let max = props.max as number
+ let min = props.min ?? 0
+ let max = props.max ?? 1
if (typeof gradDef.center === "number") {
const radius = Math.max(max - gradDef.center, gradDef.center - min)
min = gradDef.center - radius
From 66a2ef5f7edab6ac7cd730760ad7bcbb996fdf21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 13:06:03 +0200
Subject: [PATCH 16/27] Fix bad parameter behavior and the dropdown CSS height
in the SoilAnalysisLegend
---
fdm-app/app/components/blocks/atlas/atlas-legend.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 01720c9d4..46af6fad1 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -139,7 +139,6 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
console.warn(
`${selectedParameter} not found in shaded soil parameters.`,
)
- return null
}
// Parameter description
@@ -166,7 +165,8 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
{parameterDescription?.name}
-
+ {/* var(--radix-select-content-available-height) is the recommended max-height here, however we have fallbacks in case that variable is missing. */}
+
{soilParameterOptions.map((opt) => {
return (
- {shadingConfig[selectedParameter].shading === "enum" ? (
+ {!shadingConfig[selectedParameter] ? null : shadingConfig[
+ selectedParameter
+ ].shading === "enum" ? (
) : (
Date: Thu, 7 May 2026 16:35:31 +0200
Subject: [PATCH 17/27] Improve navigation
---
.../app/components/blocks/header/field.tsx | 9 +-
...tlas_.soil-analysis.$b_id.soil._index.tsx} | 25 ++---
...il-analysis.$b_id.soil.analysis.$a_id.tsx} | 0
...m.$calendar.atlas_.soil-analysis.$b_id.tsx | 99 +++++++++++++++++++
4 files changed, 114 insertions(+), 19 deletions(-)
rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx => farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx} (86%)
rename fdm-app/app/routes/{farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx => farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx} (100%)
create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.tsx
diff --git a/fdm-app/app/components/blocks/header/field.tsx b/fdm-app/app/components/blocks/header/field.tsx
index f69512842..43119dece 100644
--- a/fdm-app/app/components/blocks/header/field.tsx
+++ b/fdm-app/app/components/blocks/header/field.tsx
@@ -1,5 +1,6 @@
import { ChevronDown } from "lucide-react"
import { NavLink, useLocation } from "react-router"
+import { cn } from "@/app/lib/utils"
import { useCalendarStore } from "@/app/store/calendar"
import {
BreadcrumbItem,
@@ -17,10 +18,12 @@ export function HeaderField({
b_id_farm,
b_id,
fieldOptions,
+ compact,
}: {
b_id_farm: string
b_id: string | undefined
fieldOptions: HeaderFieldOption[]
+ compact?: boolean
}) {
const location = useLocation()
const currentPath = String(location.pathname)
@@ -29,14 +32,16 @@ export function HeaderField({
return (
<>
-
+
Perceel
{fieldOptions.length > 0 ? (
<>
-
+
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
similarity index 86%
rename from fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
rename to fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
index 5fcf506f8..eea7afe9f 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
@@ -110,23 +110,14 @@ export default function FarmFieldSoilOverviewBlock() {
return (
-
-
-
- Bodem - {loaderData.field.b_name}
-
-
- In de gegevens hieronder vind je de meest recente
- waarde gemeten voor elke bodemparameter
-
-
-
-
-
- Terug naar kaart
-
-
-
+
+
+ Bodem - {loaderData.field.b_name}
+
+
+ In de gegevens hieronder vind je de meest recente waarde
+ gemeten voor elke bodemparameter
+
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx
similarity index 100%
rename from fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis.$b_id.soil.analysis.$a_id.tsx
rename to fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil.analysis.$a_id.tsx
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.tsx
new file mode 100644
index 000000000..73cbf0307
--- /dev/null
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.tsx
@@ -0,0 +1,99 @@
+import { getFarms, getFields } from "@nmi-agro/fdm-core"
+import { MapIcon } from "lucide-react"
+import { NavLink, Outlet, useLoaderData } from "react-router"
+import { HeaderAtlas } from "~/components/blocks/header/atlas"
+import { Header } from "~/components/blocks/header/base"
+import { HeaderFarm } from "~/components/blocks/header/farm"
+import { HeaderField } from "~/components/blocks/header/field"
+import { Button } from "~/components/ui/button"
+import { SidebarInset } from "~/components/ui/sidebar"
+import { getSession } from "~/lib/auth.server"
+import { getTimeframe } from "~/lib/calendar"
+import { handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+import type { Route } from "./+types/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id"
+
+export async function loader({ params, request }: Route.LoaderArgs) {
+ try {
+ const session = await getSession(request)
+ const { b_id_farm, calendar, b_id } = params
+ const timeframe = getTimeframe(params)
+
+ // Get the fields to be selected
+ const fields = await getFields(
+ fdm,
+ session.principal_id,
+ b_id_farm,
+ timeframe,
+ )
+ if (!fields.some((field) => field.b_id === b_id)) {
+ throw new Error(`Field ${b_id} does not belong to this farm.`)
+ }
+ const fieldOptions = fields.map((field) => {
+ if (!field?.b_id || !field?.b_name) {
+ throw new Error("Invalid field data structure")
+ }
+ return {
+ b_id: field.b_id,
+ b_name: field.b_name,
+ b_area: Math.round((field.b_area ?? 0) * 10) / 10,
+ }
+ })
+
+ // Get a list of possible farms of the user
+ const farms = await getFarms(fdm, session.principal_id)
+
+ const farmOptions = farms.map((farm) => {
+ return {
+ b_id_farm: farm.b_id_farm,
+ b_name_farm: farm.b_name_farm,
+ }
+ })
+
+ return {
+ b_id_farm: b_id_farm,
+ b_id: b_id,
+ calendar: calendar,
+ farmOptions: farmOptions,
+ fieldOptions: fieldOptions,
+ }
+ } catch (e) {
+ throw handleLoaderError(e)
+ }
+}
+
+export default function AtlasSoilAnalysisFieldDetailLayout() {
+ const { b_id_farm, calendar, b_id, farmOptions, fieldOptions } =
+ useLoaderData()
+ return (
+
+
+
+
+
+
+
+
+ Terug naar kaart
+
+
+
+
+
+ )
+}
From 182bed4bc22fa9ee4ec266e955204e1d069f7749 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 16:57:09 +0200
Subject: [PATCH 18/27] Make the gradient legend vertical
---
.../components/blocks/atlas/atlas-legend.tsx | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 46af6fad1..94d473e9e 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -155,14 +155,14 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
)
return (
-
+
setSelectedParameter(val as ShadedSoilParameters)
}
>
-
+
{parameterDescription?.name}
{/* var(--radix-select-content-available-height) is the recommended max-height here, however we have fallbacks in case that variable is missing. */}
@@ -283,23 +283,26 @@ function GradientSoilAnalysisLegend(
return (
-
+
{gradientSvg}
-
+
(
@@ -307,7 +310,6 @@ function GradientSoilAnalysisLegend(
).toString()
}
/>
-
Date: Thu, 7 May 2026 17:11:09 +0200
Subject: [PATCH 19/27] Handle missing gradient definition
---
fdm-app/app/components/blocks/atlas/atlas-legend.tsx | 12 ++++++++++--
...tlas-soil-analysis.tsx => atlas-soil-analysis.ts} | 2 +-
2 files changed, 11 insertions(+), 3 deletions(-)
rename fdm-app/app/components/blocks/atlas/{atlas-soil-analysis.tsx => atlas-soil-analysis.ts} (99%)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 94d473e9e..73a959bc4 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -249,12 +249,22 @@ function GradientSoilAnalysisLegend(
selectedParameter: ShadedSoilParameters
},
) {
+ const gradientId = useId()
+
const gradDef =
GRADIENT_DEFINITIONS[
GRADIENT_SHADED_SOIL_PARAMETERS[
props.selectedParameter as keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
]
]
+
+ if (!gradDef) {
+ console.warn(
+ `No gradient definition found for parameter: ${props.selectedParameter}`,
+ )
+ return null
+ }
+
const parameterMapper = getShadingParameterMapper(props.selectedParameter)
let min = props.min ?? 0
@@ -268,8 +278,6 @@ function GradientSoilAnalysisLegend(
const chartData = [{ name: "Legenda", min: min, max: max }]
const gradient = gradDef.gradient
- const gradientId = useId()
-
const gradientSvg: React.ReactNode[] = []
for (let i = 0; i < gradient.length; i += 2) {
gradientSvg.push(
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
similarity index 99%
rename from fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
rename to fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index ac782085d..915b6a115 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -346,7 +346,7 @@ const DEFAULT_PARAMETER_MAPPER: ValueMapper = {
return x
},
}
-/**Gets the forward and inverse mappings if a different mapping than linear is used for chromatic shading
+/**Gets the forward and inverse mappings if a different mapping than linear is used for gradient shading
*
* @param parameter parameter to get the mappings for
* @returns object containing mapping functions
From a809a40388b34730815b6c8c3761bbd8754ebdea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Thu, 7 May 2026 17:31:15 +0200
Subject: [PATCH 20/27] Resolve CodeQL finding
---
...id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
index eea7afe9f..e22c25ff2 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas_.soil-analysis.$b_id.soil._index.tsx
@@ -7,13 +7,11 @@ import {
import {
data,
type LoaderFunctionArgs,
- NavLink,
useFetcher,
useLoaderData,
} from "react-router"
import { SoilDataCards } from "~/components/blocks/soil/cards"
import { SoilAnalysesList } from "~/components/blocks/soil/list"
-import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import { getSession } from "~/lib/auth.server"
From f7365de880f61024190bdbee573126531e41c9ab Mon Sep 17 00:00:00 2001
From: SvenVw <37927107+SvenVw@users.noreply.github.com>
Date: Wed, 13 May 2026 12:22:21 +0200
Subject: [PATCH 21/27] refactor: update colors for agricultural soil types
---
.../blocks/atlas/atlas-soil-analysis.ts | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index 915b6a115..6d764bc80 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -101,15 +101,15 @@ const COLORBREWER_PUBUGN = evenlySpaced(
)
export const SHADED_SOIL_TYPES = [
- { value: "moerige_klei", label: "Moerige klei", fill: "rgb(45, 0, 168)" },
- { value: "rivierklei", label: "Rivierklei", fill: "rgb(112, 194, 0)" },
- { value: "dekzand", label: "Dekzand", fill: "rgb(244, 244, 70)" },
- { value: "zeeklei", label: "Zeeklei", fill: "rgb(6, 158, 200)" },
- { value: "dalgrond", label: "Dalgrond", fill: "rgb(195, 195, 195)" },
- { value: "veen", label: "Veen", fill: "rgb(223, 115, 255)" },
- { value: "loess", label: "Löss", fill: "rgb(255, 255, 255)" },
- { value: "duinzand", label: "Duinzand", fill: "rgb(255, 100, 0)" },
- { value: "maasklei", label: "Maasklei", fill: "rgb(152, 158, 0)" },
+ { value: "moerige_klei", label: "Moerige klei", fill: "#D37FD0" },
+ { value: "rivierklei", label: "Rivierklei", fill: "#81FE00" },
+ { value: "dekzand", label: "Dekzand", fill: "#FFF99" },
+ { value: "zeeklei", label: "Zeeklei", fill: "#32AA00" },
+ { value: "dalgrond", label: "Dalgrond", fill: "#D37FD0" },
+ { value: "veen", label: "Veen", fill: "#6A1EB5" },
+ { value: "loess", label: "Löss", fill: "#AA2049" },
+ { value: "duinzand", label: "Duinzand", fill: "#FFDD71" },
+ { value: "maasklei", label: "Maasklei", fill: "#FED31E" },
]
/** Which gradient definition to use for gradient-shaded parameters.
From 3e2fa07367d494a3795248050834fd173a9b8d39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Wed, 13 May 2026 16:46:23 +0200
Subject: [PATCH 22/27] Change gradient shading colors
---
.../blocks/atlas/atlas-soil-analysis.ts | 107 +++---------------
1 file changed, 18 insertions(+), 89 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index 6d764bc80..8cbeb064f 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -7,35 +7,6 @@ function evenlySpaced(...args: string[]) {
return args.flatMap((item, i) => [i / (args.length - 1), item])
}
-const COLORBREWER_ORANGES = evenlySpaced(
- "#fff5eb",
- "#fee6ce",
- "#fdd0a2",
- "#fdae6b",
- "#fd8d3c",
- "#f16913",
- "#d94801",
- "#a63603",
- "#7f2704",
-)
-const COLORBREWER_BLUES = evenlySpaced(
- "#f7fbff",
- "#deebf7",
- "#c6dbef",
- "#9ecae1",
- "#6baed6",
- "#4292c6",
- "#2171b5",
- "#08519c",
- "#08306b",
-)
-const COLORBREWER_GREYS = evenlySpaced(
- "#f7f7f7",
- "#cccccc",
- "#969696",
- "#636363",
- "#252525",
-)
const COLORBREWER_YLORBR = evenlySpaced(
"#ffffe5",
"#fff7bc",
@@ -46,28 +17,7 @@ const COLORBREWER_YLORBR = evenlySpaced(
"#cc4c02",
"#8c2d04",
)
-const COLORBREWER_BUGN = evenlySpaced(
- "#f7fcfd",
- "#e5f5f9",
- "#ccece6",
- "#99d8c9",
- "#66c2a4",
- "#41ae76",
- "#238b45",
- "#006d2c",
- "#00441b",
-)
-const COLORBREWER_GNBU = evenlySpaced(
- "#f7fcf0",
- "#e0f3db",
- "#ccebc5",
- "#a8ddb5",
- "#7bccc4",
- "#4eb3d3",
- "#2b8cbe",
- "#08589e",
-)
const COLORBREWER_RDBU = evenlySpaced(
"#b2182b",
"#d6604d",
@@ -78,27 +28,6 @@ const COLORBREWER_RDBU = evenlySpaced(
"#4393c3",
"#2166ac",
)
-const COLORBREWER_RDPU = evenlySpaced(
- "#fff7f3",
- "#fde0dd",
- "#fcc5c0",
- "#fa9fb5",
- "#f768a1",
- "#dd3497",
- "#ae017e",
- "#7a0177",
-)
-const COLORBREWER_PUBUGN = evenlySpaced(
- "#fff7fb",
- "#ece2f0",
- "#d0d1e6",
- "#a6bddb",
- "#67a9cf",
- "#3690c0",
- "#02818a",
- "#016c59",
- "#014636",
-)
export const SHADED_SOIL_TYPES = [
{ value: "moerige_klei", label: "Moerige klei", fill: "#D37FD0" },
@@ -161,24 +90,24 @@ export const GRADIENT_DEFINITIONS: {
center?: number
}
} = {
- aluminum: { gradient: COLORBREWER_GREYS },
- bacterium: { gradient: COLORBREWER_GNBU },
- calcium: { gradient: COLORBREWER_BUGN },
- carbon: { gradient: COLORBREWER_GREYS },
- carbon_ratio: { gradient: COLORBREWER_GREYS },
- copper: { gradient: COLORBREWER_BLUES },
+ aluminum: { gradient: COLORBREWER_YLORBR },
+ bacterium: { gradient: COLORBREWER_YLORBR },
+ calcium: { gradient: COLORBREWER_YLORBR },
+ carbon: { gradient: COLORBREWER_YLORBR },
+ carbon_ratio: { gradient: COLORBREWER_YLORBR },
+ copper: { gradient: COLORBREWER_YLORBR },
earth_heavy: { gradient: COLORBREWER_YLORBR },
- earth_light: { gradient: COLORBREWER_ORANGES },
- nitrogen: { gradient: COLORBREWER_BLUES },
- iron: { gradient: COLORBREWER_ORANGES },
- magnesium: { gradient: COLORBREWER_PUBUGN },
- phosphorus: { gradient: COLORBREWER_RDPU },
- potassium: { gradient: COLORBREWER_RDPU },
+ earth_light: { gradient: COLORBREWER_YLORBR },
+ nitrogen: { gradient: COLORBREWER_YLORBR },
+ iron: { gradient: COLORBREWER_YLORBR },
+ magnesium: { gradient: COLORBREWER_YLORBR },
+ phosphorus: { gradient: COLORBREWER_YLORBR },
+ potassium: { gradient: COLORBREWER_YLORBR },
ph: { gradient: COLORBREWER_RDBU, center: 7 },
sand_dark: { gradient: COLORBREWER_YLORBR },
- sand_light: { gradient: COLORBREWER_ORANGES },
+ sand_light: { gradient: COLORBREWER_YLORBR },
sulfur: { gradient: COLORBREWER_YLORBR },
- zinc: { gradient: COLORBREWER_GREYS },
+ zinc: { gradient: COLORBREWER_YLORBR },
}
const ENUM_SHADED_SOIL_PARAMETERS = {
@@ -235,7 +164,7 @@ export function getSoilAnalysisLayerStyle(
parameter as GradientShadedSoilParameters
]
const fillColor = GRADIENT_DEFINITIONS[gradientName]
- function transparentIfUndefined(
+ function greyIfUndefined(
expr: ExpressionSpecification,
): ["match", ...unknown[]] {
return [
@@ -245,7 +174,7 @@ export function getSoilAnalysisLayerStyle(
expr,
"string",
expr,
- "transparent",
+ "#777777",
]
}
if (typeof fillColor.center !== "undefined") {
@@ -259,7 +188,7 @@ export function getSoilAnalysisLayerStyle(
return {
type: "fill",
paint: {
- "fill-color": transparentIfUndefined([
+ "fill-color": greyIfUndefined([
"interpolate",
["linear"],
dataGetter,
@@ -276,7 +205,7 @@ export function getSoilAnalysisLayerStyle(
return {
type: "fill",
paint: {
- "fill-color": transparentIfUndefined([
+ "fill-color": greyIfUndefined([
"interpolate",
["linear"],
dataGetter,
From a5c8f0500d9271a6845d536cbf259b00efc70c49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Fri, 15 May 2026 10:07:16 +0200
Subject: [PATCH 23/27] Unify gradient stops logic
---
.../components/blocks/atlas/atlas-legend.tsx | 35 ++++----
.../blocks/atlas/atlas-soil-analysis.ts | 88 +++++++++++++------
2 files changed, 75 insertions(+), 48 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 73a959bc4..5fe0cbcd9 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -22,6 +22,7 @@ import { Spinner } from "~/components/ui/spinner"
import {
GRADIENT_DEFINITIONS,
GRADIENT_SHADED_SOIL_PARAMETERS,
+ getGradientStops,
getShadedSoilParameters,
getShadingParameterMapper,
SHADED_SOIL_TYPES,
@@ -267,27 +268,17 @@ function GradientSoilAnalysisLegend(
const parameterMapper = getShadingParameterMapper(props.selectedParameter)
- let min = props.min ?? 0
- let max = props.max ?? 1
- if (typeof gradDef.center === "number") {
- const radius = Math.max(max - gradDef.center, gradDef.center - min)
- min = gradDef.center - radius
- max = gradDef.center + radius
- }
+ const min = props.min ?? 0
+ const max = props.max ?? 1
const chartData = [{ name: "Legenda", min: min, max: max }]
- const gradient = gradDef.gradient
+ const gradient = getGradientStops(
+ gradDef.gradient,
+ min,
+ max,
+ gradDef.center,
+ )
- const gradientSvg: React.ReactNode[] = []
- for (let i = 0; i < gradient.length; i += 2) {
- gradientSvg.push(
- ,
- )
- }
return (
- {gradientSvg}
+ {gradient.map((stop) => (
+
+ ))}
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index 8cbeb064f..0cab67b0b 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -135,6 +135,59 @@ export function getShadedSoilParameters() {
] as { parameter: ShadedSoilParameters; shading: "gradient" | "enum" }[]
}
+export function getGradientStops(
+ gradient: (string | number)[],
+ min: number,
+ max: number,
+ center: number | undefined,
+) {
+ let fromMin = min
+ let fromMax = max
+ let toMin = 0
+ let toMax = 1
+
+ if (typeof center !== "undefined") {
+ if (min <= center && max <= center) {
+ toMax = 0.5
+ }
+
+ if (min >= center && max >= center) {
+ toMin = 0.5
+ }
+
+ if (min <= center && max >= center) {
+ const radius = Math.max(max - center, center - min)
+ fromMin = center - radius
+ toMin = center + radius
+ }
+ }
+
+ if (Math.abs(fromMax - fromMin) < 0.001) {
+ fromMax = fromMin + 0.001
+ }
+
+ if (Math.abs(toMax - toMin) < 0.001) {
+ toMax = toMin + 0.001
+ }
+
+ const stops: { normalPosition: number; position: number; color: string }[] =
+ []
+
+ for (let i = 0; i < gradient.length - 1; i += 2) {
+ const originalPos = gradient[i] as number
+ const originalCol = gradient[i + 1] as string
+
+ const t = (originalPos - toMin) / (toMax - toMin)
+ stops.push({
+ normalPosition: t,
+ position: fromMin + t * (fromMax - fromMin),
+ color: originalCol,
+ })
+ }
+
+ return stops
+}
+
export function getSoilAnalysisLayerStyle(
parameter: ShadedSoilParameters,
min: number,
@@ -177,30 +230,6 @@ export function getSoilAnalysisLayerStyle(
"#777777",
]
}
- if (typeof fillColor.center !== "undefined") {
- // Cover as much range as needed but still keep the center (for example for pH display, where 7 is the center)
- const radius = Math.max(
- max - fillColor.center,
- fillColor.center - min,
- )
- const newMin = fillColor.center - radius
- const newMax = fillColor.center + radius
- return {
- type: "fill",
- paint: {
- "fill-color": greyIfUndefined([
- "interpolate",
- ["linear"],
- dataGetter,
- ...fillColor.gradient.map((item) =>
- typeof item === "string"
- ? item
- : (newMax - newMin) * item + newMin,
- ),
- ]),
- },
- }
- }
return {
type: "fill",
@@ -209,11 +238,12 @@ export function getSoilAnalysisLayerStyle(
"interpolate",
["linear"],
dataGetter,
- ...fillColor.gradient.map((item) =>
- typeof item === "string"
- ? item
- : (max - min) * item + min,
- ),
+ ...getGradientStops(
+ fillColor.gradient,
+ min,
+ max,
+ fillColor.center,
+ ).flatMap((stop) => [stop.position, stop.color]),
]),
},
}
From fd97665739a8eb64e71c56dfcf7906316e10f463 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Fri, 15 May 2026 12:41:57 +0200
Subject: [PATCH 24/27] Change layout of soil analysis atlas controls
---
.../components/blocks/atlas/atlas-legend.tsx | 136 +++-----
.../blocks/atlas/atlas-soil-analysis.ts | 2 +-
...m.$calendar.atlas.soil-analysis._index.tsx | 321 ++++++++++++------
3 files changed, 274 insertions(+), 185 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 5fe0cbcd9..d56b83dfd 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -10,14 +10,8 @@ import {
XAxis,
YAxis,
} from "recharts"
-import { Card, CardContent } from "~/components/ui/card"
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import { ChartContainer } from "~/components/ui/chart"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
-} from "~/components/ui/select"
import { Spinner } from "~/components/ui/spinner"
import {
GRADIENT_DEFINITIONS,
@@ -117,19 +111,13 @@ export function ElevationLegend({
interface SoilAnalysisLegendProps {
fieldsData?: FeatureCollection
selectedParameter: ShadedSoilParameters
- setSelectedParameter: (parameter: ShadedSoilParameters) => void
soilParametersDescriptions: SoilParameterDescription
min?: number
max?: number
}
export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
- const {
- fieldsData,
- selectedParameter,
- setSelectedParameter,
- soilParametersDescriptions,
- } = props
+ const { fieldsData, selectedParameter } = props
// Parameter shading config
const shadingConfig = Object.fromEntries(
@@ -142,69 +130,50 @@ export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) {
)
}
- // Parameter description
- const soilParameterOptions = soilParametersDescriptions.filter(
- (item) => item.parameter in shadingConfig,
- )
- const parameterDescription = soilParametersDescriptions.find(
- (opt) => opt.parameter === selectedParameter,
- )
-
const anyDataAvailable = fieldsData?.features.some(
(feature) =>
feature.properties && selectedParameter in feature.properties,
)
+ const parameterDescription = props.soilParametersDescriptions.find(
+ (item) => item.parameter === props.selectedParameter,
+ )
+
+ const unitDisplay =
+ parameterDescription?.unit && parameterDescription.unit !== "-"
+ ? ` (${parameterDescription.unit})`
+ : ""
+ const title = parameterDescription
+ ? `${parameterDescription.name}${unitDisplay}`
+ : undefined
+
return (
-
-
- setSelectedParameter(val as ShadedSoilParameters)
- }
- >
-
- {parameterDescription?.name}
-
- {/* var(--radix-select-content-available-height) is the recommended max-height here, however we have fallbacks in case that variable is missing. */}
-
- {soilParameterOptions.map((opt) => {
- return (
-
-
-
- {opt.name}
-
-
- {opt.description}
-
-
-
- )
- })}
-
-
- {!shadingConfig[selectedParameter] ? null : shadingConfig[
- selectedParameter
- ].shading === "enum" ? (
-
- ) : (
-
- )}
- {fieldsData &&
- fieldsData.features.length > 0 &&
- !anyDataAvailable && (
-
-
- Geen data op hele bedrijf
-
+
+
+
+ {title}
+
+
+
+ {!shadingConfig[selectedParameter] ? null : shadingConfig[
+ selectedParameter
+ ].shading === "enum" ? (
+
+ ) : (
+
)}
+ {fieldsData &&
+ fieldsData.features.length > 0 &&
+ !anyDataAvailable && (
+
+
+ Geen data op hele bedrijf
+
+ )}
+
)
}
@@ -233,11 +202,13 @@ function EnumSoilAnalysisLegend(props: SoilAnalysisLegendProps) {
- {opt.label}
+
+ {opt.label}
+
))}
@@ -282,18 +253,23 @@ function GradientSoilAnalysisLegend(
return (
-
+
{gradient.map((stop) => (
-
-
+
[item.parameter, item]),
+ )
+
+ // Parameter description
+ const soilParameterOptions = soilParametersDescriptions.filter(
+ (item) => item.parameter in shadingConfig,
+ )
+
const parameterDescription = soilParametersDescriptions.find(
(item) => item.parameter === selectedParameter,
)
@@ -233,6 +250,12 @@ export default function FarmAtlasFieldSoilBlock() {
})
const [showFields, setShowFields] = useState(true)
+ type HoverInfo = {
+ x: number
+ y: number
+ feature: GeoJSONFeature
+ }
+ const [hoverInfo, setHoverInfo] = useState(null)
const onViewportChange = useCallback((event: ViewStateChangeEvent) => {
setViewState(event.viewState)
@@ -255,132 +278,222 @@ export default function FarmAtlasFieldSoilBlock() {
const layerLayout = { visibility: showFields ? "visible" : "none" } as const
- const renderHoverPanel = useCallback(
- (feature: GeoJSONFeature) => {
- const value = feature.properties[selectedParameter]
- return (
-
-
-
- {feature.properties.b_name}
-
- {feature.properties.b_area} ha
-
-
+ const onMouseMove = useCallback((e: MapMouseEvent) => {
+ const feature = e.features?.[0]
+ if (feature) {
+ setHoverInfo({
+ x: e.point.x,
+ y: e.point.y,
+ feature: feature,
+ })
+ } else {
+ setHoverInfo(null)
+ }
+ }, [])
+
+ const onMouseLeave = useCallback(() => setHoverInfo(null), [])
+
+ return (
+
+
+
+ setViewState((currentViewState) => ({
+ ...currentViewState,
+ longitude,
+ latitude,
+ zoom,
+ pitch: currentViewState.pitch, // Ensure pitch is carried over
+ bearing: currentViewState.bearing, // Ensure bearing is carried over
+ }))
+ }
+ showFields={showFields}
+ onToggleFields={() => setShowFields(!showFields)}
+ showFlyToFields={
+ fieldsData && fieldsData.features.length > 0
+ ? true
+ : undefined
+ }
+ onFlyToFields={() => {
+ setViewState({ ...initialViewState })
+ if (initialViewState.bounds) {
+ mapRef.current?.fitBounds(
+ initialViewState.bounds,
+ initialViewState.fitBoundsOptions,
+ )
+ }
+ }}
+ />
+
+
+
+ {fieldsData && (
+ {
+ navigate(
+ `/farm/${b_id_farm}/${calendar}/atlas/soil-analysis/${feature.properties.b_id}/soil`,
+ )
+ }}
+ >
+
+
+
+ )}
+
+ {/* Soil Parameter Dropdown */}
+
+
+
+ setSelectedParameter(val as ShadedSoilParameters)
+ }
+ >
+
+ {parameterDescription?.name}
+
+ {/* var(--radix-select-content-available-height) is the recommended max-height here, however we have fallbacks in case that variable is missing. */}
+
+ {soilParameterOptions.map((opt) => {
+ return (
+
+
+
+ {opt.name}
+
+
+ {opt.description}
+
+
+
+ )
+ })}
+
+
+
+
+ {/* Hover tooltip */}
+ {hoverInfo && (
+
+
+
+ {hoverInfo.feature.properties.b_name}
+
+ {hoverInfo.feature.properties.b_area != null && (
+
+ {Number(
+ hoverInfo.feature.properties.b_area,
+ ).toFixed(2)}{" "}
+ ha
+
+ )}
-
+
{parameterDescription?.name}
- {typeof value === "undefined" ? (
+ {typeof hoverInfo.feature.properties[
+ selectedParameter
+ ] === "undefined" ? (
Geen data
) : parameterDescription?.type === "date" ? (
- {formatDate(value, "PP", {
- locale: nl,
- })}
+ {formatDate(
+ typeof hoverInfo.feature.properties[
+ selectedParameter
+ ],
+ "PP",
+ {
+ locale: nl,
+ },
+ )}
) : selectedParameter === "b_soiltype_agr" ? (
+
+ item.value ===
+ hoverInfo.feature
+ .properties[
+ selectedParameter
+ ],
+ )?.fill ?? "#777777",
+ }}
+ />
{SHADED_SOIL_TYPES.find(
- (item) => item.value === value,
- )?.label ?? value}
+ (item) =>
+ item.value ===
+ hoverInfo.feature.properties[
+ selectedParameter
+ ],
+ )?.label ??
+ hoverInfo.feature.properties[
+ selectedParameter
+ ]}
) : (
- {value} {parameterDescription?.unit}
+ {
+ hoverInfo.feature.properties[
+ selectedParameter
+ ]
+ }{" "}
+ {parameterDescription?.unit}
)}
- )
- },
- [selectedParameter, parameterDescription],
- )
-
- return (
-
-
- setViewState((currentViewState) => ({
- ...currentViewState,
- longitude,
- latitude,
- zoom,
- pitch: currentViewState.pitch, // Ensure pitch is carried over
- bearing: currentViewState.bearing, // Ensure bearing is carried over
- }))
- }
- showFields={showFields}
- onToggleFields={() => setShowFields(!showFields)}
- showFlyToFields={
- fieldsData && fieldsData.features.length > 0
- ? true
- : undefined
- }
- onFlyToFields={() => {
- setViewState({ ...initialViewState })
- if (initialViewState.bounds) {
- mapRef.current?.fitBounds(
- initialViewState.bounds,
- initialViewState.fitBoundsOptions,
- )
- }
- }}
- />
-
-
-
- {fieldsData && (
- {
- navigate(
- `/farm/${b_id_farm}/${calendar}/atlas/soil-analysis/${feature.properties.b_id}/soil`,
- )
- }}
- >
-
-
-
)}
-
-
+ {/* Soil Analysis Color Legend */}
+
-
-
+
)
}
From 474062cda52d5ddccc4bfdae649b2d151742086e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Fri, 15 May 2026 12:43:37 +0200
Subject: [PATCH 25/27] Fix gradient center symmetric range issue
---
fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index 8d429a6c9..866f1afa4 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -158,7 +158,7 @@ export function getGradientStops(
if (min <= center && max >= center) {
const radius = Math.max(max - center, center - min)
fromMin = center - radius
- toMin = center + radius
+ fromMax = center + radius
}
}
From cb27d128b32dd49caa45162037485a8379f51a70 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Fri, 15 May 2026 14:05:11 +0200
Subject: [PATCH 26/27] Clean-up
---
.../components/blocks/atlas/atlas-legend.tsx | 3 +--
.../components/blocks/atlas/atlas-panels.tsx | 17 +++--------------
.../components/blocks/atlas/atlas-styles.tsx | 10 ++++++++++
...arm.$calendar.atlas.soil-analysis._index.tsx | 15 +++++++--------
4 files changed, 21 insertions(+), 24 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index d56b83dfd..20398b7b3 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -254,7 +254,7 @@ function GradientSoilAnalysisLegend(
(
diff --git a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
index 68106c0fb..29b542ced 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx
@@ -1,12 +1,8 @@
import type { FeatureCollection } from "geojson"
import throttle from "lodash.throttle"
import { Check, ChevronDown, ChevronUp, Info } from "lucide-react"
-import type {
- GeoJSONFeature,
- MapGeoJSONFeature,
- MapLibreZoomEvent,
-} from "maplibre-gl"
-import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
+import type { MapGeoJSONFeature, MapLibreZoomEvent } from "maplibre-gl"
+import { useCallback, useEffect, useRef, useState } from "react"
import type { MapLayerMouseEvent as MapMouseEvent } from "react-map-gl/maplibre"
import { useMap } from "react-map-gl/maplibre"
import { data, NavLink, useFetcher } from "react-router"
@@ -35,8 +31,6 @@ import { cn } from "~/lib/utils"
* - `zoomLevelFields` is the zoom threshold after which no panel is shown
* - `layer` is a layer ID or an array of IDs for which the panel is shown
* - `layerExclude` can be a layerId or an array of IDs which block the panel from being shown
- * - `render` can be used to render a custom panel instead of the default one.
- * It **SHOULD** be a stable reference since when it changes the event handlers on the map are reinstantiated.
* - `clickRedirectsToDetailsPage`, if set to true, causes the default panel to tell the user that clicking will navigate to a different page
* @returns the output of the render function, or a Card containing the information mentioned above.
*/
@@ -44,13 +38,11 @@ export function FieldsPanelHover({
zoomLevelFields,
layer,
layerExclude,
- render,
clickRedirectsToDetailsPage = false,
}: {
zoomLevelFields: number
layer: string[] | string
layerExclude?: string[] | string
- render?: (feature: GeoJSONFeature) => ReactNode
clickRedirectsToDetailsPage?: boolean
}) {
const { current: map } = useMap()
@@ -129,9 +121,7 @@ export function FieldsPanelHover({
? feature.properties.b_name
: feature.properties.b_lu_name
: "Naam"
- return active && render ? (
- render(feature)
- ) : (
+ return (
@@ -202,7 +192,6 @@ export function FieldsPanelHover({
zoomLevelFields,
layerIdsKey,
excludedLayerIdsKey,
- render,
clickRedirectsToDetailsPage,
])
diff --git a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
index 4cd58b7f7..a00c58168 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx
@@ -60,6 +60,16 @@ function getFieldsStyleInner(layerId: string): LayerProps {
}
}
+ if (layerId === "fieldsSavedHeatmapOutline") {
+ return {
+ type: "line",
+ paint: {
+ "line-color": "#ffffff",
+ "line-width": 1.5,
+ },
+ }
+ }
+
const baseFieldsFillColorExpr: ExpressionSpecification = [
"match",
["get", "b_lu_croprotation"],
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
index 38a598187..35966b8ee 100644
--- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
+++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.soil-analysis._index.tsx
@@ -35,6 +35,7 @@ import {
type ShadedSoilParameters,
} from "~/components/blocks/atlas/atlas-soil-analysis"
import { FieldSourceClickable } from "~/components/blocks/atlas/atlas-sources"
+import { getFieldsStyle } from "~/components/blocks/atlas/atlas-styles"
import { getViewState } from "~/components/blocks/atlas/atlas-viewstate"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import {
@@ -176,8 +177,8 @@ export default function FarmAtlasFieldSoilAnalysisBlock() {
} = useLoaderData()
const navigate = useNavigate()
- const heatmapLayerId = "fieldsSaved"
- const heatmapStrokeLayerId = "fieldsSavedStroke"
+ const heatmapLayerId = "fieldsSavedHeatmap"
+ const heatmapOutlineLayerId = "fieldsSavedHeatmapOutline"
const selectedParameter = useSelectedAtlasSoilParameterStore(
(store) => store.selectedParameter,
)
@@ -233,6 +234,8 @@ export default function FarmAtlasFieldSoilAnalysisBlock() {
min,
max,
)
+ const heatmapLayerOutlineStyle = getFieldsStyle(heatmapOutlineLayerId)
+
// ViewState logic
const initialViewState = getViewState(fieldsData)
const [viewState, setViewState] = useState(() => {
@@ -355,12 +358,8 @@ export default function FarmAtlasFieldSoilAnalysisBlock() {
layout={layerLayout}
/>
From a239d0a2385e132c1e62b7174d93dbd0269920d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?=
Date: Fri, 15 May 2026 15:14:00 +0200
Subject: [PATCH 27/27] Add documentation to the atlas-soil-analysis functions
---
.../components/blocks/atlas/atlas-legend.tsx | 4 +-
.../blocks/atlas/atlas-soil-analysis.ts | 85 ++++++++++++++++---
2 files changed, 76 insertions(+), 13 deletions(-)
diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
index 20398b7b3..ab6b95924 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
+++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx
@@ -16,11 +16,11 @@ import { Spinner } from "~/components/ui/spinner"
import {
GRADIENT_DEFINITIONS,
GRADIENT_SHADED_SOIL_PARAMETERS,
- getGradientStops,
getShadedSoilParameters,
getShadingParameterMapper,
SHADED_SOIL_TYPES,
type ShadedSoilParameters,
+ transformGradientStops,
} from "./atlas-soil-analysis"
interface ElevationLegendProps {
@@ -243,7 +243,7 @@ function GradientSoilAnalysisLegend(
const max = props.max ?? 1
const chartData = [{ name: "Legenda", min: min, max: max }]
- const gradient = getGradientStops(
+ const gradient = transformGradientStops(
gradDef.gradient,
min,
max,
diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
index 866f1afa4..e86beb891 100644
--- a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
+++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts
@@ -3,8 +3,28 @@ import type { LayerProps } from "react-map-gl"
/* ================ SHADING DEFINITIONS ================ */
+/**
+ * A gradient stop with position and color
+ */
+type GradientStop = {
+ /** Stop position such that 0 becomes the start of the gradient and 1 becomes the end */
+ normalPosition: number
+ /** normalPosition linearly-interpolated between the data min and max values. ColorBrewer gradients have this value as if the data min is 0 and the max is 1 */
+ position: number
+ /** Color at this gradient stop */
+ color: string
+}
+
+/**
+ * Collection of stops defining a gradient's colors
+ */
+type Gradient = GradientStop[]
+
function evenlySpaced(...args: string[]) {
- return args.flatMap((item, i) => [i / (args.length - 1), item])
+ return args.map((item, i) => {
+ const t = i / (args.length - 1)
+ return { position: t, normalPosition: t, color: item }
+ })
}
const COLORBREWER_YLORBR = evenlySpaced(
@@ -29,6 +49,9 @@ const COLORBREWER_RDBU = evenlySpaced(
"#2166ac",
)
+/**
+ * Value, label, and display fill color for each agricultural soiltype that is supported by fdm-core
+ */
export const SHADED_SOIL_TYPES = [
{ value: "moerige_klei", label: "Moerige klei", fill: "#D37FD0" },
{ value: "rivierklei", label: "Rivierklei", fill: "#81FE00" },
@@ -86,7 +109,7 @@ type GradientShadedSoilParameters = keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS
/** Actual gradient definitions */
export const GRADIENT_DEFINITIONS: {
[k in (typeof GRADIENT_SHADED_SOIL_PARAMETERS)[GradientShadedSoilParameters]]: {
- gradient: (string | number)[]
+ gradient: Gradient
center?: number
}
} = {
@@ -110,6 +133,7 @@ export const GRADIENT_DEFINITIONS: {
zinc: { gradient: COLORBREWER_YLORBR },
}
+/** MapLibreGL match arm definitions for each soil type that should be shaded according to enum values */
const ENUM_SHADED_SOIL_PARAMETERS = {
b_soiltype_agr: SHADED_SOIL_TYPES.flatMap(({ value, fill }) => [
value,
@@ -118,10 +142,16 @@ const ENUM_SHADED_SOIL_PARAMETERS = {
} as const
type EnumShadedSoilParameters = keyof typeof ENUM_SHADED_SOIL_PARAMETERS
+/** Soil parameters supported by the soil analysis atlas */
export type ShadedSoilParameters =
| GradientShadedSoilParameters
| EnumShadedSoilParameters
+/**
+ * Gets the list of soil parameters supported by the soil analysis atlas
+ *
+ * @returns array of strings
+ */
export function getShadedSoilParameters() {
return [
...Object.keys(GRADIENT_SHADED_SOIL_PARAMETERS).map((parameter) => ({
@@ -135,12 +165,35 @@ export function getShadedSoilParameters() {
] as { parameter: ShadedSoilParameters; shading: "gradient" | "enum" }[]
}
-export function getGradientStops(
- gradient: (string | number)[],
+/**
+ * Transforms the gradient stops such that the stops are positioned either
+ *
+ * - between the min and max value, where original 0 is mapped to min and 1 to max
+ * - 0 to min and 0.5 to max if center is specified and all values are less than the center value
+ * - 0.5 to min and 1 to max if center is specified and all values are greater than the center value
+ * - 0 to the greatest possible and 1 to the least possible value such that 0.5 is mapped to the center,
+ * if the previous two cases don't hold.
+ *
+ * This strategy ensures that there is always a big contrast between the min and max colors, but it is
+ * still possible to specify a meaningful center value.
+ *
+ * For example in the case of pH, if all data points indicate acidic soil, no blue values will be used,
+ * the most pale value in the middle of the gradient will be for the minimum acidity, and the most intense
+ * red will be for the maximum acidity.
+ *
+ * @param gradient gradient to use. It may be "clipped" and some colors might not be used, according to the strategy above.
+ * @param min minimum data value
+ * @param max maximum data value
+ * @param center optional value to always align the center of the original gradient at
+ * @returns a new list of gradient stops. normalPosition might be different than the original gradient, but has the same units.
+ * position will have the units of the data. Both of them might go out of the 0-1 or min-max range if gradient clipping occurred.
+ */
+export function transformGradientStops(
+ gradient: Gradient,
min: number,
max: number,
center: number | undefined,
-) {
+): Gradient {
let fromMin = min
let fromMax = max
let toMin = 0
@@ -170,12 +223,11 @@ export function getGradientStops(
toMax = toMin + 0.001
}
- const stops: { normalPosition: number; position: number; color: string }[] =
- []
+ const stops: Gradient = []
- for (let i = 0; i < gradient.length - 1; i += 2) {
- const originalPos = gradient[i] as number
- const originalCol = gradient[i + 1] as string
+ for (let i = 0; i < gradient.length; i++) {
+ const originalPos = gradient[i].position
+ const originalCol = gradient[i].color
const t = (originalPos - toMin) / (toMax - toMin)
stops.push({
@@ -188,6 +240,17 @@ export function getGradientStops(
return stops
}
+/**
+ * Builds the layer style to be applied to the field layer on the soil analysis atlas. This needs to be
+ * paired with a correctly-working legend, which can make use of the `transformGradientStops` function.
+ *
+ * Missing values will be displayed in a gray color.
+ *
+ * @param parameter which soil parameter to get the styles for
+ * @param min minimum data value
+ * @param max maximum data value
+ * @returns Layer component `type` and `paint` props
+ */
export function getSoilAnalysisLayerStyle(
parameter: ShadedSoilParameters,
min: number,
@@ -238,7 +301,7 @@ export function getSoilAnalysisLayerStyle(
"interpolate",
["linear"],
dataGetter,
- ...getGradientStops(
+ ...transformGradientStops(
fillColor.gradient,
min,
max,