diff --git a/.changeset/orange-yaks-refuse.md b/.changeset/orange-yaks-refuse.md new file mode 100644 index 000000000..b414ce789 --- /dev/null +++ b/.changeset/orange-yaks-refuse.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Added the soil analysis atlas. Users can select different soil parameters which will show each field on the atlas coloured according the parameter's value in its soil analyses. diff --git a/.changeset/ready-clowns-pick.md b/.changeset/ready-clowns-pick.md new file mode 100644 index 000000000..cdb8ea728 --- /dev/null +++ b/.changeset/ready-clowns-pick.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Users can no longer edit soil analyses by clicking the pencil icon on the parameter card's corner, if they don't have the right to edit the analysis that is the source of the value shown on the card. diff --git a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx index e76f9e5f5..ab6b95924 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-legend.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-legend.tsx @@ -1,5 +1,27 @@ -import { Card, CardContent } from "~/components/ui/card" +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, + BarChart, + type BarShapeProps, + Rectangle, + XAxis, + YAxis, +} from "recharts" +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" +import { ChartContainer } from "~/components/ui/chart" import { Spinner } from "~/components/ui/spinner" +import { + GRADIENT_DEFINITIONS, + GRADIENT_SHADED_SOIL_PARAMETERS, + getShadedSoilParameters, + getShadingParameterMapper, + SHADED_SOIL_TYPES, + type ShadedSoilParameters, + transformGradientStops, +} from "./atlas-soil-analysis" interface ElevationLegendProps { min?: number @@ -86,3 +108,198 @@ export function ElevationLegend({ ) } +interface SoilAnalysisLegendProps { + fieldsData?: FeatureCollection + selectedParameter: ShadedSoilParameters + soilParametersDescriptions: SoilParameterDescription + min?: number + max?: number +} + +export function SoilAnalysisLegend(props: SoilAnalysisLegendProps) { + const { fieldsData, selectedParameter } = props + + // Parameter shading config + const shadingConfig = Object.fromEntries( + getShadedSoilParameters().map((item) => [item.parameter, item]), + ) + + if (!shadingConfig[selectedParameter]) { + console.warn( + `${selectedParameter} not found in shaded soil parameters.`, + ) + } + + const anyDataAvailable = fieldsData?.features.some( + (feature) => + feature.properties && selectedParameter in feature.properties, + ) + + const parameterDescription = props.soilParametersDescriptions.find( + (item) => item.parameter === props.selectedParameter, + ) + + const unitDisplay = + parameterDescription?.unit && parameterDescription.unit !== "-" + ? ` (${parameterDescription.unit})` + : "" + const title = parameterDescription + ? `${parameterDescription.name}${unitDisplay}` + : undefined + + return ( + + + + {title} + + + + {!shadingConfig[selectedParameter] ? null : shadingConfig[ + selectedParameter + ].shading === "enum" ? ( + + ) : ( + + )} + {fieldsData && + fieldsData.features.length > 0 && + !anyDataAvailable && ( +

+ + Geen data op hele bedrijf +

+ )} +
+
+ ) +} + +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 gradientId = useId() + + const gradDef = + GRADIENT_DEFINITIONS[ + GRADIENT_SHADED_SOIL_PARAMETERS[ + props.selectedParameter as keyof typeof GRADIENT_SHADED_SOIL_PARAMETERS + ] + ] + + if (!gradDef) { + console.warn( + `No gradient definition found for parameter: ${props.selectedParameter}`, + ) + return null + } + + const parameterMapper = getShadingParameterMapper(props.selectedParameter) + + const min = props.min ?? 0 + const max = props.max ?? 1 + + const chartData = [{ name: "Legenda", min: min, max: max }] + const gradient = transformGradientStops( + gradDef.gradient, + min, + max, + gradDef.center, + ) + + return ( + + + + + {gradient.map((stop) => ( + + ))} + + + + ( + Math.round(parameterMapper.inverse(n) * 100) / 100 + ).toString() + } + /> + + [entry.min, entry.max]} + shape={(props: BarShapeProps) => ( + + )} + /> + + + ) +} diff --git a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx index 564e742e8..29b542ced 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-panels.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-panels.tsx @@ -21,6 +21,19 @@ import { Separator } from "~/components/ui/separator" import { Spinner } from "~/components/ui/spinner" import { cn } from "~/lib/utils" +/** + * Renders a panel showing the field name or the cultivation name and the corresponding area, + * for the farm or cultivation field that is currently hovered on with the mouse pointer. + * It can also include contextual information if the field IDs "fieldsSaved", "fieldsAvailable" etc. are used. + * + * This component will always contain some HTML, but it will have hidden visibility if there is no intersected feature + * + * - `zoomLevelFields` is the zoom threshold after which no panel is shown + * - `layer` is a layer ID or an array of IDs for which the panel is shown + * - `layerExclude` can be a layerId or an array of IDs which block the panel from being shown + * - `clickRedirectsToDetailsPage`, if set to true, causes the default panel to tell the user that clicking will navigate to a different page + * @returns the output of the render function, or a Card containing the information mentioned above. + */ export function FieldsPanelHover({ zoomLevelFields, layer, diff --git a/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts new file mode 100644 index 000000000..e86beb891 --- /dev/null +++ b/fdm-app/app/components/blocks/atlas/atlas-soil-analysis.ts @@ -0,0 +1,386 @@ +import type { ExpressionSpecification } from "maplibre-gl" +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.map((item, i) => { + const t = i / (args.length - 1) + return { position: t, normalPosition: t, color: item } + }) +} + +const COLORBREWER_YLORBR = evenlySpaced( + "#ffffe5", + "#fff7bc", + "#fee391", + "#fec44f", + "#fe9929", + "#ec7014", + "#cc4c02", + "#8c2d04", +) + +const COLORBREWER_RDBU = evenlySpaced( + "#b2182b", + "#d6604d", + "#f4a582", + "#fddbc7", + "#d1e5f0", + "#92c5de", + "#4393c3", + "#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" }, + { value: "dekzand", label: "Dekzand", fill: "#FFFF99" }, + { value: "zeeklei", label: "Zeeklei", fill: "#32AA00" }, + { value: "dalgrond", label: "Dalgrond", fill: "#D37FD0" }, + { value: "veen", label: "Veen", fill: "#6A1EB5" }, + { value: "loess", label: "Löss", fill: "#AA2049" }, + { value: "duinzand", label: "Duinzand", fill: "#FFDD71" }, + { value: "maasklei", label: "Maasklei", fill: "#FED31E" }, +] + +/** Which gradient definition to use for gradient-shaded parameters. + * Add items here to let the user select other parameters. + */ +export const GRADIENT_SHADED_SOIL_PARAMETERS = { + a_al_ox: "aluminum", + a_c_of: "carbon", + a_ca_co: "calcium", + a_ca_co_po: "calcium", + a_caco3_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 */ +export const GRADIENT_DEFINITIONS: { + [k in (typeof GRADIENT_SHADED_SOIL_PARAMETERS)[GradientShadedSoilParameters]]: { + gradient: Gradient + center?: number + } +} = { + aluminum: { gradient: COLORBREWER_YLORBR }, + bacterium: { gradient: COLORBREWER_YLORBR }, + calcium: { gradient: COLORBREWER_YLORBR }, + carbon: { gradient: COLORBREWER_YLORBR }, + carbon_ratio: { gradient: COLORBREWER_YLORBR }, + copper: { gradient: COLORBREWER_YLORBR }, + earth_heavy: { gradient: COLORBREWER_YLORBR }, + earth_light: { gradient: COLORBREWER_YLORBR }, + nitrogen: { gradient: COLORBREWER_YLORBR }, + iron: { gradient: COLORBREWER_YLORBR }, + magnesium: { gradient: COLORBREWER_YLORBR }, + phosphorus: { gradient: COLORBREWER_YLORBR }, + potassium: { gradient: COLORBREWER_YLORBR }, + ph: { gradient: COLORBREWER_RDBU, center: 7 }, + sand_dark: { gradient: COLORBREWER_YLORBR }, + sand_light: { gradient: COLORBREWER_YLORBR }, + sulfur: { gradient: COLORBREWER_YLORBR }, + 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, + fill, + ]).concat(["#777777"]), +} 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) => ({ + parameter, + shading: "gradient", + })), + ...Object.keys(ENUM_SHADED_SOIL_PARAMETERS).map((parameter) => ({ + parameter, + shading: "enum", + })), + ] as { parameter: ShadedSoilParameters; shading: "gradient" | "enum" }[] +} + +/** + * 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 + let toMax = 1 + + if (typeof center !== "undefined") { + if (min <= center && max <= center) { + toMax = 0.5 + } + + if (min >= center && max >= center) { + toMin = 0.5 + } + + if (min <= center && max >= center) { + const radius = Math.max(max - center, center - min) + fromMin = center - radius + fromMax = center + radius + } + } + + if (Math.abs(fromMax - fromMin) < 0.001) { + fromMax = fromMin + 0.001 + } + + if (Math.abs(toMax - toMin) < 0.001) { + toMax = toMin + 0.001 + } + + const stops: Gradient = [] + + 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({ + normalPosition: t, + position: fromMin + t * (fromMax - fromMin), + color: originalCol, + }) + } + + 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, + max: number, +): { paint: LayerProps["paint"]; type: "fill" } { + // MapLibreGL expression to get the data path out of the input object (which is the feature properties) + const dataGetter = getShadingParameterMapper(parameter).paint([ + "get", + parameter, + ]) + + if (parameter in ENUM_SHADED_SOIL_PARAMETERS) { + const fillColor = + ENUM_SHADED_SOIL_PARAMETERS[parameter as EnumShadedSoilParameters] + return { + type: "fill", + paint: { + "fill-opacity": 0.8, + "fill-color": ["match", dataGetter, ...fillColor], + }, + } + } + + if (parameter in GRADIENT_SHADED_SOIL_PARAMETERS) { + const gradientName = + GRADIENT_SHADED_SOIL_PARAMETERS[ + parameter as GradientShadedSoilParameters + ] + const fillColor = GRADIENT_DEFINITIONS[gradientName] + function greyIfUndefined( + expr: ExpressionSpecification, + ): ["match", ...unknown[]] { + return [ + "match", + ["typeof", dataGetter], + "number", + expr, + "string", + expr, + "#777777", + ] + } + + return { + type: "fill", + paint: { + "fill-color": greyIfUndefined([ + "interpolate", + ["linear"], + dataGetter, + ...transformGradientStops( + fillColor.gradient, + min, + max, + fillColor.center, + ).flatMap((stop) => [stop.position, stop.color]), + ]), + }, + } + } + + 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 gradient shading + * + * @param parameter parameter to get the mappings for + * @returns object containing mapping functions + */ +export function getShadingParameterMapper(parameter: ShadedSoilParameters) { + if ( + parameter in SHADING_PARAMETER_MAPPERS && + !(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 +} diff --git a/fdm-app/app/components/blocks/atlas/atlas-sources.tsx b/fdm-app/app/components/blocks/atlas/atlas-sources.tsx index e36922dec..2f1a467fc 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,50 @@ export function FieldsSourceAvailable({ ) } + +export function FieldSourceClickable({ + id, + excludedLayerId, + fieldsData, + children, + onFieldClick, +}: { + id: string + excludedLayerId?: string + fieldsData: FeatureCollection + children: ReactNode + onFieldClick: (feature: Feature) => unknown +}) { + const { current: map } = useMap() + + useEffect(() => { + function clickOnMap(evt: MapLayerMouseEvent) { + if (!map) return + if (!(evt.features && evt.features.length > 0)) return + + if ( + excludedLayerId && + map.queryRenderedFeatures(evt.point, { + layers: [excludedLayerId], + }).length + ) { + return + } + + onFieldClick(evt.features[0] as unknown as Feature) + } + + if (map) { + map.on("click", id, clickOnMap) + return () => { + map.off("click", id, clickOnMap) + } + } + }, [map, id, excludedLayerId, 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..a00c58168 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,26 @@ 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"], + ...getCultivationTypesHavingColors().flatMap((k) => [ + k, + getCultivationColor(k), + ]), + getCultivationColor("other"), + ] as any + // default styles return { type: "fill", 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/header/atlas.tsx b/fdm-app/app/components/blocks/header/atlas.tsx index bdfb971c0..abfa0789e 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,16 @@ 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" + const isFarmSelected = b_id_farm && b_id_farm !== "undefined" return ( <> @@ -41,27 +48,58 @@ export function HeaderAtlas({ b_id_farm }: { b_id_farm: string | undefined }) { - - - Gewaspercelen - - - - - Hoogtekaart - - - - - Bodemkaart - - + {isFarmSelected && ( + + + Bedrijf + + + + Gewaspercelen + + + + + Bodemanalyses + + + + )} + {isFarmSelected && } + + {isFarmSelected && ( + + Overig + + )} + {!isFarmSelected && ( + + + Gewaspercelen + + + )} + + + Hoogtekaart + + + + + Bodemkaart + + + 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/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 179fa4813..0cfc6828c 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 - } else if (farmId) { + atlasSoilAnalysisLink = undefined + } else if (farmId && farmId !== "undefined") { 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 = undefined } + 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 @@ -148,13 +165,14 @@ export function SidebarApps() { )} - - {atlasFieldsLink ? ( + {atlasFieldsLink ? ( + - ) : null} - - - {atlasElevationLink ? ( + + ) : null} + {atlasSoilAnalysisLink ? ( + + + + Bodemanalyses + + + + + ) : null} + {atlasElevationLink ? ( + + Hoogtekaart - ) : null} - - - {atlasSoilLink ? ( + + ) : null} + {atlasSoilLink ? ( + Bodemkaart - ) : null} - + + ) : null} diff --git a/fdm-app/app/components/blocks/soil/cards.tsx b/fdm-app/app/components/blocks/soil/cards.tsx index e2ede9d24..cbc6b6389 100644 --- a/fdm-app/app/components/blocks/soil/cards.tsx +++ b/fdm-app/app/components/blocks/soil/cards.tsx @@ -4,7 +4,14 @@ import type { } from "@nmi-agro/fdm-core" import { format } from "date-fns/format" import { nl } from "date-fns/locale/nl" -import { Calendar, Microscope, Pencil, Sparkles, User } from "lucide-react" +import { + Calendar, + ExternalLink, + Microscope, + Pencil, + Sparkles, + User, +} from "lucide-react" import { NavLink } from "react-router" import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card" import { Separator } from "~/components/ui/separator" @@ -82,18 +89,21 @@ 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 }) { + const EditIcon = canModify ? Pencil : ExternalLink return ( @@ -116,17 +126,21 @@ function SoilDataCard({ - + - Bewerken + + {canModify ? "Bewerken" : "Bekijken"} + ) : null}
- {type === "enum" ? ( + {value === null ? ( + "Onbekend" + ) : type === "enum" ? (
{label && type === "enum" ? label : value}
@@ -191,9 +205,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 +266,10 @@ export function SoilDataCards({ date={card.date} source={card.source} sourceLabel={sourceLabel} + canModify={ + canModifyAllSoilAnalyses || + canModifySoilAnalysis[card.a_id] + } /> ) })} @@ -264,47 +286,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..84458d0e4 100644 --- a/fdm-app/app/components/blocks/soil/list.tsx +++ b/fdm-app/app/components/blocks/soil/list.tsx @@ -50,13 +50,15 @@ export function SoilAnalysesList({

{analysis.a_source === "nl-other-nmi" ? "Geschat met NMI BodemSchat" - : format( - analysis.b_sampling_date, - "PP", - { - locale: nl, - }, - )} + : analysis.b_sampling_date + ? format( + analysis.b_sampling_date, + "PP", + { + locale: nl, + }, + ) + : "Onbekende datum"}

{analysis.a_source === "nl-other-nmi" @@ -90,7 +92,11 @@ export function SoilAnalysesList({ "nl-other-nmi" } > - Bewerk + {canModifySoilAnalysis[ + analysis.a_id + ] + ? "Bewerk" + : "Bekijk"} +

+ + + + ) +} 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 ( + +
+ + + +
+
+ +
+ +
+
+
+ ) +} 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 + } /> )} diff --git a/fdm-app/app/store/selected-soil-parameter.ts b/fdm-app/app/store/selected-soil-parameter.ts new file mode 100644 index 000000000..adabb514f --- /dev/null +++ b/fdm-app/app/store/selected-soil-parameter.ts @@ -0,0 +1,45 @@ +import { create } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" +import { + getShadedSoilParameters, + type ShadedSoilParameters, +} from "~/components/blocks/atlas/atlas-soil-analysis" +import { ssrSafeSessionJSONStorage } from "./storage" + +interface SelectedAtlasSoilParameterState { + selectedParameter: ShadedSoilParameters + setSelectedParameter: (selectedParameter: ShadedSoilParameters) => 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), + onRehydrateStorage: () => { + // Returned function will be called after rehydration + return (state, error) => { + if (error) { + console.warn(error) + } + // If the stored soil parameter is invalid, set default + if ( + state && + !getShadedSoilParameters().some( + (item) => + item.parameter === state.selectedParameter, + ) + ) { + state.setSelectedParameter("a_som_loi") + } + } + }, + }, + ), + )