Concept project · runs in your browserTypeScript · React.js · Next.js · Leaflet
NDVI Time-Series Viewer Demo
Six fictional fields near Sargodha with a season of synthetic Sentinel-2-style observations. Scrub or play through the dates to recolour the map by NDVI, compare up to three fields on the chart, and tune the drop detector that separates a stress dip from normal ripening. The data is generated, and labelled as such.
Interactive demo
NDVI
cloud masked (>60%)
season NDVI
- Field 3 · Wheat (stress)
- Field 1 · Wheat (on time)
- rolling mean
Click or drag on the chart to move the date. Grey ticks under the axis are cloud-masked dates.
Data table for Field 3 · Wheat (stress)
| date | ndvi | red B4 | nir B8 | cloud % | flag |
|---|---|---|---|---|---|
| 2025-11-05 | 0.176 | 0.1123 | 0.1603 | 5 | |
| 2025-11-10 | 0.182 | 0.1100 | 0.1589 | 17 | |
| 2025-11-15 | 0.215 | 0.1028 | 0.1592 | 18 | |
| 2025-11-20 | 0.224 | 0.1085 | 0.1711 | 18 | |
| 2025-11-25 | 0.271 | 0.0979 | 0.1706 | 2 | |
| 2025-11-30 | 0.302 | 0.1013 | 0.1890 | 0 | |
| 2025-12-05 | 0.340 | 0.0919 | 0.1867 | 19 | |
| 2025-12-10 | 0.406 | 0.0867 | 0.2051 | 25 | |
| 2025-12-15 | — | — | — | 100 | masked |
| 2025-12-20 | — | — | — | 71 | masked |
| 2025-12-25 | — | — | — | 71 | masked |
| 2025-12-30 | 0.665 | 0.0663 | 0.3298 | 22 | |
| 2026-01-04 | 0.719 | 0.0565 | 0.3460 | 2 | |
| 2026-01-09 | — | — | — | 75 | masked |
| 2026-01-14 | — | — | — | 91 | masked |
| 2026-01-19 | — | — | — | 100 | masked |
| 2026-01-24 | 0.778 | 0.0554 | 0.4434 | 16 | |
| 2026-01-29 | 0.784 | 0.0563 | 0.4656 | 14 | |
| 2026-02-03 | 0.787 | 0.0572 | 0.4801 | 23 | |
| 2026-02-08 | — | — | — | 74 | masked |
| 2026-02-13 | 0.694 | 0.0652 | 0.3605 | 15 | dip |
| 2026-02-18 | 0.575 | 0.0741 | 0.2743 | 16 | dip |
| 2026-02-23 | 0.574 | 0.0737 | 0.2723 | 29 | dip |
| 2026-02-28 | 0.606 | 0.0726 | 0.2957 | 18 | |
| 2026-03-05 | 0.651 | 0.0704 | 0.3328 | 16 | |
| 2026-03-10 | 0.620 | 0.0673 | 0.2866 | 23 | |
| 2026-03-15 | 0.594 | 0.0725 | 0.2851 | 1 | |
| 2026-03-20 | 0.537 | 0.0789 | 0.2618 | 1 | decline |
| 2026-03-25 | 0.494 | 0.0778 | 0.2295 | 6 | decline |
| 2026-03-30 | 0.426 | 0.0843 | 0.2094 | 9 | decline |
| 2026-04-04 | 0.364 | 0.0891 | 0.1909 | 22 | decline |
| 2026-04-09 | 0.308 | 0.0995 | 0.1880 | 24 | decline |
| 2026-04-14 | 0.255 | 0.0993 | 0.1674 | 10 | decline |
| 2026-04-19 | 0.219 | 0.1052 | 0.1643 | 19 | decline |
| 2026-04-24 | — | — | — | 74 | masked |
| 2026-04-29 | — | — | — | 74 | masked |
## How it works
Each observation stores mean red (B4) and near-infrared (B8) reflectance for a field, and NDVI is computed from them as (NIR − Red) / (NIR + Red). Observations with more than 60 percent cloud are masked: the chart breaks the line and the map shows a dashed grey field instead of a misleading low value.
The drop detector compares each clear observation with the mean of the previous three clear ones. Flags on consecutive clear dates are grouped into one episode, and the episode is called a dip if NDVI climbs back within three clear dates, or a decline if it does not. That keeps normal ripening from showing up as a run of alarms.
The synthetic series come from double-logistic growth curves per crop (on-time wheat, late wheat, stressed wheat, berseem with cuts, sugarcane with a harvest, fallow) with a seeded random generator, so server and browser produce identical numbers.
## View source
The code running in the window above, exactly as deployed.
app/lab/ndvi-time-series-viewer/ndvi.ts252 lines
/**
* NDVI time series: synthetic Sentinel-2-like observations, cloud masking,
* rolling-mean anomaly detection, colour scale and CSV export.
*
* The data is SYNTHETIC. It is generated from crop growth curves with a fixed seed so it
* looks like a real Rabi season (Nov–Apr) in Punjab, but no satellite was involved.
* In production the same `Observation` shape would come from a zonal-statistics job
* that clips Sentinel-2 L2A bands B4 (red) and B8 (NIR) to each field polygon.
*/
import { mulberry32, ringFromMetres, type LngLat } from "@/components/lab/geo/geo";
export type CropProfile = "wheat" | "wheat-late" | "wheat-stressed" | "fodder" | "sugarcane" | "fallow";
export type SampleField = { id: string; name: string; crop: string; profile: CropProfile; ring: LngLat[]; note: string };
export type Observation = {
date: string; // YYYY-MM-DD (UTC)
/** Mean surface reflectance over the field's clear pixels, 0–1. null when masked. */
red: number | null;
nir: number | null;
ndvi: number | null;
/** Share of the field covered by cloud or cloud shadow, 0–100. */
cloudPct: number;
};
/** Observations with more cloud than this are masked: shown as gaps, never as zeros. */
export const CLOUD_MASK_PCT = 60;
export const ndviFrom = (nir: number, red: number) => (nir - red) / (nir + red);
/* ------------------------------------------------------------------ */
/* Sample fields */
/* ------------------------------------------------------------------ */
const ORIGIN: LngLat = [72.708, 32.052];
const rect = (e: number, n: number, w: number, h: number, skew = 0): Array<[number, number]> => [
[e, n],
[e + w, n + skew],
[e + w, n + h + skew],
[e, n + h],
];
export const FIELDS: SampleField[] = [
{ id: "n1", name: "Field 1 · Wheat (on time)", crop: "Wheat", profile: "wheat", ring: ringFromMetres(ORIGIN, rect(0, 0, 280, 190, 6)), note: "Sown mid-November, normal season." },
{ id: "n2", name: "Field 2 · Wheat (late)", crop: "Wheat", profile: "wheat-late", ring: ringFromMetres(ORIGIN, rect(300, 0, 260, 190, 4)), note: "Sown after cotton, about two weeks late. Peak shifts right." },
{ id: "n3", name: "Field 3 · Wheat (stress)", crop: "Wheat", profile: "wheat-stressed", ring: ringFromMetres(ORIGIN, rect(0, 210, 280, 200, 5)), note: "Missed irrigation in February: a real drop the anomaly check should catch." },
{ id: "n4", name: "Field 4 · Berseem fodder", crop: "Berseem", profile: "fodder", ring: ringFromMetres(ORIGIN, rect(300, 210, 160, 200, 3)), note: "Cut every 5–6 weeks, so drops are expected management, not stress." },
{ id: "n5", name: "Field 5 · Sugarcane", crop: "Sugarcane", profile: "sugarcane", ring: ringFromMetres(ORIGIN, rect(480, 210, 200, 260, 2)), note: "Harvested in late January, then a ratoon crop regrows slowly." },
{ id: "n6", name: "Field 6 · Fallow", crop: "Fallow", profile: "fallow", ring: ringFromMetres(ORIGIN, rect(-220, 0, 200, 300, 4)), note: "Bare soil with some weeds. NDVI stays low." },
];
/* ------------------------------------------------------------------ */
/* Synthetic series */
/* ------------------------------------------------------------------ */
export const SEASON_START = Date.UTC(2025, 10, 5); // 5 Nov 2025
export const REVISIT_DAYS = 5; // Sentinel-2A + 2B combined revisit
export const N_DATES = 36; // to late April 2026
export const DATES: string[] = Array.from({ length: N_DATES }, (_, i) =>
new Date(SEASON_START + i * REVISIT_DAYS * 86_400_000).toISOString().slice(0, 10),
);
const logistic = (t: number, mid: number, k: number) => 1 / (1 + Math.exp(-k * (t - mid)));
/** Double-logistic green-up / senescence curve, the usual shape for annual crops. */
const doubleLogistic = (t: number, base: number, amp: number, sos: number, eos: number, k1 = 0.09, k2 = 0.08) =>
base + amp * (logistic(t, sos, k1) - logistic(t, eos, k2));
function trueNdvi(profile: CropProfile, t: number): number {
switch (profile) {
case "wheat":
return doubleLogistic(t, 0.17, 0.66, 38, 148);
case "wheat-late":
return doubleLogistic(t, 0.17, 0.62, 55, 158);
case "wheat-stressed": {
const v = doubleLogistic(t, 0.17, 0.64, 40, 146);
// Water stress from day 95: a fast drop, then partial recovery after irrigation on day 112.
const stress = t < 95 ? 0 : t < 105 ? ((t - 95) / 10) * 0.2 : t < 112 ? 0.2 : Math.max(0.08, 0.2 - (t - 112) * 0.02);
return v - stress;
}
case "fodder": {
// Regrowth after each cut (sowing day 0, cuts on day 60, 100, 140).
const cuts = [0, 60, 100, 140];
const last = cuts.filter((c) => c <= t).pop() ?? 0;
const since = t - last;
const ceiling = last === 0 ? 0.78 : 0.8;
return 0.3 + (ceiling - 0.3) * (1 - Math.exp(-since / (last === 0 ? 22 : 12)));
}
case "sugarcane": {
if (t < 82) return 0.72 - t * 0.0008;
if (t < 87) return 0.2;
return Math.min(0.55, 0.2 + (t - 87) * 0.0045);
}
case "fallow":
return 0.13 + 0.05 * Math.max(0, Math.sin((t - 60) / 30));
}
}
/** Reflectance pair that reproduces a target NDVI, roughly following real soil/vegetation spectra. */
function bandsFor(ndvi: number, rand: () => number): { red: number; nir: number } {
const red = Math.max(0.025, 0.125 - 0.09 * ndvi + (rand() - 0.5) * 0.008);
const nir = (red * (1 + ndvi)) / (1 - ndvi);
return { red, nir };
}
const round = (n: number, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
export function generateSeries(field: SampleField, seed = 2026): Observation[] {
const sceneRand = mulberry32(seed); // shared per date: clouds cover the whole area
const fieldRand = mulberry32(seed + field.id.charCodeAt(1) * 7919);
return DATES.map((date, i) => {
const t = i * REVISIT_DAYS;
// Winter fog and western disturbances make Dec–Jan scenes cloudier.
const foggy = t > 30 && t < 80;
const scene = sceneRand();
const sceneCloud = scene < (foggy ? 0.35 : 0.12) ? 70 + scene * 80 : scene * 25;
const cloudPct = Math.round(Math.min(100, Math.max(0, sceneCloud + (fieldRand() - 0.5) * 20)));
const target = Math.min(0.92, Math.max(0.05, trueNdvi(field.profile, t) + (fieldRand() - 0.5) * 0.03));
if (cloudPct > CLOUD_MASK_PCT) return { date, red: null, nir: null, ndvi: null, cloudPct };
const { red, nir } = bandsFor(target, fieldRand);
return { date, red: round(red), nir: round(nir), ndvi: round(ndviFrom(nir, red), 3), cloudPct };
});
}
/* ------------------------------------------------------------------ */
/* Anomalies */
/* ------------------------------------------------------------------ */
export type Anomaly = { index: number; date: string; value: number; mean: number; drop: number };
/** A run of consecutive flagged clear observations, classified by what happened next. */
export type Episode = {
start: Anomaly;
/** The flagged observation with the largest drop in the run. */
worst: Anomaly;
points: Anomaly[];
/** "dip": NDVI climbed back afterwards (stress relieved, fodder cut regrowing). "decline": it did not (senescence, harvest). */
kind: "dip" | "decline";
};
/** Mean of the previous `window` clear observations, for every date (null until enough history). */
export function rollingMean(series: Observation[], window = 3): Array<number | null> {
const out: Array<number | null> = [];
const recent: number[] = [];
for (const o of series) {
out.push(recent.length >= window ? recent.reduce((a, b) => a + b, 0) / recent.length : null);
if (o.ndvi !== null) {
recent.push(o.ndvi);
if (recent.length > window) recent.shift();
}
}
return out;
}
/**
* Flag clear observations that fall more than `threshold` below the mean of the
* previous `window` clear observations. Masked dates are skipped, never treated as zero.
*/
export function findAnomalies(series: Observation[], threshold: number, window = 3): Anomaly[] {
const means = rollingMean(series, window);
const out: Anomaly[] = [];
series.forEach((o, i) => {
const mean = means[i];
if (o.ndvi === null || mean === null || mean === undefined) return;
const drop = mean - o.ndvi;
if (drop > threshold) out.push({ index: i, date: o.date, value: o.ndvi, mean: round(mean, 3), drop: round(drop, 3) });
});
return out;
}
/**
* Group flags into episodes (consecutive clear observations) and classify each one.
* Recovery = any of the next 3 clear values rises at least threshold/2 above the episode minimum.
* This separates a fixable dip from the expected end-of-season decline, which a plain
* rolling-mean rule would otherwise report as a long run of "anomalies".
*/
export function findEpisodes(series: Observation[], threshold: number, window = 3): Episode[] {
const flags = findAnomalies(series, threshold, window);
const clear = series.map((o, i) => (o.ndvi === null ? -1 : i)).filter((i) => i >= 0);
const pos = new Map(clear.map((idx, k) => [idx, k]));
const episodes: Episode[] = [];
for (const a of flags) {
const prev = episodes[episodes.length - 1];
const last = prev?.points[prev.points.length - 1];
if (prev && last && pos.get(a.index)! - pos.get(last.index)! === 1) {
prev.points.push(a);
if (a.drop > prev.worst.drop) prev.worst = a;
} else {
episodes.push({ start: a, worst: a, points: [a], kind: "decline" });
}
}
for (const e of episodes) {
const min = Math.min(...e.points.map((p) => p.value));
const k = pos.get(e.points[e.points.length - 1]!.index)!;
const next = clear.slice(k + 1, k + 4).map((i) => series[i]!.ndvi!);
e.kind = next.some((v) => v >= min + threshold / 2) ? "dip" : "decline";
}
return episodes;
}
/* ------------------------------------------------------------------ */
/* Colour scale */
/* ------------------------------------------------------------------ */
/** Brown → yellow-green → dark green. Avoids a red/green split, so it reads for colour-blind users. */
export const NDVI_STOPS: Array<[number, string]> = [
[0.0, "#8c510a"],
[0.2, "#d8b365"],
[0.4, "#c2e699"],
[0.6, "#78c679"],
[0.8, "#238443"],
[0.9, "#004529"],
];
const hex = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
export function ndviColour(v: number): string {
const stops = NDVI_STOPS;
if (v <= stops[0]![0]) return stops[0]![1];
for (let i = 1; i < stops.length; i++) {
const [b, cb] = stops[i]!;
const [a, ca] = stops[i - 1]!;
if (v <= b) {
const f = (v - a) / (b - a);
const A = hex(ca);
const B = hex(cb);
return `rgb(${A.map((x, k) => Math.round(x + (B[k]! - x) * f)).join(",")})`;
}
}
return stops[stops.length - 1]![1];
}
export function describeNdvi(v: number): string {
if (v < 0.2) return "bare soil or stubble";
if (v < 0.4) return "sparse or emerging crop";
if (v < 0.6) return "moderate canopy";
if (v < 0.8) return "dense, healthy canopy";
return "very dense canopy";
}
/* ------------------------------------------------------------------ */
/* CSV */
/* ------------------------------------------------------------------ */
export function toCSV(field: SampleField, series: Observation[], anomalies: Anomaly[]): string {
const flagged = new Set(anomalies.map((a) => a.index));
const rows = series.map((o, i) =>
[field.id, o.date, o.ndvi ?? "", o.red ?? "", o.nir ?? "", o.cloudPct, o.ndvi === null ? "masked" : flagged.has(i) ? "drop" : ""].join(","),
);
return ["# synthetic data generated for a demo, not satellite measurements", "field_id,date,ndvi,red_b4,nir_b8,cloud_pct,flag", ...rows].join("\n");
}app/lab/ndvi-time-series-viewer/Chart.tsx130 lines
"use client";
import type { Episode, Observation } from "./ndvi";
export type Line = { id: string; name: string; colour: string; obs: Observation[] };
const W = 720;
const H = 260;
const M = { l: 38, r: 14, t: 14, b: 30 };
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/**
* Plain SVG line chart. Masked (cloudy) dates break the line instead of dropping to zero,
* the dashed line is the rolling mean of the primary field, triangles mark flagged drops.
*/
export function NdviChart({
dates,
lines,
mean,
episodes,
dateIdx,
onPick,
}: {
dates: string[];
lines: Line[];
mean: Array<number | null>;
episodes: Episode[];
dateIdx: number;
onPick: (i: number) => void;
}) {
const n = dates.length;
const x = (i: number) => M.l + (i / (n - 1)) * (W - M.l - M.r);
const y = (v: number) => M.t + (1 - v) * (H - M.t - M.b);
const path = (vals: Array<number | null>) => {
let d = "";
let pen = false;
vals.forEach((v, i) => {
if (v === null) {
pen = false;
return;
}
d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`;
pen = true;
});
return d;
};
const monthTicks = dates
.map((d, i) => ({ i, d }))
.filter(({ d, i }) => i === 0 || d.slice(5, 7) !== dates[i - 1]!.slice(5, 7));
function pick(e: React.PointerEvent<SVGSVGElement>) {
if (e.type === "pointermove" && e.buttons !== 1) return;
const r = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * W;
const i = Math.round(((px - M.l) / (W - M.l - M.r)) * (n - 1));
onPick(Math.max(0, Math.min(n - 1, i)));
}
const primary = lines[0];
const flagged = episodes.flatMap((e) => e.points.map((p) => ({ ...p, kind: e.kind })));
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-auto w-full touch-none select-none"
role="img"
aria-label={`NDVI over the season for ${lines.map((l) => l.name).join(", ")}. A data table follows below the chart.`}
onPointerDown={pick}
onPointerMove={pick}
>
{[0, 0.2, 0.4, 0.6, 0.8, 1].map((v) => (
<g key={v}>
<line x1={M.l} x2={W - M.r} y1={y(v)} y2={y(v)} stroke="var(--border)" strokeWidth={1} />
<text x={M.l - 6} y={y(v) + 3} textAnchor="end" fontSize={10} fill="var(--fg-muted)" fontFamily="var(--font-mono)">
{v.toFixed(1)}
</text>
</g>
))}
{monthTicks.map(({ i, d }) => (
<text key={d} x={x(i)} y={H - 10} fontSize={10} fill="var(--fg-muted)" fontFamily="var(--font-mono)">
{MONTHS[Number(d.slice(5, 7)) - 1]}
{d.slice(5, 7) === "01" || i === 0 ? ` ’${d.slice(2, 4)}` : ""}
</text>
))}
{primary?.obs.map((o, i) =>
o.ndvi === null ? (
<rect key={i} x={x(i) - 3} y={H - M.b + 2} width={6} height={5} rx={1} fill="var(--fg-muted)" opacity={0.6}>
<title>{`${o.date}: masked, ${o.cloudPct}% cloud`}</title>
</rect>
) : null,
)}
<path d={path(mean)} fill="none" stroke="var(--fg-muted)" strokeWidth={1.5} strokeDasharray="4 4" />
{lines
.slice()
.reverse()
.map((l) => (
<g key={l.id}>
<path d={path(l.obs.map((o) => o.ndvi))} fill="none" stroke={l.colour} strokeWidth={l === primary ? 2.5 : 1.75} strokeLinejoin="round" />
{l.obs.map((o, i) =>
o.ndvi === null ? null : <circle key={i} cx={x(i)} cy={y(o.ndvi)} r={l === primary ? 2.6 : 1.8} fill={l.colour} />,
)}
</g>
))}
{flagged.map((p) => (
<path
key={p.index}
d={`M${x(p.index)},${y(p.value) + 7} l-6,10 h12 z`}
fill={p.kind === "dip" ? "var(--danger)" : "var(--warn-fg)"}
stroke="var(--bg)"
strokeWidth={1}
>
<title>{`${p.date}: ${p.value.toFixed(2)}, ${p.drop.toFixed(2)} below the rolling mean (${p.kind})`}</title>
</path>
))}
<line x1={x(dateIdx)} x2={x(dateIdx)} y1={M.t} y2={H - M.b} stroke="var(--fg)" strokeWidth={1} opacity={0.5} />
<rect x={x(dateIdx) - 30} y={0} width={60} height={13} rx={3} fill="var(--bg-muted)" />
<text x={x(dateIdx)} y={10} textAnchor="middle" fontSize={9.5} fill="var(--fg)" fontFamily="var(--font-mono)">
{dates[dateIdx]}
</text>
</svg>
);
}app/lab/ndvi-time-series-viewer/Demo.tsx437 lines
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
import { MAP_CLASS, useLeafletMap } from "@/components/lab/geo/useLeafletMap";
import { ringCenter, type LngLat } from "@/components/lab/geo/geo";
import { NdviChart, type Line } from "./Chart";
import {
CLOUD_MASK_PCT,
DATES,
FIELDS,
NDVI_STOPS,
describeNdvi,
findEpisodes,
generateSeries,
ndviColour,
rollingMean,
toCSV,
} from "./ndvi";
const KEY = "lab:ndvi-time-series-viewer:v1";
const SLOT_COLOURS = ["var(--accent)", "var(--accent-2)", "var(--accent-3)"];
const toLatLng = (p: LngLat): [number, number] => [p[1], p[0]];
type Saved = { selected: string; compare: string[]; threshold: number; dateIdx: number };
const DEFAULTS: Saved = { selected: "n3", compare: ["n1"], threshold: 0.08, dateIdx: 21 };
const btn =
"inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elev px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40";
/** All series are generated once at module load: deterministic, so server and client agree. */
const SERIES = Object.fromEntries(FIELDS.map((f) => [f.id, generateSeries(f)]));
export default function Demo() {
const center = ringCenter(FIELDS[0]!.ring);
const { ref, ctx } = useLeafletMap({ center: [center[1], center[0] + 0.002], zoom: 16 });
const [s, setS] = useState<Saved>(DEFAULTS);
const [loaded, setLoaded] = useState(false);
const [playing, setPlaying] = useState(false);
const fitted = useRef(false);
useEffect(() => {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const v = JSON.parse(raw) as Partial<Saved>;
if (v.selected && SERIES[v.selected]) setS({ ...DEFAULTS, ...v });
}
} catch {
/* ignore */
}
setLoaded(true);
}, []);
useEffect(() => {
if (!loaded) return;
try {
localStorage.setItem(KEY, JSON.stringify(s));
} catch {
/* ignore */
}
}, [s, loaded]);
useEffect(() => {
if (!playing) return;
const t = setInterval(() => {
setS((v) => {
if (v.dateIdx >= DATES.length - 1) {
setPlaying(false);
return v;
}
return { ...v, dateIdx: v.dateIdx + 1 };
});
}, 550);
return () => clearInterval(t);
}, [playing]);
const field = FIELDS.find((f) => f.id === s.selected) ?? FIELDS[0]!;
const series = SERIES[field.id]!;
const mean = useMemo(() => rollingMean(series), [series]);
const episodes = useMemo(() => findEpisodes(series, s.threshold), [series, s.threshold]);
const obs = series[s.dateIdx]!;
const flaggedHere = episodes.flatMap((e) => e.points.map((p) => ({ ...p, kind: e.kind }))).find((p) => p.index === s.dateIdx);
const lines: Line[] = [field.id, ...s.compare.filter((id) => id !== field.id)].slice(0, 3).map((id, k) => ({
id,
name: FIELDS.find((f) => f.id === id)!.name,
colour: SLOT_COLOURS[k]!,
obs: SERIES[id]!,
}));
/* ---------------- map ---------------- */
useEffect(() => {
if (!ctx) return;
const { L, map } = ctx;
const group = L.featureGroup().addTo(map);
for (const f of FIELDS) {
const o = SERIES[f.id]![s.dateIdx]!;
const sel = f.id === s.selected;
const poly: Leaflet.Polygon = L.polygon(f.ring.map(toLatLng), {
color: sel ? "#ffffff" : "#18181b",
weight: sel ? 3 : 1,
fillColor: o.ndvi === null ? "#9ca3af" : ndviColour(o.ndvi),
fillOpacity: o.ndvi === null ? 0.45 : 0.85,
dashArray: o.ndvi === null ? "4 4" : undefined,
}).addTo(group);
poly.bindTooltip(o.ndvi === null ? `cloud ${o.cloudPct}%` : o.ndvi.toFixed(2), {
permanent: true,
direction: "center",
className: "!px-1 !py-0 !font-mono !text-[11px]",
});
poly.on("click", () => setS((v) => ({ ...v, selected: f.id })));
}
if (!fitted.current) {
fitted.current = true;
map.fitBounds(group.getBounds(), { padding: [24, 24] });
}
return () => {
group.remove();
};
}, [ctx, s.dateIdx, s.selected]);
function toggleCompare(id: string) {
setS((v) => {
if (v.compare.includes(id)) return { ...v, compare: v.compare.filter((x) => x !== id) };
const others = v.compare.filter((x) => x !== v.selected);
if (others.length >= 2) return v;
return { ...v, compare: [...others, id] };
});
}
function downloadCsv() {
const blob = new Blob([toCSV(field, series, episodes.flatMap((e) => e.points))], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ndvi-${field.id}-synthetic.csv`;
a.click();
URL.revokeObjectURL(url);
}
const compareCount = s.compare.filter((x) => x !== s.selected).length;
const setDate = (i: number) => setS((v) => ({ ...v, dateIdx: i }));
return (
<div>
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-warn-bg px-4 py-2 text-xs text-warn-fg">
<strong className="font-mono">SYNTHETIC DATA</strong>
<span>
Generated from crop growth curves with a fixed seed to look like a Rabi season in Punjab. No satellite imagery is used. Fields
are fictional.
</span>
</div>
<div className="grid lg:grid-cols-[1fr_22rem]">
<div className="min-w-0">
<div className="relative">
<div ref={ref} className={`${MAP_CLASS} h-[360px] sm:h-[440px]`} role="application" aria-label="Fields coloured by NDVI on the selected date. The field list beside the map is a text alternative." />
<div className="pointer-events-none absolute bottom-6 left-2 z-[500] rounded-md border border-[#27272a] bg-[#0b0b0d]/85 p-2 font-mono text-[10px] text-[#e4e4e7]">
<p className="mb-1">NDVI</p>
<div
className="h-2.5 w-40"
style={{ background: `linear-gradient(to right, ${NDVI_STOPS.map(([v, c]) => `${c} ${(v / 0.9) * 100}%`).join(",")})` }}
aria-hidden="true"
/>
<div className="flex w-40 justify-between">
{[0, 0.2, 0.4, 0.6, 0.8].map((v) => (
<span key={v}>{v.toFixed(1)}</span>
))}
</div>
<p className="mt-1 flex items-center gap-1">
<span className="inline-block size-2.5 border border-dashed border-[#e4e4e7] bg-[#9ca3af]/60" /> cloud masked (>{CLOUD_MASK_PCT}%)
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border p-3">
<button
type="button"
className={btn}
onClick={() => {
if (s.dateIdx >= DATES.length - 1) setDate(0);
setPlaying((p) => !p);
}}
aria-pressed={playing}
>
{playing ? "❚❚ Pause" : "▶ Play season"}
</button>
<label className="flex min-w-[12rem] flex-1 items-center gap-3 text-xs">
<span className="sr-only">Observation date</span>
<input
type="range"
min={0}
max={DATES.length - 1}
value={s.dateIdx}
onChange={(e) => {
setPlaying(false);
setDate(Number(e.target.value));
}}
aria-valuetext={`${DATES[s.dateIdx]}`}
className="w-full accent-[var(--accent)]"
/>
</label>
<output className="font-mono text-sm" aria-live="polite">
{DATES[s.dateIdx]}
</output>
</div>
</div>
<aside className="space-y-4 border-t border-border bg-bg-elev p-4 lg:border-t-0 lg:border-l">
<section aria-labelledby="sel-h" aria-live="polite">
<h3 id="sel-h" className="font-mono text-sm font-semibold">
{field.name}
</h3>
<p className="mt-1 text-xs text-fg-muted">{field.note}</p>
{obs.ndvi !== null && obs.red !== null && obs.nir !== null ? (
<div className="mt-3 space-y-2">
<p className="flex items-baseline gap-2">
<span className="font-mono text-3xl font-semibold">{obs.ndvi.toFixed(2)}</span>
<span className="text-sm text-fg-muted">{describeNdvi(obs.ndvi)}</span>
</p>
<div className="rounded-md bg-[#0b0b0d] p-3 font-mono text-[11px] leading-relaxed text-[#a1a1aa]">
<p>
NDVI = (<span className="text-[#f472b6]">NIR</span> − <span className="text-[#f87171]">Red</span>) / (
<span className="text-[#f472b6]">NIR</span> + <span className="text-[#f87171]">Red</span>)
</p>
<p className="text-[#e4e4e7]">
= (<span className="text-[#f472b6]">{obs.nir.toFixed(3)}</span> − <span className="text-[#f87171]">{obs.red.toFixed(3)}</span>) / (
<span className="text-[#f472b6]">{obs.nir.toFixed(3)}</span> + <span className="text-[#f87171]">{obs.red.toFixed(3)}</span>)
</p>
<p className="text-[#4ade80]">= {obs.ndvi.toFixed(3)}</p>
<p className="mt-1">B8 (NIR, 842 nm) · B4 (red, 665 nm) · cloud {obs.cloudPct}%</p>
</div>
{mean[s.dateIdx] != null && (
<p className="text-xs text-fg-muted">
Rolling mean of the previous 3 clear dates: <span className="font-mono text-fg">{mean[s.dateIdx]!.toFixed(2)}</span>
</p>
)}
{flaggedHere && (
<p className={`text-xs font-medium ${flaggedHere.kind === "dip" ? "text-danger" : "text-warn-fg"}`}>
{flaggedHere.kind === "dip" ? "▲ Flagged dip" : "▲ Flagged decline"}: {flaggedHere.drop.toFixed(2)} below the rolling mean.
</p>
)}
</div>
) : (
<p className="mt-3 rounded-md border border-dashed border-border p-3 text-sm text-fg-muted">
Masked: {obs.cloudPct}% of the field is under cloud on {obs.date}. The chart shows a gap here, not a zero.
</p>
)}
</section>
<section aria-labelledby="list-h">
<h3 id="list-h" className="font-mono text-sm font-semibold">
fields <span className="font-normal text-fg-muted">· compare up to 2 more</span>
</h3>
<ul className="mt-2 divide-y divide-border rounded-md border border-border bg-bg">
{FIELDS.map((f) => {
const o = SERIES[f.id]![s.dateIdx]!;
const sel = f.id === s.selected;
const inCompare = !sel && s.compare.includes(f.id);
return (
<li key={f.id} className={`flex items-center gap-2 px-2 py-1.5 ${sel ? "bg-accent-soft/60" : ""}`}>
<span
className="size-3 shrink-0 rounded-sm border border-border"
style={{ background: o.ndvi === null ? "#9ca3af" : ndviColour(o.ndvi) }}
aria-hidden="true"
/>
<button
type="button"
onClick={() => setS((v) => ({ ...v, selected: f.id }))}
aria-current={sel}
className="min-w-0 flex-1 truncate text-left text-sm hover:text-accent"
>
{f.name}
</button>
<span className="font-mono text-xs text-fg-muted">{o.ndvi === null ? "—" : o.ndvi.toFixed(2)}</span>
<label className="flex items-center" title="Compare on chart">
<input
type="checkbox"
disabled={sel || (!inCompare && compareCount >= 2)}
checked={sel || inCompare}
onChange={() => toggleCompare(f.id)}
className="accent-[var(--accent)]"
/>
<span className="sr-only">Compare {f.name}</span>
</label>
</li>
);
})}
</ul>
</section>
</aside>
</div>
<div className="grid gap-0 border-t border-border lg:grid-cols-[1fr_22rem]">
<section aria-labelledby="chart-h" className="min-w-0 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id="chart-h" className="font-mono text-sm font-semibold">
season NDVI
</h3>
<ul className="flex flex-wrap gap-3 text-xs">
{lines.map((l) => (
<li key={l.id} className="flex items-center gap-1.5">
<span className="inline-block h-0.5 w-4" style={{ background: l.colour }} aria-hidden="true" />
{l.name}
</li>
))}
<li className="flex items-center gap-1.5 text-fg-muted">
<span className="inline-block w-4 border-t border-dashed border-fg-muted" aria-hidden="true" /> rolling mean
</li>
</ul>
</div>
<div className="mt-2">
<NdviChart dates={DATES} lines={lines} mean={mean} episodes={episodes} dateIdx={s.dateIdx} onPick={(i) => { setPlaying(false); setDate(i); }} />
</div>
<p className="text-xs text-fg-muted">Click or drag on the chart to move the date. Grey ticks under the axis are cloud-masked dates.</p>
<details className="mt-3 rounded-md border border-border">
<summary className="cursor-pointer px-3 py-2 font-mono text-xs hover:text-accent">Data table for {field.name}</summary>
<div className="max-h-72 overflow-auto">
<table className="w-full text-left font-mono text-xs">
<caption className="sr-only">NDVI observations for {field.name} (synthetic)</caption>
<thead className="sticky top-0 bg-bg-muted text-fg-muted">
<tr>
{["date", "ndvi", "red B4", "nir B8", "cloud %", "flag"].map((h) => (
<th key={h} className="px-3 py-1.5 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{series.map((o, i) => {
const flag = episodes.find((e) => e.points.some((p) => p.index === i));
return (
<tr key={o.date} className={`border-t border-border ${i === s.dateIdx ? "bg-accent-soft/60" : ""}`}>
<td className="px-3 py-1">{o.date}</td>
<td className="px-3 py-1">{o.ndvi?.toFixed(3) ?? "—"}</td>
<td className="px-3 py-1">{o.red?.toFixed(4) ?? "—"}</td>
<td className="px-3 py-1">{o.nir?.toFixed(4) ?? "—"}</td>
<td className="px-3 py-1">{o.cloudPct}</td>
<td className="px-3 py-1">{o.ndvi === null ? "masked" : flag ? flag.kind : ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</details>
</section>
<aside className="space-y-4 border-t border-border bg-bg-elev p-4 lg:border-t-0 lg:border-l">
<section aria-labelledby="anom-h">
<h3 id="anom-h" className="font-mono text-sm font-semibold">
drop detection
</h3>
<label className="mt-2 block text-xs">
<span className="flex justify-between">
<span>Flag when NDVI falls below the 3-date rolling mean by more than</span>
<span className="font-mono text-fg">{s.threshold.toFixed(2)}</span>
</span>
<input
type="range"
min={0.04}
max={0.2}
step={0.01}
value={s.threshold}
onChange={(e) => setS((v) => ({ ...v, threshold: Number(e.target.value) }))}
className="mt-1 w-full accent-[var(--accent)]"
/>
</label>
{episodes.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">No drops above the threshold for this field.</p>
) : (
<ul className="mt-2 space-y-1.5" aria-live="polite">
{episodes.map((e) => (
<li key={e.start.index}>
<button
type="button"
onClick={() => setDate(e.worst.index)}
className="w-full rounded-md border border-border bg-bg px-2 py-1.5 text-left text-xs hover:border-accent"
>
<span className={`font-mono font-semibold ${e.kind === "dip" ? "text-danger" : "text-warn-fg"}`}>
{e.kind === "dip" ? "dip" : "decline"}
</span>{" "}
{e.start.date}
{e.points.length > 1 ? ` → ${e.points[e.points.length - 1]!.date}` : ""}
<span className="block text-fg-muted">
worst −{e.worst.drop.toFixed(2)} on {e.worst.date} ({e.worst.value.toFixed(2)} vs mean {e.worst.mean.toFixed(2)})
</span>
</button>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-fg-muted">
A <span className="text-danger">dip</span> recovers within three clear dates (stress relieved, fodder regrowing). A{" "}
<span className="text-warn-fg">decline</span> does not (ripening or harvest).
</p>
</section>
<div className="flex flex-wrap gap-2">
<button type="button" className={btn} onClick={downloadCsv}>
Download CSV
</button>
<button
type="button"
className={btn}
onClick={() => {
setPlaying(false);
setS(DEFAULTS);
}}
>
Reset demo
</button>
</div>
<section aria-labelledby="what-h" className="text-xs text-fg-muted">
<h3 id="what-h" className="font-mono text-sm font-semibold text-fg">
what is NDVI?
</h3>
<p className="mt-1">
Healthy leaves absorb red light for photosynthesis and reflect near-infrared strongly. NDVI = (NIR − Red) / (NIR + Red)
turns that contrast into one number from −1 to 1. Bare soil sits around 0.1–0.2, a dense wheat canopy at heading reaches
0.8 or more.
</p>
<p className="mt-1">
Clouds and their shadows ruin the measurement, so cloudy observations are masked and shown as gaps. Treating them as low
values would invent crop failures.
</p>
</section>
</aside>
</div>
</div>
);
}components/lab/geo/useLeafletMap.ts52 lines
"use client";
import "leaflet/dist/leaflet.css";
import { useEffect, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
export type LeafletNS = typeof Leaflet;
export type MapCtx = { L: LeafletNS; map: Leaflet.Map };
export const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
/**
* Creates a Leaflet map with OpenStreetMap tiles inside the returned ref.
* Leaflet touches `window` on import, so it is loaded with a dynamic import after mount
* (never during server rendering). `ctx` is null until the map exists.
*/
export function useLeafletMap(opts: { center: [lat: number, lng: number]; zoom: number; options?: Leaflet.MapOptions }) {
const ref = useRef<HTMLDivElement>(null);
const [ctx, setCtx] = useState<MapCtx | null>(null);
const init = useRef(opts);
useEffect(() => {
let cancelled = false;
let map: Leaflet.Map | null = null;
import("leaflet").then((mod) => {
const L = ((mod as unknown as { default?: LeafletNS }).default ?? mod) as LeafletNS;
if (cancelled || !ref.current) return;
map = L.map(ref.current, {
center: init.current.center,
zoom: init.current.zoom,
zoomControl: true,
...init.current.options,
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: OSM_ATTRIBUTION,
}).addTo(map);
setCtx({ L, map });
});
return () => {
cancelled = true;
map?.remove();
setCtx(null);
};
}, []);
return { ref, ctx };
}
/** Tailwind classes for a map container: isolates Leaflet's z-indexes from the site header. */
export const MAP_CLASS = "relative z-0 isolate w-full bg-bg-muted [&_.leaflet-container]:font-sans";