From d3747bdc92654bb6b628f76e14f0b794a39f617a Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 12:38:01 +0200 Subject: [PATCH 01/22] feat: setup page for farm overview of indicators --- .../components/blocks/header/indicators.tsx | 32 ++ .../app/components/blocks/sidebar/apps.tsx | 43 +++ fdm-app/app/components/ui/breadcrumb.tsx | 4 +- fdm-app/app/integrations/bln3.server.ts | 121 +++++++ fdm-app/app/lib/indicators.ts | 307 ++++++++++++++++++ 5 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 fdm-app/app/components/blocks/header/indicators.tsx create mode 100644 fdm-app/app/integrations/bln3.server.ts create mode 100644 fdm-app/app/lib/indicators.ts diff --git a/fdm-app/app/components/blocks/header/indicators.tsx b/fdm-app/app/components/blocks/header/indicators.tsx new file mode 100644 index 000000000..42d93b0c3 --- /dev/null +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -0,0 +1,32 @@ +import { NavLink } from "react-router" +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb" +import { useCalendarStore } from "~/store/calendar" + +export function HeaderIndicators({ + b_id_farm, +}: { + b_id_farm: string +}) { + const calendar = useCalendarStore((state) => state.calendar) + + return ( + <> + + + + + Indicatoren + + + + + ) +} diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 179fa4813..382922e2d 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -1,6 +1,7 @@ import { ArrowRightLeft, BookOpenText, + Gauge, Landmark, MapIcon, Minus, @@ -103,6 +104,15 @@ export function SidebarApps() { omBalanceLink = undefined } + let indicatorsLink: string | undefined + if (isCreateFarmWizard) { + indicatorsLink = undefined + } else if (farmId && farmId !== "undefined") { + indicatorsLink = `/farm/${farmId}/${selectedCalendar}/indicators` + } else { + indicatorsLink = undefined + } + return ( @@ -337,6 +347,39 @@ export function SidebarApps() { )} + + {indicatorsLink ? ( + + + + Indicatoren + + + ) : ( + + + + + + Indicatoren + + + + + Selecteer een bedrijf om de + indicatoren te bekijken + + + )} + diff --git a/fdm-app/app/components/ui/breadcrumb.tsx b/fdm-app/app/components/ui/breadcrumb.tsx index 5f4eb065d..5c5a69baf 100644 --- a/fdm-app/app/components/ui/breadcrumb.tsx +++ b/fdm-app/app/components/ui/breadcrumb.tsx @@ -1,5 +1,5 @@ import { ChevronRight, MoreHorizontal } from "lucide-react" -import { Slot } from "radix-ui" +import { Slot as SlotPrimitive } from "radix-ui" import * as React from "react" import { cn } from "~/lib/utils" @@ -45,7 +45,7 @@ const BreadcrumbLink = React.forwardRef< asChild?: boolean } >(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? Slot : "a" + const Comp = asChild ? SlotPrimitive.Slot : "a" return ( { + const nmiApiKey = getNmiApiKey() + + const inputs = await collectInputForBln3Score(fdm, principal_id, b_id, timeframe) + const score = await getBln3Score(fdm, { ...inputs, nmiApiKey } as Bln3ScoreInputs) + return score +} + +/** + * Calculates BLN3 scores for all fields in a farm. + * + * Uses `Promise.allSettled` so individual field failures do not abort the + * whole farm load. Fields that fail return `null` with an error message. + */ +export async function getIndicatorsForFarm({ + principal_id, + b_id_farm, + timeframe, +}: { + principal_id: PrincipalId + b_id_farm: string + timeframe?: Timeframe +}): Promise { + const fields = await getFields(fdm, principal_id, b_id_farm, timeframe) + + const results = await Promise.allSettled( + fields.map((field) => + getIndicatorsForField({ + principal_id, + b_id: field.b_id, + timeframe, + }), + ), + ) + + return results.map((result, index) => { + const b_id = fields[index].b_id + if (result.status === "fulfilled") { + return { b_id, score: result.value, error: null } + } + const errorMessage = + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + console.error(`BLN3 score failed for field ${b_id}:`, errorMessage) + return { b_id, score: null, error: errorMessage } + }) +} + +/** + * Computes a farm-level average score for a given set of indicator IDs. + * Only fields with available scores contribute to the average. + * + * @param fieldScores - Array of per-field BLN3 scores + * @param indicatorIds - Indicator IDs to include in the aggregation + * @param mode - "score" (with measures) or "index" (without measures) + */ +export function computeFarmAggregation( + fieldScores: FieldBln3Score[], + indicatorIds: string[], + mode: "score" | "index" = "score", +): number | null { + const allValues: number[] = [] + + for (const { score } of fieldScores) { + if (!score) continue + for (const indicator of score.indicators) { + if (!indicatorIds.includes(indicator.indicator_id)) continue + const value = mode === "score" ? indicator.score : indicator.index + allValues.push(value) + } + } + + if (allValues.length === 0) return null + const avg = allValues.reduce((sum, v) => sum + v, 0) / allValues.length + return avg +} diff --git a/fdm-app/app/lib/indicators.ts b/fdm-app/app/lib/indicators.ts new file mode 100644 index 000000000..531f7915e --- /dev/null +++ b/fdm-app/app/lib/indicators.ts @@ -0,0 +1,307 @@ +/** + * BLN3 indicator taxonomy, utilities, and score display helpers. + * Shared by both farm-level and field-level indicator pages. + */ + +// ── Types ────────────────────────────────────────────────────────────────── + +export type IndicatorCategory = + | "Biologisch" + | "Chemisch" + | "Fysisch" + | "Grondwater" + | "Nutriënten" + | "Oppervlaktewater" + +export type IndicatorInfo = { + id: string + name: string + description: string + category: IndicatorCategory +} + +export type ScoreTier = "green" | "yellow" | "red" + +// ── Aggregation groupings ────────────────────────────────────────────────── + +/** OBI = Biologisch + Chemisch (excl. C_SEQ) + Fysisch */ +export const OBI_INDICATOR_IDS = [ + "B_DI", + "B_SF", + "C_K", + "C_MG", + "C_N", + "C_P", + "C_PH", + "C_S", + "P_AS", + "P_CO", + "P_CR", + "P_DS", + "P_DU", + "P_RO", + "P_SE", + "P_WRET", + "P_WO", + "P_WS", +] + +/** BBWP = Grondwater + Nutriënten + Oppervlaktewater + C_SEQ */ +export const BBWP_INDICATOR_IDS = [ + "C_SEQ", + "GW_GWR", + "GW_NLEA", + "GW_PEST", + "NUT_K", + "NUT_N", + "NUT_P", + "SW_NLEA", + "SW_PLEA", +] + +// ── Indicator taxonomy ────────────────────────────────────────────────────── + +export const INDICATORS: IndicatorInfo[] = [ + // Biologisch + { + id: "B_DI", + name: "Ziektewerendheid", + description: + "Het vermogen van de bodem om bodemgebonden ziekten en plagen te voorkomen", + category: "Biologisch", + }, + { + id: "B_SF", + name: "Microbiele activiteit", + description: + "De mate van activiteit van het micro-organismen (zoals bacteriën en schimmels) in de bodem", + category: "Biologisch", + }, + // Chemisch + { + id: "C_K", + name: "Kaliumbeschikbaarheid", + description: + "De beschikbaarheid van kalium vanuit de bodem voor het gewas", + category: "Chemisch", + }, + { + id: "C_MG", + name: "Magnesiumbeschikbaarheid", + description: + "De beschikbaarheid van magnesium vanuit de bodem voor het gewas", + category: "Chemisch", + }, + { + id: "C_N", + name: "Stikstofbeschikbaarheid", + description: + "De beschikbaarheid van stikstof vanuit de bodem voor het gewas", + category: "Chemisch", + }, + { + id: "C_P", + name: "Fosfaatbeschikbaarheid", + description: + "De beschikbaarheid van fosfor vanuit de bodem voor het gewas", + category: "Chemisch", + }, + { + id: "C_PH", + name: "Zuurgraad", + description: + "De zuurgraad van de bodem, belangrijk voor de beschikbaarheid van nutriënten en een actief bodemleven", + category: "Chemisch", + }, + { + id: "C_S", + name: "Zwavelbeschikbaarheid", + description: "De beschikbaarheid van zwavel vanuit de bodem voor het gewas", + category: "Chemisch", + }, + { + id: "C_SEQ", + name: "Koolstofvastlegging", + description: "De potentie van de bodem om koolstof vast te leggen", + category: "Chemisch", + }, + // Fysisch + { + id: "P_AS", + name: "Aggregaatstabiliteit", + description: + "De stevigheid van bodemaggregaten wat de bodem beter bestand maakt tegen verdichting en zware regenval", + category: "Fysisch", + }, + { + id: "P_CO", + name: "Weerstand tegen bodemverdichting", + description: "De mate waarin de bodem bestand is tegen bodemverdichting", + category: "Fysisch", + }, + { + id: "P_CR", + name: "Verkruimelbaarheid", + description: + "De mate waarin de bodem is te verkruimelen om een goed zaaibed aan te leggen", + category: "Fysisch", + }, + { + id: "P_DS", + name: "Weerstand tegen droogte", + description: + "Het vermogen van de bodem om voldoende vocht vast te houden en te leveren tijdens droge perioden", + category: "Fysisch", + }, + { + id: "P_DU", + name: "Weerstand tegen verstuiving", + description: "De weerbaarheid van de bodem tegen winderosie", + category: "Fysisch", + }, + { + id: "P_RO", + name: "Bewortelbaarheid", + description: + "De mate waarin de bodem gemakkelijk te bewortelen is voor het gewas", + category: "Fysisch", + }, + { + id: "P_SE", + name: "Weerstand tegen verslemping", + description: + "De weerbaarheid van de bodem tegen het vormen van een slempkorst", + category: "Fysisch", + }, + { + id: "P_WRET", + name: "Waterbergend vermogen", + description: "Het vermogen van de bodem om water vast te houden", + category: "Fysisch", + }, + { + id: "P_WO", + name: "Bewerkbaarheid", + description: + "De mate waarin de bodem bewerkbaar is en voldoende draagkracht heeft", + category: "Fysisch", + }, + { + id: "P_WS", + name: "Weerstand tegen wateroverlast", + description: + "Het vermogen van de bodem om overtollig water snel af te voeren, zodat zuurstoftekort bij de wortels wordt voorkomen", + category: "Fysisch", + }, + // Grondwater + { + id: "GW_GWR", + name: "Grondwateraanvulling", + description: + "De mate waarin regenwater kan infiltreren naar het diepere grondwater in plaats van oppervlakkig af te stromen naar de sloten", + category: "Grondwater", + }, + { + id: "GW_NLEA", + name: "Weerstand tegen stikstofuitspoeling", + description: + "Het vermogen van de bodem om stikstof in de bodem vast te houden in plaats van dat het uitspoelt naar het grondwater", + category: "Grondwater", + }, + { + id: "GW_PEST", + name: "Weerstand tegen middeluitspoeling", + description: + "Het vermogen van de bodem om gewasbeschermingsmiddelen te binden en af te breken, zodat ze niet in het grondwater terechtkomen", + category: "Grondwater", + }, + // Nutriënten + { + id: "NUT_K", + name: "Kaliumbenutting", + description: + "De effectiviteit waarmee het gewas de aanwezige en bemeste kalium kan opnemen en benutten", + category: "Nutriënten", + }, + { + id: "NUT_N", + name: "Stikstofbenutting", + description: + "De effectiviteit waarmee het gewas de aanwezige en bemeste stikstof kan opnemen en benutten", + category: "Nutriënten", + }, + { + id: "NUT_P", + name: "Fosfaatbenutting", + description: + "De effectiviteit waarmee het gewas de aanwezige en bemeste fosfaat kan opnemen en benutten", + category: "Nutriënten", + }, + // Oppervlaktewater + { + id: "SW_NLEA", + name: "Weerstand tegen stikstofafspoeling", + description: + "Het vermogen van de bodem om afstroming van stikstof naar het oppervlaktewater te voorkomen na hevige neerslag", + category: "Oppervlaktewater", + }, + { + id: "SW_PLEA", + name: "Weerstand tegen fosfaatafspoeling", + description: + "Het vermogen van de bodem om fosfaat te binden en afstroming naar het oppervlaktewater te voorkomen na hevige neerslag", + category: "Oppervlaktewater", + }, +] + +export const INDICATOR_CATEGORIES: IndicatorCategory[] = [ + "Biologisch", + "Chemisch", + "Fysisch", + "Grondwater", + "Nutriënten", + "Oppervlaktewater", +] + +// ── Score utilities ───────────────────────────────────────────────────────── + +/** Convert 0–1 API score to 0–100 display value. */ +export function scoreToDisplay(score01: number): number { + return Math.round(score01 * 100) +} + +/** Returns a colour tier for a 0–100 display score. */ +export function getScoreTier(score100: number): ScoreTier { + if (score100 >= 70) return "green" + if (score100 >= 40) return "yellow" + return "red" +} + +/** Returns a hex fill colour for a 0–100 display score. */ +export function getScoreColor(score100: number): string { + const tier = getScoreTier(score100) + if (tier === "green") return "#22c55e" + if (tier === "yellow") return "#eab308" + return "#ef4444" +} + +/** Returns a Dutch text verdict for a 0–100 display score. */ +export function getScoreVerdict(score100: number): string { + if (score100 >= 80) return "Uitstekend" + if (score100 >= 70) return "Goed" + if (score100 >= 50) return "Matig" + if (score100 >= 40) return "Aandacht gewenst" + return "Actie nodig" +} + +/** Looks up an indicator by ID. Returns undefined if not found. */ +export function getIndicatorInfo(id: string): IndicatorInfo | undefined { + return INDICATORS.find((i) => i.id === id) +} + +/** Returns all indicators for a given category. */ +export function getIndicatorsByCategory( + category: IndicatorCategory, +): IndicatorInfo[] { + return INDICATORS.filter((i) => i.category === category) +} From d4308cc3b75cd28900d86095318ae63cf9bcb60e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 15:34:13 +0200 Subject: [PATCH 02/22] feat: add table overview at farm level for indicators --- .../blocks/indicators/aggregation-card.tsx | 116 ++++++ .../blocks/indicators/heatmap-cell.tsx | 82 +++++ .../blocks/indicators/heatmap-table.tsx | 343 ++++++++++++++++++ .../blocks/indicators/score-badge.tsx | 34 ++ ...$b_id_farm.$calendar.indicators._index.tsx | 142 ++++++++ .../farm.$b_id_farm.$calendar.indicators.tsx | 92 +++++ fdm-app/app/tailwind.css | 8 + 7 files changed, 817 insertions(+) create mode 100644 fdm-app/app/components/blocks/indicators/aggregation-card.tsx create mode 100644 fdm-app/app/components/blocks/indicators/heatmap-cell.tsx create mode 100644 fdm-app/app/components/blocks/indicators/heatmap-table.tsx create mode 100644 fdm-app/app/components/blocks/indicators/score-badge.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx diff --git a/fdm-app/app/components/blocks/indicators/aggregation-card.tsx b/fdm-app/app/components/blocks/indicators/aggregation-card.tsx new file mode 100644 index 000000000..7282e2dc4 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/aggregation-card.tsx @@ -0,0 +1,116 @@ +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 ( + + + +
+

+ {label} +

+ + + + + + Berekend als gemiddelde van de afzonderlijke relevante indicatoren. De aggregatiescore is nog niet beschikbaar. + + +
+ + {display !== null ? display : "—"} + +
+ + {/* Colour-coded progress bar */} +
+
+
+ +
+ {tier !== null && display !== null && ( + + )} + {!showIndex && delta !== null && delta > 0 && ( + + {name}: {scoreToDisplay(index01!)} → {scoreToDisplay(score01!)} ( + + +{delta} + + ) + + )} +
+ +

{name}

+ + + + ) +} diff --git a/fdm-app/app/components/blocks/indicators/heatmap-cell.tsx b/fdm-app/app/components/blocks/indicators/heatmap-cell.tsx new file mode 100644 index 000000000..ddffbdeca --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/heatmap-cell.tsx @@ -0,0 +1,82 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { getScoreColor, getScoreVerdict, scoreToDisplay } from "~/lib/indicators" +import type { IndicatorInfo } from "~/lib/indicators" + +type HeatmapCellProps = { + indicator: IndicatorInfo + /** Score on a 0–1 scale (null = no data). */ + score01: number | null + /** Index (without measures) on a 0–1 scale. */ + index01: number | null + /** Whether to show index instead of score. */ + showIndex: boolean +} + +/** + * A single heatmap cell showing a colour-coded circle with a score. + * + * - Coloured circle: green ≥70, yellow 40–69, red <40 (on 0–100 scale) + * - Tooltip: indicator name + Dutch verdict + * - Optional delta badge when measures have significant impact + * - Grey when no data + */ +export function HeatmapCell({ + indicator, + score01, + index01, + showIndex, +}: HeatmapCellProps) { + const active01 = showIndex ? (index01 ?? score01) : score01 + const display = active01 !== null ? scoreToDisplay(active01) : null + const color = display !== null ? getScoreColor(display) : null + const verdict = display !== null ? getScoreVerdict(display) : null + + // Delta badge: show when measures improve this indicator by >5 points + const hasMeaningfulDelta = + !showIndex && + score01 !== null && + index01 !== null && + scoreToDisplay(score01) - scoreToDisplay(index01) >= 5 + const delta = + hasMeaningfulDelta && score01 !== null && index01 !== null + ? scoreToDisplay(score01) - scoreToDisplay(index01) + : null + + return ( + + +
+ {/* Coloured score circle */} +
+ {display !== null ? display : "—"} +
+ + {/* Delta badge (top-right corner) */} + {delta !== null && ( + + +{delta} + + )} +
+
+ +

{indicator.name}

+ {verdict ? ( +

{verdict} ({display}/100)

+ ) : ( +

Geen bodemanalyse beschikbaar

+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/indicators/heatmap-table.tsx b/fdm-app/app/components/blocks/indicators/heatmap-table.tsx new file mode 100644 index 000000000..e415685a6 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/heatmap-table.tsx @@ -0,0 +1,343 @@ +import { TriangleAlert } from "lucide-react" +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table" +import { NavLink } from "react-router" +import { useMemo } from "react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { + INDICATOR_CATEGORIES, + INDICATORS, + scoreToDisplay, + type IndicatorCategory, + type IndicatorInfo, +} from "~/lib/indicators" +import type { FieldBln3Score } from "~/integrations/bln3.server" +import { HeatmapCell } from "./heatmap-cell" +import { cn } from "~/lib/utils" + +type FieldRow = { + b_id: string + b_name: string | null | undefined + scores: Record +} + +const CATEGORY_TEXT: Record = { + Biologisch: "text-amber-600 dark:text-amber-400", + Chemisch: "text-blue-600 dark:text-blue-400", + Fysisch: "text-stone-600 dark:text-stone-400", + Grondwater: "text-cyan-600 dark:text-cyan-400", + "Nutriënten": "text-green-600 dark:text-green-400", + Oppervlaktewater: "text-sky-600 dark:text-sky-400", +} + +const CATEGORY_BORDER: Record = { + Biologisch: "border-b-amber-400", + Chemisch: "border-b-blue-400", + Fysisch: "border-b-stone-400", + Grondwater: "border-b-cyan-400", + "Nutriënten": "border-b-green-400", + Oppervlaktewater: "border-b-sky-400", +} + +type HeatmapTableProps = { + fields: { b_id: string; b_name: string | null | undefined }[] + fieldScores: FieldBln3Score[] + activeCategory: IndicatorCategory | null + showIndex: boolean + basePath: string +} + +/** + * Heatmap table built with TanStack Table for column grouping. + * + * Rendered with a plain (not the shadcn Table wrapper) so that a + * single overflow-auto container handles both axes, making sticky headers + * scroll correctly with horizontal scroll. + */ +export function HeatmapTable({ + fields, + fieldScores, + activeCategory, + showIndex, + basePath, +}: HeatmapTableProps) { + // Build per-field score rows + const data = useMemo(() => { + return fields.map((field) => { + const fs = fieldScores.find((s) => s.b_id === field.b_id) + const scores: FieldRow["scores"] = {} + if (fs?.score) { + for (const ind of fs.score.indicators) { + scores[ind.indicator_id] = { + score01: ind.score, + index01: ind.index, + } + } + } + return { b_id: field.b_id, b_name: field.b_name, scores } + }) + }, [fields, fieldScores]) + + // Column definitions: field column + one group per category + const columns = useMemo[]>(() => { + const fieldCol: ColumnDef = { + id: "field", + accessorKey: "b_name", + header: "Perceel", + cell: ({ row }) => ( + + {row.original.b_name ?? row.original.b_id} + + ), + } + + const categories = activeCategory ? [activeCategory] : INDICATOR_CATEGORIES + const groups: ColumnDef[] = categories.map((cat) => ({ + id: cat, + header: cat, + columns: INDICATORS.filter((i) => i.category === cat).map( + (ind: IndicatorInfo) => ({ + id: ind.id, + // Rotated indicator name header — full name always visible via tooltip + header: () => ( + + + + {ind.name} + + + + {ind.name} + + + ), + cell: ({ row }: { row: { original: FieldRow } }) => { + const vals = row.original.scores[ind.id] + return ( + + ) + }, + }), + ), + })) + + return [fieldCol, ...groups] + }, [activeCategory, showIndex, basePath]) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + // Always 2 header groups: [category group row, indicator name row] + const [categoryGroupRow, indicatorNameRow] = table.getHeaderGroups() + + // Indicator leaf columns (excluding the field column) for the painpoint row + const indicatorLeafHeaders = indicatorNameRow.headers.filter( + (h) => h.column.id !== "field", + ) + + // Painpoint counts: number of fields with display score <40 per indicator + const painpointCounts = useMemo(() => { + const counts = new Map() + const indicators = activeCategory + ? INDICATORS.filter((i) => i.category === activeCategory) + : INDICATORS + for (const ind of indicators) { + let count = 0 + for (const row of data) { + const vals = row.scores[ind.id] + if (!vals) continue + const active01 = showIndex ? vals.index01 : vals.score01 + if (scoreToDisplay(active01) < 40) count++ + } + counts.set(ind.id, count) + } + return counts + }, [data, activeCategory, showIndex]) + + const hasPainpoints = [...painpointCounts.values()].some((c) => c > 0) + + // Shared cell class strings + const thBase = "bg-background px-1 text-xs font-medium text-muted-foreground border-b border-border" + const tdBase = "text-center px-1 py-2 border-b border-border" + const stickyCol = "sticky left-0 z-10 bg-background" + const stickyCorner = "sticky left-0 z-30 bg-background" + + return ( + + {/* + * Single overflow-auto container so sticky thead scrolls correctly + * with horizontal scroll (placing sticky inside a single scroll root). + */} +
+
+ + {/* Row 1: Category group labels (colSpan per group) */} + + {categoryGroupRow.headers.map((header) => { + if (header.isPlaceholder) { + return ( + + ) + })} + + + {/* Row 2: Rotated indicator names */} + + {indicatorNameRow.headers.map((header) => { + if (header.column.id === "field") { + return ( + + ) + } + return ( + + ) + })} + + + + + {/* Painpoint row — always first */} + {hasPainpoints && ( + + + {indicatorLeafHeaders.map((header) => { + const count = + painpointCounts.get(header.column.id) ?? 0 + return ( + + ) + })} + + )} + + {/* Data rows */} + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + if (cell.column.id === "field") { + return ( + + ) + } + return ( + + ) + })} + + ))} + +
+ ) + } + const cat = header.column.id as IndicatorCategory + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ Perceel + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+
+ + + Knelpunten + + + {count > 0 ? ( + + {count} + + ) : null} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/indicators/score-badge.tsx b/fdm-app/app/components/blocks/indicators/score-badge.tsx new file mode 100644 index 000000000..4bbf263d7 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/score-badge.tsx @@ -0,0 +1,34 @@ +import { cn } from "~/lib/utils" +import { getScoreTier, getScoreVerdict } from "~/lib/indicators" + +/** + * Displays a colour-coded Dutch verdict badge for a 0–100 indicator score. + * Green ≥70 · Yellow 40–69 · Red <40. + */ +export function ScoreBadge({ + score, + className, +}: { + score: number + className?: string +}) { + const tier = getScoreTier(score) + const verdict = getScoreVerdict(score) + + return ( + + {verdict} + + ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx new file mode 100644 index 000000000..883444899 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -0,0 +1,142 @@ +import { getFields } from "@nmi-agro/fdm-core" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, + useParams, +} from "react-router" +import { AggregationCard } from "~/components/blocks/indicators/aggregation-card" +import { HeatmapTable } from "~/components/blocks/indicators/heatmap-table" +import { + computeFarmAggregation, + getIndicatorsForFarm, +} from "~/integrations/bln3.server" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError, reportError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { OBI_INDICATOR_IDS, BBWP_INDICATOR_IDS } from "~/lib/indicators" + +export const meta: MetaFunction = () => { + return [ + { + title: `Indicatoren | Bedrijfsoverzicht | ${clientConfig.name}`, + }, + { + name: "description", + content: + "Bedrijfsoverzicht BLN3 bodemkwaliteitsindicatoren per perceel.", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + + const fieldScores = await getIndicatorsForFarm({ + principal_id: session.principal_id, + b_id_farm, + timeframe, + }) + + // Log any per-field errors for observability without failing the page + for (const result of fieldScores) { + if (result.error) { + reportError( + new Error( + `BLN3 score failed for field ${result.b_id}: ${result.error}`, + ), + ) + } + } + + const obiScore = computeFarmAggregation(fieldScores, OBI_INDICATOR_IDS, "score") + const obiIndex = computeFarmAggregation(fieldScores, OBI_INDICATOR_IDS, "index") + const bbwpScore = computeFarmAggregation(fieldScores, BBWP_INDICATOR_IDS, "score") + const bbwpIndex = computeFarmAggregation(fieldScores, BBWP_INDICATOR_IDS, "index") + + return { + fields, + fieldScores, + obiScore, + obiIndex, + bbwpScore, + bbwpIndex, + } + } catch (error) { + const normalized = handleLoaderError(error) + throw normalized ?? error + } +} + +export default function IndicatorsFarmIndex() { + const { fields, fieldScores, obiScore, obiIndex, bbwpScore, bbwpIndex } = + useLoaderData() + const { b_id_farm, calendar } = useParams() + const basePath = `/farm/${b_id_farm}/${calendar}/indicators` + + return ( +
+
+
+

+ Indicatoren +

+

+ BLN3 bodemkwaliteitsindicatoren voor alle percelen op + dit bedrijf. +

+
+
+ + {/* Aggregation cards */} +
+ + +
+ + {/* Heatmap table */} + ({ + b_id: f.b_id, + b_name: f.b_name, + }))} + fieldScores={fieldScores} + activeCategory={null} + showIndex={false} + basePath={basePath} + /> +
+ ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx new file mode 100644 index 000000000..fe9836af2 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx @@ -0,0 +1,92 @@ +import { getFarm, getFarms } from "@nmi-agro/fdm-core" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + Outlet, + useLoaderData, +} from "react-router" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { HeaderIndicators } from "~/components/blocks/header/indicators" +import { SidebarInset } from "~/components/ui/sidebar" +import { getSession } from "~/lib/auth.server" +import { clientConfig } from "~/lib/config" +import { handleLoaderError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" + +export const meta: MetaFunction = () => { + return [ + { title: `Indicatoren | ${clientConfig.name}` }, + { + name: "description", + content: + "Bekijk de BLN3 bodemkwaliteitsindicatoren voor je percelen en bedrijf.", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + + const session = await getSession(request) + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (!farm) { + throw data("not found: b_id_farm", { + status: 404, + statusText: "not found: b_id_farm", + }) + } + + const farms = await getFarms(fdm, session.principal_id) + if (!farms || farms.length === 0) { + throw data("not found: farms", { + status: 404, + statusText: "not found: farms", + }) + } + + const farmOptions = farms.map((f) => ({ + b_id_farm: f.b_id_farm, + b_name_farm: f.b_name_farm, + })) + + return { + farm, + b_id_farm, + farmOptions, + } + } catch (error) { + const normalized = handleLoaderError(error) + throw normalized ?? error + } +} + +export default function IndicatorsLayout() { + const loaderData = useLoaderData() + + return ( + +
+ + +
+
+
+ +
+
+
+ ) +} diff --git a/fdm-app/app/tailwind.css b/fdm-app/app/tailwind.css index 17eae9af7..43e96ff04 100644 --- a/fdm-app/app/tailwind.css +++ b/fdm-app/app/tailwind.css @@ -185,6 +185,14 @@ /* outline: none; */ } +/* BLN3 heatmap: vertical (rotated) column header text */ +.vertical-header { + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + white-space: nowrap; +} + /* MapLibre Geocoder Fixes */ .maplibregl-ctrl-geocoder--icon-close { background-repeat: no-repeat; From e5132d75e115871f5321ea76adeac13658430a00 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 16:42:40 +0200 Subject: [PATCH 03/22] feat: add atlas for indicators --- .../components/blocks/atlas/atlas-styles.tsx | 45 ++++ .../components/blocks/header/indicators.tsx | 51 ++-- .../components/blocks/indicators/atlas.tsx | 200 ++++++++++++++ .../blocks/indicators/category-filter.tsx | 80 ++++++ .../{heatmap-cell.tsx => table-cell.tsx} | 0 .../{heatmap-table.tsx => table.tsx} | 18 +- .../app/components/blocks/sidebar/apps.tsx | 112 +++++--- fdm-app/app/lib/indicators.ts | 13 + ...$b_id_farm.$calendar.indicators._index.tsx | 93 ++++--- ....$b_id_farm.$calendar.indicators.atlas.tsx | 244 ++++++++++++++++++ .../farm.$b_id_farm.$calendar.indicators.tsx | 22 +- 11 files changed, 795 insertions(+), 83 deletions(-) create mode 100644 fdm-app/app/components/blocks/indicators/atlas.tsx create mode 100644 fdm-app/app/components/blocks/indicators/category-filter.tsx rename fdm-app/app/components/blocks/indicators/{heatmap-cell.tsx => table-cell.tsx} (100%) rename fdm-app/app/components/blocks/indicators/{heatmap-table.tsx => table.tsx} (93%) create mode 100644 fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx diff --git a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx index 7b2d82932..ea0081dea 100644 --- a/fdm-app/app/components/blocks/atlas/atlas-styles.tsx +++ b/fdm-app/app/components/blocks/atlas/atlas-styles.tsx @@ -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, + }, + } +} diff --git a/fdm-app/app/components/blocks/header/indicators.tsx b/fdm-app/app/components/blocks/header/indicators.tsx index 42d93b0c3..e5af2f6f7 100644 --- a/fdm-app/app/components/blocks/header/indicators.tsx +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -1,32 +1,53 @@ -import { NavLink } from "react-router" +import { ChevronDown } from "lucide-react" +import { NavLink, useLocation } from "react-router" +import { useCalendarStore } from "@/app/store/calendar" import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, } from "~/components/ui/breadcrumb" -import { useCalendarStore } from "~/store/calendar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" -export function HeaderIndicators({ - b_id_farm, -}: { - b_id_farm: string -}) { +export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { const calendar = useCalendarStore((state) => state.calendar) + const location = useLocation() + const isKaart = location.pathname.includes("/kaart") + const currentName = isKaart ? "Kaart" : "Tabel" return ( <> - - - Indicatoren - + + Indicatoren + + + + + {currentName} + + + + + + Tabel + + + + + Kaart + + + + + ) } diff --git a/fdm-app/app/components/blocks/indicators/atlas.tsx b/fdm-app/app/components/blocks/indicators/atlas.tsx new file mode 100644 index 000000000..b49c868fe --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/atlas.tsx @@ -0,0 +1,200 @@ +/** + * Lazy-loaded map component for the farm indicators overview page. + * Shows farm fields coloured by their average BLN3 score. + * Clicking a field navigates to its detail page. + * + * Import with React.lazy to avoid SSR issues with maplibre-gl. + */ +import maplibregl, { type StyleSpecification } from "maplibre-gl" +import { useCallback, useMemo, useState } from "react" +import { + Layer, + Map as MapGL, + type MapMouseEvent, + type ViewState, + type ViewStateChangeEvent, +} from "react-map-gl/maplibre" +import { useNavigate } from "react-router" +import type { FeatureCollection } from "geojson" +import { MapTilerAttribution } from "~/components/blocks/atlas/atlas-attribution" +import { FieldsSourceNotClickable } from "~/components/blocks/atlas/atlas-sources" +import { + getFieldsScoreOutlineStyle, + getFieldsScoreStyle, +} from "~/components/blocks/atlas/atlas-styles" +import { getViewState } from "~/components/blocks/atlas/atlas-viewstate" + +type IndicatorsMapProps = { + fieldsGeoJSON: FeatureCollection + mapStyle: string | StyleSpecification + basePath: string + /** GeoJSON property name to colour fields by. Defaults to "avgScore". */ + selectedProperty?: string + /** Human-readable label shown in the map legend. */ + label?: string + height?: string +} + +type HoverInfo = { + x: number + y: number + fieldName: string + properties: Record +} | null + +const SCORE_LAYER = "indicatorsScore" +const OUTLINE_LAYER = "indicatorsScoreOutline" +const SOURCE_ID = "indicatorsFields" + +export default function IndicatorsMap({ + fieldsGeoJSON, + mapStyle, + basePath, + selectedProperty = "avgScore", + label, + height = "380px", +}: IndicatorsMapProps) { + const navigate = useNavigate() + const initialViewState = getViewState(fieldsGeoJSON) + const [viewState, setViewState] = useState( + initialViewState as ViewState, + ) + const [hoverInfo, setHoverInfo] = useState(null) + + const onViewportChange = useCallback( + (event: ViewStateChangeEvent) => setViewState(event.viewState), + [], + ) + + const onMouseMove = useCallback((e: MapMouseEvent) => { + const feature = e.features?.[0] + if (feature) { + setHoverInfo({ + x: e.point.x, + y: e.point.y, + fieldName: + (feature.properties?.b_name as string) ?? + (feature.properties?.b_id as string) ?? + "Onbekend perceel", + properties: feature.properties as Record< + string, + number | string | null + >, + }) + } else { + setHoverInfo(null) + } + }, []) + + const onMouseLeave = useCallback(() => setHoverInfo(null), []) + + // Recompute paint expressions only when the active property changes + const scoreStyle = useMemo( + () => getFieldsScoreStyle(SCORE_LAYER, selectedProperty), + [selectedProperty], + ) + const outlineStyle = useMemo( + () => getFieldsScoreOutlineStyle(OUTLINE_LAYER, selectedProperty), + [selectedProperty], + ) + + // Current hover score (reactive to selectedProperty changes) + const hoverScore = + hoverInfo != null && + typeof hoverInfo.properties[selectedProperty] === "number" && + (hoverInfo.properties[selectedProperty] as number) >= 0 + ? (hoverInfo.properties[selectedProperty] as number) + : null + + return ( +
+ { + const b_id = e.features?.[0]?.properties?.b_id as + | string + | undefined + if (b_id) navigate(`${basePath}/${b_id}`) + }} + > + + + + + + + + {/* Hover tooltip */} + {hoverInfo && ( +
+

+ {hoverInfo.fieldName} +

+

+ {hoverScore != null ? ( + <> + Score:{" "} + + {hoverScore} + + + ) : ( + "Geen data" + )} +

+
+ )} + + {/* Legend overlay — pointer-events-none so it doesn't block field clicks */} +
+ {label && ( +

+ {label} +

+ )} +
+
+ 0 + 40 + 70 + 100 +
+
+
+ Geen data +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/indicators/category-filter.tsx b/fdm-app/app/components/blocks/indicators/category-filter.tsx new file mode 100644 index 000000000..b1c80e1d2 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/category-filter.tsx @@ -0,0 +1,80 @@ +import { useSearchParams } from "react-router" +import { cn } from "~/lib/utils" +import { INDICATOR_CATEGORIES, type IndicatorCategory } from "~/lib/indicators" + +const CHIP_ACTIVE: Record = { + Biologisch: + "border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400", + Chemisch: + "border-blue-400 bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", + Fysisch: + "border-stone-400 bg-stone-50 text-stone-700 dark:bg-stone-950/30 dark:text-stone-400", + Grondwater: + "border-cyan-400 bg-cyan-50 text-cyan-700 dark:bg-cyan-950/30 dark:text-cyan-400", + "Nutriënten": + "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400", + Oppervlaktewater: + "border-sky-400 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-400", +} + +const chipBase = + "rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" +const chipInactive = + "border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground" +const chipAllActive = + "border-foreground bg-muted text-foreground" + +/** + * Pill-shaped filter chips for indicator categories. + * Active chip is synced with the `?category=…` URL search param so the + * selection is preserved when sharing or navigating back. + */ +export function CategoryFilter() { + const [searchParams, setSearchParams] = useSearchParams() + const activeCategory = + (searchParams.get("category") as IndicatorCategory | null) ?? null + + const select = (cat: IndicatorCategory | null) => { + setSearchParams( + (prev) => { + if (cat === null) prev.delete("category") + else prev.set("category", cat) + return prev + }, + { preventScrollReset: true }, + ) + } + + return ( +
+ + + {INDICATOR_CATEGORIES.map((cat) => ( + + ))} +
+ ) +} diff --git a/fdm-app/app/components/blocks/indicators/heatmap-cell.tsx b/fdm-app/app/components/blocks/indicators/table-cell.tsx similarity index 100% rename from fdm-app/app/components/blocks/indicators/heatmap-cell.tsx rename to fdm-app/app/components/blocks/indicators/table-cell.tsx diff --git a/fdm-app/app/components/blocks/indicators/heatmap-table.tsx b/fdm-app/app/components/blocks/indicators/table.tsx similarity index 93% rename from fdm-app/app/components/blocks/indicators/heatmap-table.tsx rename to fdm-app/app/components/blocks/indicators/table.tsx index e415685a6..bcbd11808 100644 --- a/fdm-app/app/components/blocks/indicators/heatmap-table.tsx +++ b/fdm-app/app/components/blocks/indicators/table.tsx @@ -21,7 +21,7 @@ import { type IndicatorInfo, } from "~/lib/indicators" import type { FieldBln3Score } from "~/integrations/bln3.server" -import { HeatmapCell } from "./heatmap-cell" +import { HeatmapCell } from "./table-cell" import { cn } from "~/lib/utils" type FieldRow = { @@ -54,6 +54,10 @@ type HeatmapTableProps = { activeCategory: IndicatorCategory | null showIndex: boolean basePath: string + /** Called when the user clicks a column header to pin/unpin that indicator on the map. */ + onIndicatorClick?: (indicatorId: string | null) => void + /** ID of the currently pinned indicator (highlights the column). */ + selectedIndicatorId?: string | null } /** @@ -69,6 +73,8 @@ export function HeatmapTable({ activeCategory, showIndex, basePath, + onIndicatorClick, + selectedIndicatorId, }: HeatmapTableProps) { // Build per-field score rows const data = useMemo(() => { @@ -250,7 +256,17 @@ export function HeatmapTable({ className={cn( thBase, "h-36 w-12 min-w-[48px] pb-2 align-bottom overflow-hidden", + onIndicatorClick && "cursor-pointer hover:bg-muted/40", + selectedIndicatorId === header.column.id && + "bg-muted/60 ring-2 ring-inset ring-primary/50", )} + onClick={() => + onIndicatorClick?.( + selectedIndicatorId === header.column.id + ? null + : header.column.id, + ) + } >
{flexRender( diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 382922e2d..54b29aab2 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -113,6 +113,15 @@ export function SidebarApps() { indicatorsLink = undefined } + let indicatorsKaartLink: string | undefined + if (isCreateFarmWizard) { + indicatorsKaartLink = undefined + } else if (farmId && farmId !== "undefined") { + indicatorsKaartLink = `/farm/${farmId}/${selectedCalendar}/indicators/kaart` + } else { + indicatorsKaartLink = undefined + } + return ( @@ -347,39 +356,82 @@ export function SidebarApps() { )} - - {indicatorsLink ? ( - - - - Indicatoren - - - ) : ( - - + + + {indicatorsLink ? ( + - - - Indicatoren - + + Indicatoren + + - - - Selecteer een bedrijf om de - indicatoren te bekijken - - - )} - + + ) : ( + + + + + + Indicatoren + + + + + Selecteer een bedrijf om de + indicatoren te bekijken + + + )} + + + + {indicatorsLink ? ( + + + Tabel + + + ) : null} + + + {indicatorsKaartLink ? ( + + + Kaart + + + ) : null} + + + + + diff --git a/fdm-app/app/lib/indicators.ts b/fdm-app/app/lib/indicators.ts index 531f7915e..d311e9ab4 100644 --- a/fdm-app/app/lib/indicators.ts +++ b/fdm-app/app/lib/indicators.ts @@ -263,6 +263,19 @@ export const INDICATOR_CATEGORIES: IndicatorCategory[] = [ "Oppervlaktewater", ] +/** + * Short GeoJSON property names for per-category average scores stored on map features. + * Used to drive dynamic map colouring when a category filter is active. + */ +export const CATEGORY_MAP_PROP: Record = { + Biologisch: "avg_bio", + Chemisch: "avg_che", + Fysisch: "avg_fys", + Grondwater: "avg_grw", + "Nutriënten": "avg_nut", + Oppervlaktewater: "avg_opp", +} + // ── Score utilities ───────────────────────────────────────────────────────── /** Convert 0–1 API score to 0–100 display value. */ diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx index 883444899..e54c53b1b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -5,9 +5,11 @@ import { type MetaFunction, useLoaderData, useParams, + useSearchParams, } from "react-router" import { AggregationCard } from "~/components/blocks/indicators/aggregation-card" -import { HeatmapTable } from "~/components/blocks/indicators/heatmap-table" +import { CategoryFilter } from "~/components/blocks/indicators/category-filter" +import { HeatmapTable } from "@/app/components/blocks/indicators/table" import { computeFarmAggregation, getIndicatorsForFarm, @@ -17,7 +19,12 @@ import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError, reportError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { OBI_INDICATOR_IDS, BBWP_INDICATOR_IDS } from "~/lib/indicators" +import { + OBI_INDICATOR_IDS, + BBWP_INDICATOR_IDS, + INDICATOR_CATEGORIES, + type IndicatorCategory, +} from "~/lib/indicators" export const meta: MetaFunction = () => { return [ @@ -58,7 +65,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { timeframe, }) - // Log any per-field errors for observability without failing the page for (const result of fieldScores) { if (result.error) { reportError( @@ -69,10 +75,26 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } - const obiScore = computeFarmAggregation(fieldScores, OBI_INDICATOR_IDS, "score") - const obiIndex = computeFarmAggregation(fieldScores, OBI_INDICATOR_IDS, "index") - const bbwpScore = computeFarmAggregation(fieldScores, BBWP_INDICATOR_IDS, "score") - const bbwpIndex = computeFarmAggregation(fieldScores, BBWP_INDICATOR_IDS, "index") + const obiScore = computeFarmAggregation( + fieldScores, + OBI_INDICATOR_IDS, + "score", + ) + const obiIndex = computeFarmAggregation( + fieldScores, + OBI_INDICATOR_IDS, + "index", + ) + const bbwpScore = computeFarmAggregation( + fieldScores, + BBWP_INDICATOR_IDS, + "score", + ) + const bbwpIndex = computeFarmAggregation( + fieldScores, + BBWP_INDICATOR_IDS, + "index", + ) return { fields, @@ -94,49 +116,56 @@ export default function IndicatorsFarmIndex() { const { b_id_farm, calendar } = useParams() const basePath = `/farm/${b_id_farm}/${calendar}/indicators` + const [searchParams] = useSearchParams() + const rawCategory = searchParams.get("category") + const activeCategory = ( + INDICATOR_CATEGORIES.includes(rawCategory as IndicatorCategory) + ? rawCategory + : null + ) as IndicatorCategory | null + return ( -
-
-
-

- Indicatoren -

-

- BLN3 bodemkwaliteitsindicatoren voor alle percelen op - dit bedrijf. -

-
+
+
+

+ Indicatoren +

+

+ BLN3 bodemkwaliteitsindicatoren voor alle percelen op dit + bedrijf. +

- {/* Aggregation cards */}
- {/* Heatmap table */} - ({ - b_id: f.b_id, - b_name: f.b_name, - }))} - fieldScores={fieldScores} - activeCategory={null} - showIndex={false} - basePath={basePath} - /> +
+ + ({ + b_id: field.b_id, + b_name: field.b_name, + }))} + fieldScores={fieldScores} + activeCategory={activeCategory} + showIndex={false} + basePath={basePath} + /> +
) } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx new file mode 100644 index 000000000..cc8806ba6 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx @@ -0,0 +1,244 @@ +import { getFields } from "@nmi-agro/fdm-core" +import { simplify } from "@turf/simplify" +import type { FeatureCollection, Geometry } from "geojson" +import { Fragment, lazy, Suspense, useState } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, + useParams, +} from "react-router" +import { getIndicatorsForFarm, type FieldBln3Score } from "~/integrations/bln3.server" +import { getMapStyle } from "~/integrations/map" +import { getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError, reportError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { + OBI_INDICATOR_IDS, + BBWP_INDICATOR_IDS, + INDICATORS, + INDICATOR_CATEGORIES, + CATEGORY_MAP_PROP, +} from "~/lib/indicators" +import { cn } from "~/lib/utils" + +const IndicatorsMap = lazy( + () => import("@/app/components/blocks/indicators/atlas"), +) + +const BADGE_BASE = + "rounded-full border px-3 py-1 text-xs font-medium transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring whitespace-nowrap" +const BADGE_ACTIVE = "border-foreground bg-muted text-foreground" +const BADGE_INACTIVE = + "border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground" + +export const meta: MetaFunction = () => { + return [{ title: `Kaart | Indicatoren | ${clientConfig.name}` }] +} + +function computeFieldAvgScore(fs: FieldBln3Score | undefined): number { + if (!fs?.score) return -1 + const vals = fs.score.indicators + .map((ind) => ind.score) + .filter((s) => s != null && !Number.isNaN(s)) + if (vals.length === 0) return -1 + return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const b_id_farm = params.b_id_farm + if (!b_id_farm) { + throw data("invalid: b_id_farm", { + status: 400, + statusText: "invalid: b_id_farm", + }) + } + + const session = await getSession(request) + const timeframe = getTimeframe(params) + + const fields = await getFields( + fdm, + session.principal_id, + b_id_farm, + timeframe, + ) + + const fieldScores = await getIndicatorsForFarm({ + principal_id: session.principal_id, + b_id_farm, + timeframe, + }) + + for (const result of fieldScores) { + if (result.error) { + reportError( + new Error( + `BLN3 score failed for field ${result.b_id}: ${result.error}`, + ), + ) + } + } + + const fieldsGeoJSON: FeatureCollection = { + type: "FeatureCollection", + features: fields.map((field) => { + const fs = fieldScores.find((score) => score.b_id === field.b_id) + + const groupAvg = (ids: string[]): number => { + const scores = ids + .map( + (id) => + fs?.score?.indicators.find( + (indicator) => indicator.indicator_id === id, + )?.score, + ) + .filter((score): score is number => { + return score != null && !Number.isNaN(score) + }) + + return scores.length > 0 + ? Math.round( + (scores.reduce((sum, score) => sum + score, 0) / + scores.length) * + 100, + ) + : -1 + } + + const catProps: Record = { + avg_obi: groupAvg(OBI_INDICATOR_IDS), + avg_bbwp: groupAvg(BBWP_INDICATOR_IDS), + } + + for (const category of INDICATOR_CATEGORIES) { + const categoryIds = INDICATORS.filter( + (indicator) => indicator.category === category, + ).map((indicator) => indicator.id) + catProps[CATEGORY_MAP_PROP[category]] = groupAvg(categoryIds) + } + + const indicatorProps: Record = {} + for (const indicator of INDICATORS) { + const rawScore = fs?.score?.indicators.find( + (item) => item.indicator_id === indicator.id, + )?.score + indicatorProps[indicator.id] = + rawScore != null && !Number.isNaN(rawScore) + ? Math.round(rawScore * 100) + : -1 + } + + return { + type: "Feature" as const, + properties: { + b_id: field.b_id, + b_name: field.b_name ?? null, + avgScore: computeFieldAvgScore(fs), + ...catProps, + ...indicatorProps, + }, + geometry: simplify(field.b_geometry as Geometry, { + tolerance: 0.00001, + highQuality: true, + }), + } + }), + } + + return { + fieldsGeoJSON, + mapStyle: getMapStyle("satellite"), + } + } catch (error) { + const normalized = handleLoaderError(error) + throw normalized ?? error + } +} + +export default function IndicatorsFarmMap() { + const { fieldsGeoJSON, mapStyle } = useLoaderData() + const { b_id_farm, calendar } = useParams() + const basePath = `/farm/${b_id_farm}/${calendar}/indicators` + const [selectedProperty, setSelectedProperty] = useState("avgScore") + const [selectedLabel, setSelectedLabel] = useState("Gemiddelde score") + + return ( +
+ {/* Floating badge panel — wraps on desktop, scrolls on mobile */} +
+
+ + +
+ + {INDICATOR_CATEGORIES.map((category, index) => { + const categoryIndicators = INDICATORS.filter( + (indicator) => indicator.category === category, + ) + + return ( + + + {category} + + {categoryIndicators.map((indicator) => ( + + ))} + {index < INDICATOR_CATEGORIES.length - 1 ? ( +
+ ) : null} + + ) + })} +
+
+ + } + > + + +
+ ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx index fe9836af2..973e45c04 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx @@ -5,6 +5,7 @@ import { type MetaFunction, Outlet, useLoaderData, + useLocation, } from "react-router" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -59,9 +60,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_name_farm: f.b_name_farm, })) + const calendar = params.calendar ?? "" + return { farm, b_id_farm, + calendar, farmOptions, } } catch (error) { @@ -72,20 +76,28 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export default function IndicatorsLayout() { const loaderData = useLoaderData() + const location = useLocation() + const isKaart = location.pathname.includes("/kaart") + + const headerAction = { + label: isKaart ? "Tabel" : "Kaart", + to: isKaart + ? `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators` + : `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators/kaart`, + disabled: false, + } return ( -
+
-
-
- -
+
+
) From df0bc7b78c9f744561c1754cbf9a0567f9f5d537 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 16:59:41 +0200 Subject: [PATCH 04/22] feat: add bln3 info and option to calculate without measures --- .../components/blocks/header/indicators.tsx | 10 +- .../blocks/indicators/aggregation-card.tsx | 8 +- .../blocks/indicators/bln3-help-dialog.tsx | 151 ++++++++++++++++++ .../blocks/indicators/measures-toggle.tsx | 43 +++++ .../app/components/blocks/sidebar/apps.tsx | 2 +- ...$b_id_farm.$calendar.indicators._index.tsx | 41 +++-- .../farm.$b_id_farm.$calendar.indicators.tsx | 6 +- 7 files changed, 232 insertions(+), 29 deletions(-) create mode 100644 fdm-app/app/components/blocks/indicators/bln3-help-dialog.tsx create mode 100644 fdm-app/app/components/blocks/indicators/measures-toggle.tsx diff --git a/fdm-app/app/components/blocks/header/indicators.tsx b/fdm-app/app/components/blocks/header/indicators.tsx index e5af2f6f7..1456c74ec 100644 --- a/fdm-app/app/components/blocks/header/indicators.tsx +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -16,8 +16,8 @@ import { export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { const calendar = useCalendarStore((state) => state.calendar) const location = useLocation() - const isKaart = location.pathname.includes("/kaart") - const currentName = isKaart ? "Kaart" : "Tabel" + const isKaart = location.pathname.includes("/atlas") + const currentName = isKaart ? "Naar kaart" : "Naar tabel" return ( <> @@ -37,12 +37,12 @@ export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { - Tabel + Naar tabel - - Kaart + + Naar kaart diff --git a/fdm-app/app/components/blocks/indicators/aggregation-card.tsx b/fdm-app/app/components/blocks/indicators/aggregation-card.tsx index 7282e2dc4..9f1bce610 100644 --- a/fdm-app/app/components/blocks/indicators/aggregation-card.tsx +++ b/fdm-app/app/components/blocks/indicators/aggregation-card.tsx @@ -98,12 +98,8 @@ export function AggregationCard({ )} {!showIndex && delta !== null && delta > 0 && ( - - {name}: {scoreToDisplay(index01!)} → {scoreToDisplay(score01!)} ( - - +{delta} - - ) + + +{delta} met maatregelen )}
diff --git a/fdm-app/app/components/blocks/indicators/bln3-help-dialog.tsx b/fdm-app/app/components/blocks/indicators/bln3-help-dialog.tsx new file mode 100644 index 000000000..6110bd62f --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/bln3-help-dialog.tsx @@ -0,0 +1,151 @@ +import { HelpCircle } from "lucide-react" +import { Button } from "~/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" + +/** + * Help dialog explaining the BLN3 soil quality framework. + * Triggered by a small "?" button, rendered inline next to the page title. + */ +export function Bln3HelpDialog() { + return ( + + + + + + + Wat is BLN3? + +
+

+ BLN3 staat + voor{" "} + Bodemindicatoren voor Landbouwgronden Nederland{" "} + (versie 3). Het is een wetenschappelijk onderbouwd + systeem om de bodemkwaliteit van landbouwpercelen + objectief te beoordelen aan de hand van 28 indicatoren. +

+ +
+

+ 28 indicatoren in 6 thema's +

+
    + {[ + ["Biologisch", "bodemleven en biodiversiteit"], + ["Chemisch", "nutriëntengehaltes en zuurgraad"], + [ + "Fysisch", + "bodemstructuur en waterhuishouding", + ], + ["Grondwater", "uitspoeling naar grondwater"], + [ + "Nutriënten", + "nutriëntenkringlopen en efficiëntie", + ], + [ + "Oppervlaktewater", + "belasting van oppervlaktewater", + ], + ].map(([cat, desc]) => ( +
  • + + {cat}: + + {desc} +
  • + ))} +
+
+ +
+

+ Scores (0–100) +

+
    +
  • + + ≥ 70 + {" "} + — Goed tot Uitstekend +
  • +
  • + + 40–69 + {" "} + — Matig: aandacht gewenst +
  • +
  • + + < 40 + {" "} + — Onvoldoende: actie nodig +
  • +
+
+ +
+

+ Met vs. zonder maatregelen +

+

+ + Met maatregelen + {" "} + toont de verwachte bodemkwaliteit wanneer aanbevolen + bodemmaatregelen worden toegepast.{" "} + + Zonder maatregelen + {" "} + toont de huidige situatie op basis van uw + bodemanalyses. +

+
+ +
+

+ OBI & BBWP +

+

+ De{" "} + + Open Bodem Index (OBI) + {" "} + is een instrument dat de algehele bodemkwaliteit van + een perceel weergeeft voor agrarische productie, + gebaseerd op de meest relevante BLN3-indicatoren + voor bodemgezondheid. +

+

+ Het{" "} + + BedrijfsBodemWaterPlan (BBWP) + {" "} + is een instrument waarmee telers concrete + maatregelen plannen om de bodem- en waterkwaliteit + op hun bedrijf te verbeteren — voor het verminderen + van nutriëntenuitspoeling en afspoeling en het + verhogen van het waterbergend vermogen. De + BBWP-score geeft aan in hoeverre het perceel + bijdraagt aan deze doelen voor de waterkwaliteit en + kwantiteit. +

+
+
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/indicators/measures-toggle.tsx b/fdm-app/app/components/blocks/indicators/measures-toggle.tsx new file mode 100644 index 000000000..e8bf29398 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/measures-toggle.tsx @@ -0,0 +1,43 @@ +import { useSearchParams } from "react-router" +import { Label } from "~/components/ui/label" +import { Switch } from "~/components/ui/switch" + +/** + * Toggle between "Met maatregelen" (score) and "Zonder maatregelen" (index). + * Syncs with the ?measures=off URL search param for shareability. + */ +export function MeasuresToggle() { + const [searchParams, setSearchParams] = useSearchParams() + const withMeasures = searchParams.get("measures") !== "off" + + const handleToggle = (checked: boolean) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + if (checked) { + next.delete("measures") + } else { + next.set("measures", "off") + } + return next + }, + { preventScrollReset: true }, + ) + } + + return ( +
+ + +
+ ) +} diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 54b29aab2..fbd65a862 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -117,7 +117,7 @@ export function SidebarApps() { if (isCreateFarmWizard) { indicatorsKaartLink = undefined } else if (farmId && farmId !== "undefined") { - indicatorsKaartLink = `/farm/${farmId}/${selectedCalendar}/indicators/kaart` + indicatorsKaartLink = `/farm/${farmId}/${selectedCalendar}/indicators/atlas` } else { indicatorsKaartLink = undefined } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx index e54c53b1b..ff35afb77 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -8,7 +8,9 @@ import { useSearchParams, } from "react-router" import { AggregationCard } from "~/components/blocks/indicators/aggregation-card" +import { Bln3HelpDialog } from "~/components/blocks/indicators/bln3-help-dialog" import { CategoryFilter } from "~/components/blocks/indicators/category-filter" +import { MeasuresToggle } from "~/components/blocks/indicators/measures-toggle" import { HeatmapTable } from "@/app/components/blocks/indicators/table" import { computeFarmAggregation, @@ -117,6 +119,8 @@ export default function IndicatorsFarmIndex() { const basePath = `/farm/${b_id_farm}/${calendar}/indicators` const [searchParams] = useSearchParams() + + // Category filter from URL const rawCategory = searchParams.get("category") const activeCategory = ( INDICATOR_CATEGORIES.includes(rawCategory as IndicatorCategory) @@ -124,37 +128,46 @@ export default function IndicatorsFarmIndex() { : null ) as IndicatorCategory | null + // Measures toggle: default = "met maatregelen" (showIndex=false) + const showIndex = searchParams.get("measures") === "off" + return (
-
-

- Indicatoren -

-

- BLN3 bodemkwaliteitsindicatoren voor alle percelen op dit - bedrijf. -

+
+
+

+ Indicatoren +

+

+ BLN3 bodemkwaliteitsindicatoren voor alle percelen op + dit bedrijf. +

+
+
- +
+ + +
({ b_id: field.b_id, @@ -162,7 +175,7 @@ export default function IndicatorsFarmIndex() { }))} fieldScores={fieldScores} activeCategory={activeCategory} - showIndex={false} + showIndex={showIndex} basePath={basePath} />
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx index 973e45c04..926bdc34d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx @@ -77,13 +77,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export default function IndicatorsLayout() { const loaderData = useLoaderData() const location = useLocation() - const isKaart = location.pathname.includes("/kaart") + const isKaart = location.pathname.includes("/atlas") const headerAction = { - label: isKaart ? "Tabel" : "Kaart", + label: isKaart ? "Naar tabel" : "Naar kaart", to: isKaart ? `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators` - : `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators/kaart`, + : `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators/atlas`, disabled: false, } From 26cbd0c4aff3153b5d64c50a2547d85d1fa1b22c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 17:03:55 +0200 Subject: [PATCH 05/22] feat: select multiple categories at the table --- .../blocks/indicators/category-filter.tsx | 88 +++++++++++++------ .../components/blocks/indicators/table.tsx | 14 +-- ...$b_id_farm.$calendar.indicators._index.tsx | 17 ++-- 3 files changed, 72 insertions(+), 47 deletions(-) diff --git a/fdm-app/app/components/blocks/indicators/category-filter.tsx b/fdm-app/app/components/blocks/indicators/category-filter.tsx index b1c80e1d2..096f03062 100644 --- a/fdm-app/app/components/blocks/indicators/category-filter.tsx +++ b/fdm-app/app/components/blocks/indicators/category-filter.tsx @@ -25,56 +25,88 @@ const chipAllActive = "border-foreground bg-muted text-foreground" /** - * Pill-shaped filter chips for indicator categories. - * Active chip is synced with the `?category=…` URL search param so the - * selection is preserved when sharing or navigating back. + * Parses the `?categories=` URL param into a validated IndicatorCategory array. + * Returns an empty array when no filter is active (= show all). + */ +export function parseActiveCategories( + searchParams: URLSearchParams, +): IndicatorCategory[] { + const raw = searchParams.get("categories") ?? "" + return raw + .split(",") + .map((s) => s.trim()) + .filter((s): s is IndicatorCategory => + INDICATOR_CATEGORIES.includes(s as IndicatorCategory), + ) +} + +/** + * Pill-shaped multi-select filter chips for indicator categories. + * Selection is stored in the `?categories=Chemisch,Fysisch` URL param so it + * is preserved when sharing or navigating back. + * + * Click a chip to toggle it on/off. "Alle" clears the selection (= show all). */ export function CategoryFilter() { const [searchParams, setSearchParams] = useSearchParams() - const activeCategory = - (searchParams.get("category") as IndicatorCategory | null) ?? null + const activeCategories = parseActiveCategories(searchParams) + + const toggle = (cat: IndicatorCategory) => { + setSearchParams( + (prev) => { + const current = parseActiveCategories(prev) + const next = current.includes(cat) + ? current.filter((c) => c !== cat) + : [...current, cat] + if (next.length === 0) prev.delete("categories") + else prev.set("categories", next.join(",")) + return prev + }, + { preventScrollReset: true }, + ) + } - const select = (cat: IndicatorCategory | null) => { + const clearAll = () => { setSearchParams( (prev) => { - if (cat === null) prev.delete("category") - else prev.set("category", cat) + prev.delete("categories") return prev }, { preventScrollReset: true }, ) } + const allActive = activeCategories.length === 0 + return ( -
+
- {INDICATOR_CATEGORIES.map((cat) => ( - - ))} + {INDICATOR_CATEGORIES.map((cat) => { + const isActive = activeCategories.includes(cat) + return ( + + ) + })}
) } diff --git a/fdm-app/app/components/blocks/indicators/table.tsx b/fdm-app/app/components/blocks/indicators/table.tsx index bcbd11808..68c7730d8 100644 --- a/fdm-app/app/components/blocks/indicators/table.tsx +++ b/fdm-app/app/components/blocks/indicators/table.tsx @@ -51,7 +51,7 @@ const CATEGORY_BORDER: Record = { type HeatmapTableProps = { fields: { b_id: string; b_name: string | null | undefined }[] fieldScores: FieldBln3Score[] - activeCategory: IndicatorCategory | null + activeCategories: IndicatorCategory[] showIndex: boolean basePath: string /** Called when the user clicks a column header to pin/unpin that indicator on the map. */ @@ -70,7 +70,7 @@ type HeatmapTableProps = { export function HeatmapTable({ fields, fieldScores, - activeCategory, + activeCategories, showIndex, basePath, onIndicatorClick, @@ -109,7 +109,7 @@ export function HeatmapTable({ ), } - const categories = activeCategory ? [activeCategory] : INDICATOR_CATEGORIES + const categories = activeCategories.length > 0 ? activeCategories : INDICATOR_CATEGORIES const groups: ColumnDef[] = categories.map((cat) => ({ id: cat, header: cat, @@ -147,7 +147,7 @@ export function HeatmapTable({ })) return [fieldCol, ...groups] - }, [activeCategory, showIndex, basePath]) + }, [activeCategories, showIndex, basePath]) const table = useReactTable({ data, @@ -166,8 +166,8 @@ export function HeatmapTable({ // Painpoint counts: number of fields with display score <40 per indicator const painpointCounts = useMemo(() => { const counts = new Map() - const indicators = activeCategory - ? INDICATORS.filter((i) => i.category === activeCategory) + const indicators = activeCategories.length > 0 + ? INDICATORS.filter((i) => activeCategories.includes(i.category)) : INDICATORS for (const ind of indicators) { let count = 0 @@ -180,7 +180,7 @@ export function HeatmapTable({ counts.set(ind.id, count) } return counts - }, [data, activeCategory, showIndex]) + }, [data, activeCategories, showIndex]) const hasPainpoints = [...painpointCounts.values()].some((c) => c > 0) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx index ff35afb77..3bbb2bcfc 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -9,9 +9,9 @@ import { } from "react-router" import { AggregationCard } from "~/components/blocks/indicators/aggregation-card" import { Bln3HelpDialog } from "~/components/blocks/indicators/bln3-help-dialog" -import { CategoryFilter } from "~/components/blocks/indicators/category-filter" +import { CategoryFilter, parseActiveCategories } from "~/components/blocks/indicators/category-filter" import { MeasuresToggle } from "~/components/blocks/indicators/measures-toggle" -import { HeatmapTable } from "@/app/components/blocks/indicators/table" +import { HeatmapTable } from "~/components/blocks/indicators/table" import { computeFarmAggregation, getIndicatorsForFarm, @@ -24,8 +24,6 @@ import { fdm } from "~/lib/fdm.server" import { OBI_INDICATOR_IDS, BBWP_INDICATOR_IDS, - INDICATOR_CATEGORIES, - type IndicatorCategory, } from "~/lib/indicators" export const meta: MetaFunction = () => { @@ -120,13 +118,8 @@ export default function IndicatorsFarmIndex() { const [searchParams] = useSearchParams() - // Category filter from URL - const rawCategory = searchParams.get("category") - const activeCategory = ( - INDICATOR_CATEGORIES.includes(rawCategory as IndicatorCategory) - ? rawCategory - : null - ) as IndicatorCategory | null + // Category filter from URL (multi-select) + const activeCategories = parseActiveCategories(searchParams) // Measures toggle: default = "met maatregelen" (showIndex=false) const showIndex = searchParams.get("measures") === "off" @@ -174,7 +167,7 @@ export default function IndicatorsFarmIndex() { b_name: field.b_name, }))} fieldScores={fieldScores} - activeCategory={activeCategory} + activeCategories={activeCategories} showIndex={showIndex} basePath={basePath} /> From 707bf08c4ed105f54eab692a57ff0a146e8fbb91 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 17:05:20 +0200 Subject: [PATCH 06/22] fix: header text --- fdm-app/app/components/blocks/header/indicators.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fdm-app/app/components/blocks/header/indicators.tsx b/fdm-app/app/components/blocks/header/indicators.tsx index 1456c74ec..babd0de7b 100644 --- a/fdm-app/app/components/blocks/header/indicators.tsx +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -17,7 +17,7 @@ export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { const calendar = useCalendarStore((state) => state.calendar) const location = useLocation() const isKaart = location.pathname.includes("/atlas") - const currentName = isKaart ? "Naar kaart" : "Naar tabel" + const currentName = isKaart ? "Kaart" : "Tabel" return ( <> @@ -37,12 +37,12 @@ export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { - Naar tabel + Tabel - Naar kaart + Kaart From 9da44c60ba2a57ab5369e52ca9632235fbec1c32 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 17:07:43 +0200 Subject: [PATCH 07/22] fix: sidebar selection --- fdm-app/app/components/blocks/sidebar/apps.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index fbd65a862..8813267d3 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -404,7 +404,7 @@ export function SidebarApps() { indicatorsLink, ) && !location.pathname.includes( - "/kaart", + "/atlas", ) } > From da09a073514260f098b4952b5957f58aba5dae8d Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 17:11:07 +0200 Subject: [PATCH 08/22] docs: add changeset --- .changeset/green-fields-shine.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/green-fields-shine.md diff --git a/.changeset/green-fields-shine.md b/.changeset/green-fields-shine.md new file mode 100644 index 000000000..a87e73487 --- /dev/null +++ b/.changeset/green-fields-shine.md @@ -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. From 52265225c02167b408f201d5397b0035b57dfce5 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 12 May 2026 17:17:51 +0200 Subject: [PATCH 09/22] fix: sidebar highlighting --- fdm-app/app/components/blocks/sidebar/apps.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fdm-app/app/components/blocks/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 8813267d3..6fcadc460 100644 --- a/fdm-app/app/components/blocks/sidebar/apps.tsx +++ b/fdm-app/app/components/blocks/sidebar/apps.tsx @@ -129,16 +129,14 @@ export function SidebarApps() { {atlasLink ? ( Atlas From 7d0d91ae1dadb0e32744124c69fb6b85f322f064 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 12:55:47 +0200 Subject: [PATCH 10/22] fix: Use the route calendar param for link generation --- .../components/blocks/header/indicators.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/header/indicators.tsx b/fdm-app/app/components/blocks/header/indicators.tsx index babd0de7b..f88518e74 100644 --- a/fdm-app/app/components/blocks/header/indicators.tsx +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -1,5 +1,5 @@ import { ChevronDown } from "lucide-react" -import { NavLink, useLocation } from "react-router" +import { NavLink, useLocation, useParams } from "react-router" import { useCalendarStore } from "@/app/store/calendar" import { BreadcrumbItem, @@ -14,7 +14,9 @@ import { } from "~/components/ui/dropdown-menu" export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { - const calendar = useCalendarStore((state) => state.calendar) + 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" @@ -23,7 +25,9 @@ export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { <> - + Indicatoren @@ -36,12 +40,16 @@ export function HeaderIndicators({ b_id_farm }: { b_id_farm: string }) { - + Tabel - + Kaart From cc42ce1bc74f43b0db0e92e14f179025bb21c11f Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 12:58:02 +0200 Subject: [PATCH 11/22] fix: make table keybaord accessible --- .../components/blocks/indicators/table.tsx | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/fdm-app/app/components/blocks/indicators/table.tsx b/fdm-app/app/components/blocks/indicators/table.tsx index 68c7730d8..a76e09489 100644 --- a/fdm-app/app/components/blocks/indicators/table.tsx +++ b/fdm-app/app/components/blocks/indicators/table.tsx @@ -256,24 +256,41 @@ export function HeatmapTable({ className={cn( thBase, "h-36 w-12 min-w-[48px] pb-2 align-bottom overflow-hidden", - onIndicatorClick && "cursor-pointer hover:bg-muted/40", - selectedIndicatorId === header.column.id && - "bg-muted/60 ring-2 ring-inset ring-primary/50", )} - onClick={() => - onIndicatorClick?.( - selectedIndicatorId === header.column.id - ? null - : header.column.id, - ) - } > -
- {flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
+ {onIndicatorClick ? ( + + ) : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ )} ) })} From 788cfea5cdba88d56a6e30a928fc3a57a7ce1c8e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 12:58:46 +0200 Subject: [PATCH 12/22] fix: averaging when nan --- fdm-app/app/integrations/bln3.server.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/fdm-app/app/integrations/bln3.server.ts b/fdm-app/app/integrations/bln3.server.ts index a9eeef168..aaa0fcd0c 100644 --- a/fdm-app/app/integrations/bln3.server.ts +++ b/fdm-app/app/integrations/bln3.server.ts @@ -13,11 +13,7 @@ import { type Bln3Score, type Bln3ScoreInputs, } from "@nmi-agro/fdm-calculator" -import { - getFields, - type PrincipalId, - type Timeframe, -} from "@nmi-agro/fdm-core" +import { getFields, type PrincipalId, type Timeframe } from "@nmi-agro/fdm-core" import { getNmiApiKey } from "~/integrations/nmi.server" import { fdm } from "~/lib/fdm.server" @@ -45,8 +41,16 @@ export async function getIndicatorsForField({ }): Promise { const nmiApiKey = getNmiApiKey() - const inputs = await collectInputForBln3Score(fdm, principal_id, b_id, timeframe) - const score = await getBln3Score(fdm, { ...inputs, nmiApiKey } as Bln3ScoreInputs) + const inputs = await collectInputForBln3Score( + fdm, + principal_id, + b_id, + timeframe, + ) + const score = await getBln3Score(fdm, { + ...inputs, + nmiApiKey, + } as Bln3ScoreInputs) return score } @@ -111,6 +115,7 @@ export function computeFarmAggregation( for (const indicator of score.indicators) { if (!indicatorIds.includes(indicator.indicator_id)) continue const value = mode === "score" ? indicator.score : indicator.index + if (value == null || Number.isNaN(value)) continue allValues.push(value) } } From 95f2480d518c58aaba67d87aa946b9ac32e2ef3e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 13:00:36 +0200 Subject: [PATCH 13/22] fix: Avoid fetching fields twice per request path. --- fdm-app/app/integrations/bln3.server.ts | 7 +++++-- .../routes/farm.$b_id_farm.$calendar.indicators._index.tsx | 1 + .../routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/integrations/bln3.server.ts b/fdm-app/app/integrations/bln3.server.ts index aaa0fcd0c..cb7fb7789 100644 --- a/fdm-app/app/integrations/bln3.server.ts +++ b/fdm-app/app/integrations/bln3.server.ts @@ -13,7 +13,7 @@ import { type Bln3Score, type Bln3ScoreInputs, } from "@nmi-agro/fdm-calculator" -import { getFields, type PrincipalId, type Timeframe } from "@nmi-agro/fdm-core" +import { getFields, type Field, type PrincipalId, type Timeframe } from "@nmi-agro/fdm-core" import { getNmiApiKey } from "~/integrations/nmi.server" import { fdm } from "~/lib/fdm.server" @@ -64,12 +64,15 @@ export async function getIndicatorsForFarm({ principal_id, b_id_farm, timeframe, + preloadedFields, }: { principal_id: PrincipalId b_id_farm: string timeframe?: Timeframe + preloadedFields?: Field[] }): Promise { - const fields = await getFields(fdm, principal_id, b_id_farm, timeframe) + const fields = + preloadedFields ?? (await getFields(fdm, principal_id, b_id_farm, timeframe)) const results = await Promise.allSettled( fields.map((field) => diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx index 3bbb2bcfc..3d5c0daca 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -63,6 +63,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { principal_id: session.principal_id, b_id_farm, timeframe, + preloadedFields: fields, }) for (const result of fieldScores) { diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx index cc8806ba6..b71a89475 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx @@ -72,6 +72,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { principal_id: session.principal_id, b_id_farm, timeframe, + preloadedFields: fields, }) for (const result of fieldScores) { From 3fffaedd2426252e40309532ebd8a1905eb3e277 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 13 May 2026 13:27:41 +0200 Subject: [PATCH 14/22] fix: revert ui change --- fdm-app/app/components/ui/breadcrumb.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fdm-app/app/components/ui/breadcrumb.tsx b/fdm-app/app/components/ui/breadcrumb.tsx index 5c5a69baf..5f4eb065d 100644 --- a/fdm-app/app/components/ui/breadcrumb.tsx +++ b/fdm-app/app/components/ui/breadcrumb.tsx @@ -1,5 +1,5 @@ import { ChevronRight, MoreHorizontal } from "lucide-react" -import { Slot as SlotPrimitive } from "radix-ui" +import { Slot } from "radix-ui" import * as React from "react" import { cn } from "~/lib/utils" @@ -45,7 +45,7 @@ const BreadcrumbLink = React.forwardRef< asChild?: boolean } >(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a" + const Comp = asChild ? Slot : "a" return ( Date: Wed, 13 May 2026 13:44:08 +0200 Subject: [PATCH 15/22] refactor: show the indicators via dropdown instead of badges --- ....$b_id_farm.$calendar.indicators.atlas.tsx | 112 ++++++++---------- 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx index b71a89475..9a7661bdd 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx @@ -1,7 +1,7 @@ import { getFields } from "@nmi-agro/fdm-core" import { simplify } from "@turf/simplify" import type { FeatureCollection, Geometry } from "geojson" -import { Fragment, lazy, Suspense, useState } from "react" +import { lazy, Suspense, useState } from "react" import { data, type LoaderFunctionArgs, @@ -23,18 +23,22 @@ import { INDICATOR_CATEGORIES, CATEGORY_MAP_PROP, } from "~/lib/indicators" -import { cn } from "~/lib/utils" +import { Card, CardContent } from "~/components/ui/card" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" const IndicatorsMap = lazy( () => import("@/app/components/blocks/indicators/atlas"), ) -const BADGE_BASE = - "rounded-full border px-3 py-1 text-xs font-medium transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring whitespace-nowrap" -const BADGE_ACTIVE = "border-foreground bg-muted text-foreground" -const BADGE_INACTIVE = - "border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground" - export const meta: MetaFunction = () => { return [{ title: `Kaart | Indicatoren | ${clientConfig.name}` }] } @@ -166,67 +170,45 @@ export default function IndicatorsFarmMap() { const { b_id_farm, calendar } = useParams() const basePath = `/farm/${b_id_farm}/${calendar}/indicators` const [selectedProperty, setSelectedProperty] = useState("avgScore") - const [selectedLabel, setSelectedLabel] = useState("Gemiddelde score") + + const selectedLabel = + selectedProperty === "avgScore" + ? "Gemiddelde score" + : (INDICATORS.find((i) => i.id === selectedProperty)?.name ?? selectedProperty) return (
- {/* Floating badge panel — wraps on desktop, scrolls on mobile */} -
-
-