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 && ( + {}} + > + + + + )} + +
+ + + + + +
+
+ ) +} 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"} +
+ +
+ + 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 +

+
+ +
+ + +
+ ) +} 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 ( + + + {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() { )}
- - - - + 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 ( - {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 -

-
-
- -
+
+

+ 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 ( + +
+ + + +
+
+ +
+ +
+
+
+ ) +} 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. */} - - {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 */} + + + + + + {/* 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,