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. 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 new file mode 100644 index 000000000..f88518e74 --- /dev/null +++ b/fdm-app/app/components/blocks/header/indicators.tsx @@ -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 ( + <> + + + + Indicatoren + + + + + + + {currentName} + + + + + + Tabel + + + + + Kaart + + + + + + + ) +} 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..2e7a6de70 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/aggregation-card.tsx @@ -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 ( + + + +
+

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

{name}

+ + + + ) +} 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..be1f92a7a --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/atlas.tsx @@ -0,0 +1,238 @@ +/** + * 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" +import { getScoreColor, getScoreVerdict } from "~/lib/indicators" + +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} +

+ {hoverInfo.properties.b_area != null && ( +

+ {Number(hoverInfo.properties.b_area).toFixed(2)} ha +

+ )} + {label && ( +
+ + {label} + + {hoverScore != null ? ( + + {hoverScore} –{" "} + {getScoreVerdict(hoverScore)} + + ) : ( + + Geen data + + )} +
+ )} + {!label && ( +

+ {hoverScore != null ? ( + <> + Score:{" "} + + {hoverScore} + + {" – "} + {getScoreVerdict(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/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/category-filter.tsx b/fdm-app/app/components/blocks/indicators/category-filter.tsx new file mode 100644 index 000000000..a98c522bf --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/category-filter.tsx @@ -0,0 +1,77 @@ +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" + +type CategoryFilterProps = { + /** Currently active categories (empty = show all). */ + activeCategories: IndicatorCategory[] + /** Called when a category chip is toggled. */ + onToggle: (category: IndicatorCategory) => void + /** Called when "Alle" is clicked. */ + onClearAll: () => void +} + +/** + * Pill-shaped multi-select filter chips for indicator categories. + * Driven by props — parent owns the state for instant client-side filtering. + */ +export function CategoryFilter({ + activeCategories, + onToggle, + onClearAll, +}: CategoryFilterProps) { + const allActive = activeCategories.length === 0 + + return ( +
+ + + {INDICATOR_CATEGORIES.map((cat) => { + const isActive = activeCategories.includes(cat) + return ( + + ) + })} +
+ ) +} 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..1537e9293 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/measures-toggle.tsx @@ -0,0 +1,31 @@ +import { Label } from "~/components/ui/label" +import { Switch } from "~/components/ui/switch" + +type MeasuresToggleProps = { + /** Whether "Met maatregelen" is active (shows score). */ + withMeasures: boolean + /** Called when the user toggles the switch. */ + onToggle: (withMeasures: boolean) => void +} + +/** + * Toggle between "Met maatregelen" (score) and "Zonder maatregelen" (index). + * Driven by props — parent owns the state for instant client-side switching. + */ +export function MeasuresToggle({ withMeasures, onToggle }: MeasuresToggleProps) { + return ( +
+ + +
+ ) +} 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/components/blocks/indicators/table-cell.tsx b/fdm-app/app/components/blocks/indicators/table-cell.tsx new file mode 100644 index 000000000..9c10d1c28 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/table-cell.tsx @@ -0,0 +1,83 @@ +import { memo } from "react" +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 const HeatmapCell = memo(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/table.tsx b/fdm-app/app/components/blocks/indicators/table.tsx new file mode 100644 index 000000000..015a7efb5 --- /dev/null +++ b/fdm-app/app/components/blocks/indicators/table.tsx @@ -0,0 +1,376 @@ +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 "./table-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[] + activeCategories: IndicatorCategory[] + 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 +} + +/** + * 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, + activeCategories, + showIndex, + basePath, + onIndicatorClick, + selectedIndicatorId, +}: 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 = activeCategories.length > 0 ? activeCategories : 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] + }, [activeCategories, 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 = activeCategories.length > 0 + ? INDICATORS.filter((i) => activeCategories.includes(i.category)) + : 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, activeCategories, 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 + + {onIndicatorClick ? ( + + ) : ( +
+ {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/sidebar/apps.tsx b/fdm-app/app/components/blocks/sidebar/apps.tsx index 179fa4813..6fcadc460 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,24 @@ 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 + } + + let indicatorsKaartLink: string | undefined + if (isCreateFarmWizard) { + indicatorsKaartLink = undefined + } else if (farmId && farmId !== "undefined") { + indicatorsKaartLink = `/farm/${farmId}/${selectedCalendar}/indicators/atlas` + } else { + indicatorsKaartLink = undefined + } + return ( @@ -110,16 +129,14 @@ export function SidebarApps() { {atlasLink ? ( Atlas @@ -337,6 +354,82 @@ export function SidebarApps() { )} + + + {indicatorsLink ? ( + + + + Indicatoren + + + + + ) : ( + + + + + + Indicatoren + + + + + Selecteer een bedrijf om de + indicatoren te bekijken + + + )} + + + + {indicatorsLink ? ( + + + Tabel + + + ) : null} + + + {indicatorsKaartLink ? ( + + + Kaart + + + ) : null} + + + + + diff --git a/fdm-app/app/integrations/bln3.server.ts b/fdm-app/app/integrations/bln3.server.ts new file mode 100644 index 000000000..7f654e062 --- /dev/null +++ b/fdm-app/app/integrations/bln3.server.ts @@ -0,0 +1,106 @@ +/** + * @file bln3.server.ts + * + * Server-side orchestration layer for the BLN3 Indicatoren feature. + * + * Acts as a thin bridge between route loaders and fdm-calculator, following + * the same pattern as mineralization.server.ts and calculator.ts. + */ + +import { + collectInputForBln3Score, + getBln3Score, + type Bln3Score, +} from "@nmi-agro/fdm-calculator" +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" + +export type { Bln3Score } + +export type FieldBln3Score = { + b_id: string + score: Bln3Score | null + error: string | null +} + +/** + * Collects all inputs for a single field and calculates its BLN3 score. + * + * Returns null if the NMI API key is not configured or if data collection fails. + */ +export async function getIndicatorsForField({ + principal_id, + b_id, + timeframe, +}: { + principal_id: PrincipalId + b_id: string + timeframe?: Timeframe +}): Promise { + const nmiApiKey = getNmiApiKey() + + const inputs = await collectInputForBln3Score( + fdm, + principal_id, + b_id, + timeframe, + ) + const score = await getBln3Score(fdm, { + ...inputs, + nmiApiKey, + }) + 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, + preloadedFields, +}: { + principal_id: PrincipalId + b_id_farm: string + timeframe?: Timeframe + preloadedFields?: Field[] +}): Promise { + const fields = + preloadedFields ?? (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. + * + * Re-exported from ~/lib/bln3.ts for backwards compatibility with server-side callers. + */ +export { computeFarmAggregation } from "~/lib/bln3" diff --git a/fdm-app/app/lib/bln3.ts b/fdm-app/app/lib/bln3.ts new file mode 100644 index 000000000..336113e75 --- /dev/null +++ b/fdm-app/app/lib/bln3.ts @@ -0,0 +1,33 @@ +import type { FieldBln3Score } from "~/integrations/bln3.server" + +/** + * Computes a farm-level average score for a given set of indicator IDs. + * Only fields with available scores contribute to the average. + * + * This is a pure function (no I/O) so it can run on both server and client. + * + * @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 + if (value == null || Number.isNaN(value)) continue + 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..d311e9ab4 --- /dev/null +++ b/fdm-app/app/lib/indicators.ts @@ -0,0 +1,320 @@ +/** + * 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", +] + +/** + * 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. */ +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) +} 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..4c8678573 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators._index.tsx @@ -0,0 +1,265 @@ +import { getFields } from "@nmi-agro/fdm-core" +import { useEffect, useMemo, useState, useTransition } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + useLoaderData, + useParams, +} 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 "~/components/blocks/indicators/table" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Separator } from "~/components/ui/separator" +import { getIndicatorsForFarm } from "~/integrations/bln3.server" +import { getSession } from "~/lib/auth.server" +import { computeFarmAggregation } from "~/lib/bln3" +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, + type IndicatorCategory, +} from "~/lib/indicators" +import { Label } from "~/components/ui/label" +import { Switch } from "~/components/ui/switch" +import { Input } from "~/components/ui/input" +import { cn } from "~/lib/utils" + +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, + preloadedFields: fields, + }) + + for (const result of fieldScores) { + if (result.error) { + reportError( + new Error( + `BLN3 score failed for field ${result.b_id}: ${result.error}`, + ), + ) + } + } + + return { + fields: fields.map((f) => ({ + b_id: f.b_id, + b_name: f.b_name, + b_bufferstrip: f.b_bufferstrip ?? false, + })), + fieldScores, + } + } catch (error) { + const normalized = handleLoaderError(error) + throw normalized ?? error + } +} + +export default function IndicatorsFarmIndex() { + const { fields, fieldScores } = useLoaderData() + const { b_id_farm, calendar } = useParams() + const basePath = `/farm/${b_id_farm}/${calendar}/indicators` + + const [activeCategories, setActiveCategories] = useState([]) + const [withMeasures, setWithMeasures] = useState(true) + const [hideBufferstrips, setHideBufferstrips] = useState(true) + const [fieldSearch, setFieldSearch] = useState("") + const [isPending, startTransition] = useTransition() + + // Debounce the pending indicator to avoid flickering on fast transitions + const [showPending, setShowPending] = useState(false) + useEffect(() => { + if (!isPending) { setShowPending(false); return } + const id = setTimeout(() => setShowPending(true), 150) + return () => clearTimeout(id) + }, [isPending]) + + const showIndex = !withMeasures + + const handleToggleCategory = (cat: IndicatorCategory) => { + startTransition(() => { + setActiveCategories((prev) => + prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat], + ) + }) + } + + const handleClearCategories = () => { + startTransition(() => setActiveCategories([])) + } + + const handleToggleMeasures = (checked: boolean) => { + startTransition(() => setWithMeasures(checked)) + } + + const handleToggleBufferstrips = (checked: boolean) => { + startTransition(() => setHideBufferstrips(!checked)) + } + + // Filter fields based on bufferstrip toggle and search text + const filteredFields = useMemo(() => { + let result = hideBufferstrips + ? fields.filter((f) => !f.b_bufferstrip) + : fields + if (fieldSearch) { + const q = fieldSearch.toLowerCase() + result = result.filter((f) => + (f.b_name ?? f.b_id).toLowerCase().includes(q), + ) + } + return result + }, [fields, hideBufferstrips, fieldSearch]) + const filteredFieldIds = useMemo( + () => new Set(filteredFields.map((f) => f.b_id)), + [filteredFields], + ) + const filteredScores = useMemo( + () => fieldScores.filter((s) => filteredFieldIds.has(s.b_id)), + [fieldScores, filteredFieldIds], + ) + + // Compute aggregations client-side so they react to the bufferstrip filter + const obiScore = useMemo( + () => computeFarmAggregation(filteredScores, OBI_INDICATOR_IDS, "score"), + [filteredScores], + ) + const obiIndex = useMemo( + () => computeFarmAggregation(filteredScores, OBI_INDICATOR_IDS, "index"), + [filteredScores], + ) + const bbwpScore = useMemo( + () => computeFarmAggregation(filteredScores, BBWP_INDICATOR_IDS, "score"), + [filteredScores], + ) + const bbwpIndex = useMemo( + () => computeFarmAggregation(filteredScores, BBWP_INDICATOR_IDS, "index"), + [filteredScores], + ) + + return ( + <> + + +
+ {/* Aggregations section */} +
+
+

+ Scores +

+ +
+
+ + +
+
+ + + + {/* Indicator table section */} +
+
+ +
+ setFieldSearch(e.target.value)} + className="w-44 h-8 text-sm" + /> +
+ + +
+ +
+
+ ({ + b_id: field.b_id, + b_name: field.b_name, + }))} + fieldScores={filteredScores} + activeCategories={activeCategories} + showIndex={showIndex} + 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..847fe253a --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.atlas.tsx @@ -0,0 +1,228 @@ +import { getFields } from "@nmi-agro/fdm-core" +import { simplify } from "@turf/simplify" +import type { FeatureCollection, Geometry } from "geojson" +import { 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 { 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"), +) + +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, + preloadedFields: fields, + }) + + 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, + b_area: field.b_area ?? 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 = + selectedProperty === "avgScore" + ? "Gemiddelde score" + : (INDICATORS.find((i) => i.id === selectedProperty)?.name ?? selectedProperty) + + return ( +
+ {/* Floating indicator selector panel */} + + + + + + + } + > + + +
+ ) +} 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..926bdc34d --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.indicators.tsx @@ -0,0 +1,104 @@ +import { getFarm, getFarms } from "@nmi-agro/fdm-core" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + Outlet, + useLoaderData, + useLocation, +} 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, + })) + + const calendar = params.calendar ?? "" + + return { + farm, + b_id_farm, + calendar, + farmOptions, + } + } catch (error) { + const normalized = handleLoaderError(error) + throw normalized ?? error + } +} + +export default function IndicatorsLayout() { + const loaderData = useLoaderData() + const location = useLocation() + const isKaart = location.pathname.includes("/atlas") + + const headerAction = { + label: isKaart ? "Naar tabel" : "Naar kaart", + to: isKaart + ? `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators` + : `/farm/${loaderData.b_id_farm}/${loaderData.calendar}/indicators/atlas`, + disabled: false, + } + + return ( + +
+ + +
+
+ +
+
+ ) +} diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index b0f513f07..68936a8d1 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -143,7 +143,7 @@ export default function App() { userName={loaderData.userName} /> - + diff --git a/fdm-app/app/tailwind.css b/fdm-app/app/tailwind.css index 17eae9af7..debbd0480 100644 --- a/fdm-app/app/tailwind.css +++ b/fdm-app/app/tailwind.css @@ -185,6 +185,13 @@ /* outline: none; */ } +/* BLN3 heatmap: vertical (rotated) column header text */ +.vertical-header { + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); +} + /* MapLibre Geocoder Fixes */ .maplibregl-ctrl-geocoder--icon-close { background-repeat: no-repeat; diff --git a/fdm-calculator/src/bln3/input.ts b/fdm-calculator/src/bln3/input.ts index daa6e7310..03fde78c1 100644 --- a/fdm-calculator/src/bln3/input.ts +++ b/fdm-calculator/src/bln3/input.ts @@ -89,8 +89,10 @@ export async function collectInputForBln3Score( return { a_lat, a_lon, - b_soiltype_agr: latestAnalysis?.b_soiltype_agr ?? undefined, - b_gwl_class: latestAnalysis?.b_gwl_class ?? undefined, + b_soiltype_agr: + (latestAnalysis?.b_soiltype_agr ?? undefined) as Bln3ScoreCollectedInputs["b_soiltype_agr"], + b_gwl_class: + (latestAnalysis?.b_gwl_class ?? undefined) as Bln3ScoreCollectedInputs["b_gwl_class"], ...(bln3Cultivations.length > 0 && { cultivations: bln3Cultivations, }), diff --git a/fdm-calculator/src/bln3/types.d.ts b/fdm-calculator/src/bln3/types.d.ts index f5b14a844..52e63728a 100644 --- a/fdm-calculator/src/bln3/types.d.ts +++ b/fdm-calculator/src/bln3/types.d.ts @@ -21,16 +21,11 @@ export type Bln3Measure = { } /** - * Input parameters for the BLN3 score calculation. - * Maps to the request body of `POST /maatwerk/bln3/score/field`. - * - * Only `a_lat` and `a_lon` are required by the NMI API. - * All other fields are optional and improve calculation quality when provided. + * Input parameters for the BLN3 score calculation, assembled from the FDM + * database. Only `a_lat` and `a_lon` are required by the NMI API; all other + * fields are optional and improve calculation quality when provided. */ -export type Bln3ScoreInputs = { - /** NMI API key for authentication — redacted from cache hash */ - nmiApiKey: string | undefined - +export type Bln3ScoreCollectedInputs = { // ── Location (required) ────────────────────────────────────────────────── /** Latitude of the field centroid (WGS84; EPSG:4326) */ a_lat: number @@ -99,6 +94,15 @@ export type Bln3ScoreInputs = { [key: string]: unknown } +/** + * Full inputs for `getBln3Score`: collected field data plus the NMI API key. + * Maps to the request body of `POST /maatwerk/bln3/score/field`. + */ +export type Bln3ScoreInputs = Bln3ScoreCollectedInputs & { + /** NMI API key for authentication — redacted from cache hash */ + nmiApiKey: string | undefined +} + /** * A single indicator result from the BLN3 score calculation. */ @@ -128,12 +132,6 @@ export type Bln3AggregationResult = { score: number } -/** - * Field data for a BLN3 score request, assembled from the FDM database. - * Passed to `getBln3Score` together with `nmiApiKey`. - */ -export type Bln3ScoreCollectedInputs = Omit - /** * The BLN3 score result returned by `requestBln3Score` / `getBln3Score`. */