Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f269f44
Add working color ramp and legend for the soil analysis atlas
BoraIneviNMI May 4, 2026
f086209
Add atlas field soil analysis detail pages
BoraIneviNMI May 6, 2026
1480bec
Add hover panel
BoraIneviNMI May 6, 2026
b123496
Clean-up
BoraIneviNMI May 6, 2026
66d20d3
Add navigation to the soil analysis atlas
BoraIneviNMI May 6, 2026
3f3376b
Move soil analysis legend to atlas-legend.tsx
BoraIneviNMI May 6, 2026
493b93b
Improve sidebar and header behavior
BoraIneviNMI May 6, 2026
eeadc04
Improve colors
BoraIneviNMI May 6, 2026
9a0c3c9
Improve gradient legend
BoraIneviNMI May 6, 2026
ab7ba85
Add changeset
BoraIneviNMI May 6, 2026
de5c89f
Resolve CodeQL finding
BoraIneviNMI May 6, 2026
1476dc5
Address nitpicks
BoraIneviNMI May 6, 2026
dec3d0a
Handle undefined date
BoraIneviNMI May 7, 2026
d32e345
Polish up some parts
BoraIneviNMI May 7, 2026
e71c455
Nitpicks
BoraIneviNMI May 7, 2026
66a2ef5
Fix bad parameter behavior and the dropdown CSS height in the SoilAna…
BoraIneviNMI May 7, 2026
66c00f3
Merge branch 'development' into FDM580
BoraIneviNMI May 7, 2026
d8a23ce
Merge branch 'development' into FDM580
SvenVw May 7, 2026
8f4c983
Improve navigation
BoraIneviNMI May 7, 2026
182bed4
Make the gradient legend vertical
BoraIneviNMI May 7, 2026
5ff13d0
Handle missing gradient definition
BoraIneviNMI May 7, 2026
a809a40
Resolve CodeQL finding
BoraIneviNMI May 7, 2026
0ba68d2
Merge branch 'development' into FDM580
SvenVw May 13, 2026
f7365de
refactor: update colors for agricultural soil types
SvenVw May 13, 2026
3e2fa07
Change gradient shading colors
BoraIneviNMI May 13, 2026
a5c8f05
Unify gradient stops logic
BoraIneviNMI May 15, 2026
fd97665
Change layout of soil analysis atlas controls
BoraIneviNMI May 15, 2026
474062c
Fix gradient center symmetric range issue
BoraIneviNMI May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/orange-yaks-refuse.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/ready-clowns-pick.md
Original file line number Diff line number Diff line change
@@ -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.
220 changes: 219 additions & 1 deletion fdm-app/app/components/blocks/atlas/atlas-legend.tsx
Original file line number Diff line number Diff line change
@@ -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,
getGradientStops,
getShadedSoilParameters,
getShadingParameterMapper,
SHADED_SOIL_TYPES,
type ShadedSoilParameters,
} from "./atlas-soil-analysis"

interface ElevationLegendProps {
min?: number
Expand Down Expand Up @@ -86,3 +108,199 @@ export function ElevationLegend({
</div>
)
}
interface SoilAnalysisLegendProps {
fieldsData?: FeatureCollection<Geometry, GeoJsonProperties>
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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 (
<Card className="p-4 space-y-2 flex-initial min-h-0 overflow-y-auto">
<CardHeader className="p-0">
<CardTitle className="text-xs text-center text-muted-foreground">
{title}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!shadingConfig[selectedParameter] ? null : shadingConfig[
selectedParameter
].shading === "enum" ? (
<EnumSoilAnalysisLegend {...props} />
) : (
<GradientSoilAnalysisLegend
{...props}
selectedParameter={selectedParameter}
/>
)}
{fieldsData &&
fieldsData.features.length > 0 &&
!anyDataAvailable && (
<p className="flex flex-row items-center gap-2 text-[10pt]">
<TriangleAlert className="h-4 w-4 text-orange-500" />
Geen data op hele bedrijf
</p>
)}
</CardContent>
</Card>
)
}

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<string>()

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 (
<table className="border-separate border-spacing-1">
<tbody>
{displayedOptions.map((opt) => (
<tr key={opt.value}>
<td className="align-middle">
<div
className="size-3 rounded"
style={{ backgroundColor: opt.fill }}
/>
</td>
<td className="align-middle text-sm text-muted-foreground">
{opt.label}
</td>
</tr>
))}
</tbody>
</table>
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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 = getGradientStops(
gradDef.gradient,
min,
max,
gradDef.center,
)

return (
<ChartContainer
config={{}}
initialDimension={{ width: 200, height: 50 }}
className="-mx-3 -mbe-3 min-w-60 aspect-24/5"
>
<BarChart
className="overflow-visible"
barSize={20}
data={chartData}
layout="vertical"
margin={{
left: 15,
right: 15,
top: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
{gradient.map((stop) => (
<stop
key={stop.normalPosition}
offset={`${stop.normalPosition * 100}%`}
stopColor={stop.color}
/>
))}
</linearGradient>
</defs>
<XAxis
type="number"
domain={[min, max]}
interval={0}
niceTicks="snap125"
tickFormatter={(n) =>
(
Math.round(parameterMapper.inverse(n) * 100) / 100
).toString()
}
/>
<YAxis type="category" dataKey="name" tickLine={false} hide />
<Bar
isAnimationActive={false}
dataKey={(
entry: (typeof chartData)[number],
): [number, number] => [entry.min, entry.max]}
shape={(props: BarShapeProps) => (
<Rectangle {...props} fill={`url(#${gradientId})`} />
)}
/>
</BarChart>
</ChartContainer>
)
}
30 changes: 27 additions & 3 deletions fdm-app/app/components/blocks/atlas/atlas-panels.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -21,15 +25,32 @@ import { Separator } from "~/components/ui/separator"
import { Spinner } from "~/components/ui/spinner"
import { cn } from "~/lib/utils"

/**
* Renders a panel showing the field name or the cultivation name and the corresponding area,
* for the farm or cultivation field that is currently hovered on with the mouse pointer.
* It can also include contextual information if the field IDs "fieldsSaved", "fieldsAvailable" etc. are used.
*
* This component will always contain some HTML, but it will have hidden visibility if there is no intersected feature
*
* - `zoomLevelFields` is the zoom threshold after which no panel is shown
* - `layer` is a layer ID or an array of IDs for which the panel is shown
* - `layerExclude` can be a layerId or an array of IDs which block the panel from being shown
* - `render` can be used to render a custom panel instead of the default one.
* It **SHOULD** be a stable reference since when it changes the event handlers on the map are reinstantiated.
* - `clickRedirectsToDetailsPage`, if set to true, causes the default panel to tell the user that clicking will navigate to a different page
* @returns the output of the render function, or a Card containing the information mentioned above.
*/
export function FieldsPanelHover({
zoomLevelFields,
layer,
layerExclude,
render,
clickRedirectsToDetailsPage = false,
}: {
zoomLevelFields: number
layer: string[] | string
layerExclude?: string[] | string
render?: (feature: GeoJSONFeature) => ReactNode
clickRedirectsToDetailsPage?: boolean
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}) {
const { current: map } = useMap()
Expand Down Expand Up @@ -108,7 +129,9 @@ export function FieldsPanelHover({
? feature.properties.b_name
: feature.properties.b_lu_name
: "Naam"
return (
return active && render ? (
render(feature)
) : (
<Card
className={cn("w-full", !active && "invisible")}
>
Expand Down Expand Up @@ -179,6 +202,7 @@ export function FieldsPanelHover({
zoomLevelFields,
layerIdsKey,
excludedLayerIdsKey,
render,
clickRedirectsToDetailsPage,
])

Expand Down
Loading
Loading