Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d3747bd
feat: setup page for farm overview of indicators
SvenVw May 12, 2026
d4308cc
feat: add table overview at farm level for indicators
SvenVw May 12, 2026
e5132d7
feat: add atlas for indicators
SvenVw May 12, 2026
df0bc7b
feat: add bln3 info and option to calculate without measures
SvenVw May 12, 2026
26cbd0c
feat: select multiple categories at the table
SvenVw May 12, 2026
707bf08
fix: header text
SvenVw May 12, 2026
9da44c6
fix: sidebar selection
SvenVw May 12, 2026
da09a07
docs: add changeset
SvenVw May 12, 2026
5226522
fix: sidebar highlighting
SvenVw May 12, 2026
66a95fb
Merge branch 'FDM574' into FDM575
SvenVw May 13, 2026
3c9e510
Merge branch 'development' into FDM575
SvenVw May 13, 2026
7d0d91a
fix: Use the route calendar param for link generation
SvenVw May 13, 2026
cc42ce1
fix: make table keybaord accessible
SvenVw May 13, 2026
788cfea
fix: averaging when nan
SvenVw May 13, 2026
95f2480
fix: Avoid fetching fields twice per request path.
SvenVw May 13, 2026
3fffaed
fix: revert ui change
SvenVw May 13, 2026
7430bb9
refactor: show the indicators via dropdown instead of badges
SvenVw May 13, 2026
a292d97
refactor: improve the ui/ux
SvenVw May 13, 2026
bcada43
refactor: debounce the pending indicator to avoid flickering on fast …
SvenVw May 15, 2026
a034eb3
refactor: improve types
SvenVw May 15, 2026
9af5768
Update fdm-calculator/src/bln3/input.ts
SvenVw May 15, 2026
10a3382
refactor: make it more clear the category is not selectable
SvenVw May 15, 2026
6785f55
fix: fit the column names
SvenVw May 15, 2026
5b47ecc
fix: table overflowing on small screens
SvenVw 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
8 changes: 8 additions & 0 deletions .changeset/green-fields-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nmi-agro/fdm-app": minor
---

Add BLN3 indicators overview for farms. Two new pages are available under `/farm/:b_id_farm/:calendar/indicators`:

- **Tabel** – heatmap table (TanStack Table) with all 28 BLN3 indicators grouped by category (Biologisch, Chemisch, Fysisch, Grondwater, Nutriënten, Oppervlaktewater). Columns use rotated headers with tooltips. A pinned "Knelpunten" row shows the number of fields scoring below 40 per indicator. Aggregation cards for OBI (Open Bodem Index) and BBWP (BedrijfsBodemWaterPlan) show farm-level averages.
- **Kaart** – full-height MapLibre map coloured by a selected indicator score. An individual indicator can be chosen via floating badge chips grouped by category. Hovering a field shows its name and score.
45 changes: 45 additions & 0 deletions fdm-app/app/components/blocks/atlas/atlas-styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,48 @@ function getFieldsStyleInner(layerId: string): LayerProps {
},
}
}

/**
* Fill layer that colours fields by their average BLN3 score (0–100).
* Store avgScore = -1 on features that have no data (renders grey).
* Pass `property` to colour by a different GeoJSON feature property
* (e.g. a per-category average or a single indicator score).
*/
export function getFieldsScoreStyle(layerId: string, property = "avgScore"): LayerProps {
return {
id: layerId,
type: "fill",
paint: {
"fill-color": [
"interpolate",
["linear"],
["get", property],
-1, "#9ca3af", // grey — no data
0, "#ef4444", // red — score 0
40, "#eab308", // yellow — score 40
70, "#22c55e", // green — score 70+
] as any,
"fill-opacity": 0.75,
},
}
}

/** Outline layer that matches the score colour of getFieldsScoreStyle. */
export function getFieldsScoreOutlineStyle(layerId: string, property = "avgScore"): LayerProps {
return {
id: layerId,
type: "line",
paint: {
"line-color": [
"interpolate",
["linear"],
["get", property],
-1, "#6b7280",
0, "#dc2626",
40, "#ca8a04",
70, "#16a34a",
] as any,
"line-width": 2,
},
}
}
61 changes: 61 additions & 0 deletions fdm-app/app/components/blocks/header/indicators.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ChevronDown } from "lucide-react"
import { NavLink, useLocation, useParams } from "react-router"
import { useCalendarStore } from "@/app/store/calendar"
import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"

export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) {
const calendarFromStore = useCalendarStore((state) => state.calendar)
const { calendar: calendarFromRoute } = useParams()
const calendar = calendarFromRoute ?? calendarFromStore
const location = useLocation()
const isKaart = location.pathname.includes("/atlas")
const currentName = isKaart ? "Kaart" : "Tabel"

return (
<>
<BreadcrumbSeparator className="hidden xl:block" />
<BreadcrumbItem className="hidden xl:block">
<BreadcrumbLink
href={`/farm/${b_id_farm}/${calendar}/indicators`}
>
Indicatoren
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 max-w-30 sm:max-w-50 md:max-w-none outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<span className="truncate">{currentName}</span>
<ChevronDown className="h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem asChild>
<NavLink
to={`/farm/${b_id_farm}/${calendar}/indicators`}
>
Tabel
</NavLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<NavLink
to={`/farm/${b_id_farm}/${calendar}/indicators/atlas`}
>
Kaart
</NavLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
</>
)
}
112 changes: 112 additions & 0 deletions fdm-app/app/components/blocks/indicators/aggregation-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Info } from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip"
import { getScoreColor, getScoreTier, scoreToDisplay } from "~/lib/indicators"
import { ScoreBadge } from "./score-badge"

type AggregationCardProps = {
/** Aggregation label, e.g. "OBI" or "BBWP" */
label: string
/** Full Dutch name for the aggregation */
name: string
/** Score on a 0–1 scale (from API). Null when unavailable. */
score01: number | null
/**
* Optional "without measures" score on a 0–1 scale.
* When provided together with `score01`, a delta is shown.
*/
index01?: number | null
/** When true, show the index (without measures) instead of the score. */
showIndex?: boolean
}

/**
* A card summarising one BLN3 aggregation (OBI, BBWP) for the farm.
*
* Shows the farm-average score (0–100), a colour-coded progress bar,
* a text verdict badge, and optionally a delta between score and index.
*/
export function AggregationCard({
label,
name,
score01,
index01,
showIndex = false,
}: AggregationCardProps) {
const activeScore01 = showIndex ? (index01 ?? score01) : score01
const display =
activeScore01 !== null ? scoreToDisplay(activeScore01) : null
const color = display !== null ? getScoreColor(display) : "#d1d5db"
const tier = display !== null ? getScoreTier(display) : null

// Delta: how much do measures improve the score?
const hasDelta =
score01 !== null &&
index01 !== null &&
index01 !== undefined &&
score01 !== index01
const delta =
hasDelta && score01 !== null && index01 !== null
? scoreToDisplay(score01) - scoreToDisplay(index01)
: null

return (
<TooltipProvider>
<Card className="min-w-[140px]">
<CardHeader className="pb-1 pt-3 px-3">
<div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
{label}
</p>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground/60 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-center text-xs">
Berekend als gemiddelde van de afzonderlijke relevante indicatoren. De aggregatiescore is nog niet beschikbaar.
</TooltipContent>
</Tooltip>
</div>
<CardTitle className="text-2xl font-bold tabular-nums">
{display !== null ? display : "—"}
</CardTitle>
</CardHeader>
<CardContent className="pb-3 px-3 space-y-1.5">
{/* Colour-coded progress bar */}
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-primary/20">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${display ?? 0}%`,
backgroundColor: color,
}}
/>
</div>

<div className="flex items-center justify-between gap-2 flex-wrap">
{tier !== null && display !== null && (
<ScoreBadge score={display} />
)}
{!showIndex && delta !== null && delta > 0 && (
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
+{delta}
</span>
)}
</div>

<p className="text-[11px] text-muted-foreground">{name}</p>
</CardContent>
</Card>
</TooltipProvider>
)
}
Loading
Loading