Commit bab0122d authored by Heisenberg5124's avatar Heisenberg5124
Browse files

Initial commit

parent 2a1ad0b6
...@@ -8,40 +8,21 @@ import KpiDialog from "@/components/map/kpi-dialog.tsx"; ...@@ -8,40 +8,21 @@ import KpiDialog from "@/components/map/kpi-dialog.tsx";
type Mode = 'single' | 'split'; type Mode = 'single' | 'split';
const kpiMock = {
left: {
year: 2020,
median: 0.22810404002666473,
p95: 0.6100596785545349,
frac_gt_0_4: 0.18748562226823096,
frac_gt_0_6: 0.05244996549344375,
},
right: {
year: 2025,
median: 0.15449757874011993,
p95: 0.4455701410770416,
frac_gt_0_4: 0.07292385553255118,
frac_gt_0_6: 0.0006901311249137336,
},
change: {
fracLoss: 0.3400046008741661, // Δ ≤ -0.1
fracGain: 0.006211180124223602, // Δ ≥ +0.1
},
} as const;
function App() { function App() {
const MIN_YEAR = 2020; const MIN_YEAR = 2015;
const MAX_YEAR = 2025; const MAX_YEAR = 2025;
const INITIAL_YEAR = 2022; const INITIAL_YEAR = 2025;
const [mode, setMode] = useState<Mode>('single'); const [mode, setMode] = useState<Mode>('single');
const [leftYear, setLeftYear] = useState(INITIAL_YEAR - 1);
const [rightYear, setRightYear] = useState(INITIAL_YEAR);
return ( return (
<div className="h-screen w-screen overflow-hidden relative"> <div className="h-screen w-screen overflow-hidden relative">
<div className="absolute right-4 top-4 z-20 w-[min(320px,42vw)] space-y-3"> <div className="absolute right-4 top-4 z-20 w-[min(320px,42vw)] space-y-3">
<MapLayerCard/> <MapLayerCard/>
<SplitMapCard mode={mode} setMode={setMode}/> <SplitMapCard mode={mode} setMode={setMode}/>
{mode === 'split' && (<KpiDialog {...kpiMock} />)} {mode === 'split' && (<KpiDialog start_year={leftYear} end_year={rightYear} />)}
</div> </div>
<div className="h-full w-full"> <div className="h-full w-full">
...@@ -59,6 +40,10 @@ function App() { ...@@ -59,6 +40,10 @@ function App() {
initialLeftYear={INITIAL_YEAR - 1} initialLeftYear={INITIAL_YEAR - 1}
initialRightYear={INITIAL_YEAR} initialRightYear={INITIAL_YEAR}
className="h-full w-full" className="h-full w-full"
onYearsChange={(l, r) => {
setLeftYear(l);
setRightYear(r);
}}
/> />
)} )}
</div> </div>
......
...@@ -7,7 +7,7 @@ An API that returns a satellite image file for a given year and computes NDVI & ...@@ -7,7 +7,7 @@ An API that returns a satellite image file for a given year and computes NDVI &
- Swagger UI: `/docs` - Swagger UI: `/docs`
- ReDoc: `/redoc` - ReDoc: `/redoc`
* OpenAPI spec version: 1.4.0 * OpenAPI spec version: 1.5.0
*/ */
import { import {
useMutation, useMutation,
...@@ -201,10 +201,6 @@ start_year: number; ...@@ -201,10 +201,6 @@ start_year: number;
* @minimum 2013 * @minimum 2013
*/ */
end_year: number; end_year: number;
/**
* NDVI summary to compare: mean | median | p05 | p95
*/
metric?: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 100 * @maximum 100
...@@ -564,7 +560,9 @@ export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndv ...@@ -564,7 +560,9 @@ export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndv
/** /**
* @summary Fast NDVI change value (in-memory, no file writes) * Returns summary NDVI metrics for start & end years (mean, median, p05, p95)
and their deltas (end - start). Always computes all metrics.
* @summary Fast NDVI change values (mean/median/p05/p95)
*/ */
export const ndviChangeValueNdviChangeValueGet = ( export const ndviChangeValueNdviChangeValueGet = (
params: NdviChangeValueNdviChangeValueGetParams, params: NdviChangeValueNdviChangeValueGetParams,
...@@ -636,7 +634,7 @@ export function useNdviChangeValueNdviChangeValueGet<TData = Awaited<ReturnType< ...@@ -636,7 +634,7 @@ export function useNdviChangeValueNdviChangeValueGet<TData = Awaited<ReturnType<
, queryClient?: QueryClient , queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/** /**
* @summary Fast NDVI change value (in-memory, no file writes) * @summary Fast NDVI change values (mean/median/p05/p95)
*/ */
export function useNdviChangeValueNdviChangeValueGet<TData = Awaited<ReturnType<typeof ndviChangeValueNdviChangeValueGet>>, TError = HTTPValidationError>( export function useNdviChangeValueNdviChangeValueGet<TData = Awaited<ReturnType<typeof ndviChangeValueNdviChangeValueGet>>, TError = HTTPValidationError>(
......
...@@ -7,7 +7,6 @@ import { ...@@ -7,7 +7,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {Progress} from "@/components/ui/progress";
import { import {
ResponsiveContainer, ResponsiveContainer,
ComposedChart, ComposedChart,
...@@ -18,32 +17,13 @@ import { ...@@ -18,32 +17,13 @@ import {
} from "recharts"; } from "recharts";
import {BarChart3} from "lucide-react"; import {BarChart3} from "lucide-react";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {useNdviChangeValueNdviChangeValueGet} from "@/api/__generated__";
type YearStats = { // ————————————————————————————————————————————————————————
year: number; // Helpers
median: number; // ————————————————————————————————————————————————————————
p95: number; const fmtNDVI = (v?: number) =>
frac_gt_0_4?: number; // 0..1 v != null && Number.isFinite(v) ? v.toFixed(3) : "";
frac_gt_0_6?: number; // 0..1
};
type ChangeStats = {
fracLoss: number; // ≤ -0.1 (0..1)
fracGain: number; // ≥ +0.1 (0..1)
};
type Props = {
left: YearStats;
right: YearStats;
change?: ChangeStats;
title?: string;
className?: string; // for the trigger button container
open?: boolean;
onOpenChange?: (open: boolean) => void;
triggerLabel?: string;
};
const fmtNDVI = (v: number) => (Number.isFinite(v) ? v.toFixed(3) : "");
const fmtPct = (f?: number) => const fmtPct = (f?: number) =>
f == null || !Number.isFinite(f) ? "" : `${(f * 100).toFixed(1)}%`; f == null || !Number.isFinite(f) ? "" : `${(f * 100).toFixed(1)}%`;
...@@ -72,11 +52,7 @@ function SlopeMini({ ...@@ -72,11 +52,7 @@ function SlopeMini({
<ResponsiveContainer width={width} height={height}> <ResponsiveContainer width={width} height={height}>
<ComposedChart data={both} margin={{top: 6, bottom: 6, left: 6, right: 6}}> <ComposedChart data={both} margin={{top: 6, bottom: 6, left: 6, right: 6}}>
<XAxis dataKey="x" type="category" hide/> <XAxis dataKey="x" type="category" hide/>
<YAxis <YAxis type="number" domain={domain ?? [Math.min(a, b), Math.max(a, b) + 1e-6]} hide/>
type="number"
domain={domain ?? [Math.min(a, b), Math.max(a, b) + 1e-6]}
hide
/>
<Line data={both} dataKey="v" type="linear" stroke={lineColor} strokeWidth={2} dot={false}/> <Line data={both} dataKey="v" type="linear" stroke={lineColor} strokeWidth={2} dot={false}/>
<Scatter data={[both[0]]} dataKey="v" fill="#9ca3af"/> {/* left year */} <Scatter data={[both[0]]} dataKey="v" fill="#9ca3af"/> {/* left year */}
<Scatter data={[both[1]]} dataKey="v" fill="#3b82f6"/> {/* right year */} <Scatter data={[both[1]]} dataKey="v" fill="#3b82f6"/> {/* right year */}
...@@ -118,17 +94,56 @@ function Row({ ...@@ -118,17 +94,56 @@ function Row({
); );
} }
type Props = {
start_year: number;
end_year: number;
title?: string;
className?: string; // for the trigger button container
open?: boolean;
onOpenChange?: (open: boolean) => void;
triggerLabel?: string;
};
export default function KpiDialog({ export default function KpiDialog({
left, start_year,
right, end_year,
change,
title = "NDVI comparison", title = "NDVI comparison",
className, className,
open, open,
onOpenChange, onOpenChange,
triggerLabel = "KPIs", triggerLabel = "KPIs",
}: Props) { }: Props) {
const ndviDomain: [number, number] = [-0.2, 0.8]; // adjust if needed const ndviDomain: [number, number] = [-0.2, 0.9];
const {
data,
isPending,
isError,
} = useNdviChangeValueNdviChangeValueGet({
start_year: start_year ?? 2015,
end_year: end_year ?? 2025,
max_cloud: 10,
buffer_meters: 200,
});
const leftYear = data?.start?.year;
const rightYear = data?.end?.year;
const leftMedian = data?.start?.metrics?.median;
const rightMedian = data?.end?.metrics?.median;
const leftP95 = data?.start?.metrics?.p95;
const rightP95 = data?.end?.metrics?.p95;
const leftMean = data?.start?.metrics?.mean;
const rightMean = data?.end?.metrics?.mean;
const changeP05 = data?.change?.p05;
const changeMedian = data?.change?.median;
const changeP95 = data?.change?.p95;
const leftCloud = data?.start?.cloud_cover; // 0..1
const rightCloud = data?.end?.cloud_cover; // 0..1
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
...@@ -141,66 +156,68 @@ export default function KpiDialog({ ...@@ -141,66 +156,68 @@ export default function KpiDialog({
</DialogTrigger> </DialogTrigger>
</div> </div>
<DialogContent className="sm:max-w-[420px] w-[min(92vw,420px)]"> <DialogContent className="sm:max-w-[480px] w-[min(92vw,480px)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex items-center justify-between">
<span>{title}</span> <span>{title}</span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-gray-400"/> <span className="inline-block h-2.5 w-2.5 rounded-full bg-gray-400"/>
{left.year} {leftYear ?? ""}
</span> </span>
<span className="flex items-center gap-1 text-xs"> <span className="flex items-center gap-1 text-xs">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-blue-500"/> <span className="inline-block h-2.5 w-2.5 rounded-full bg-blue-500"/>
{right.year} {rightYear ?? ""}
</span> </span>
</span> </span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 pt-1"> {/* Loading / error states */}
<Row label="Median NDVI" a={left.median} b={right.median} fmt={fmtNDVI} domain={ndviDomain}/> {isPending ? (
<Row label="p95 NDVI" a={left.p95} b={right.p95} fmt={fmtNDVI} domain={ndviDomain}/> <div className="space-y-3 pt-1 animate-pulse">
<Row <div className="h-4 rounded bg-muted"/>
label="% area > 0.4" <div className="h-4 rounded bg-muted"/>
a={left.frac_gt_0_4 ?? NaN} <div className="h-4 rounded bg-muted"/>
b={right.frac_gt_0_4 ?? NaN} <div className="h-4 rounded bg-muted"/>
fmt={(v) => fmtPct(v)} </div>
domain={[0, 1]} ) : isError || !data ? (
/> <div className="text-sm text-rose-600">Failed to load NDVI KPIs. Please try again.</div>
<Row ) : (
label="% area > 0.6" <div className="space-y-4 pt-1">
a={left.frac_gt_0_6 ?? NaN} {/* Top stats */}
b={right.frac_gt_0_6 ?? NaN} <Row label="Median NDVI" a={leftMedian as number} b={rightMedian as number} fmt={fmtNDVI}
fmt={(v) => fmtPct(v)} domain={ndviDomain}/>
domain={[0, 1]} <Row label="Mean NDVI" a={leftMean as number} b={rightMean as number} fmt={fmtNDVI} domain={ndviDomain}/>
/> <Row label="p95 NDVI" a={leftP95 as number} b={rightP95 as number} fmt={fmtNDVI} domain={ndviDomain}/>
<div className="pt-2 flex items-center justify-between"> {/* Change distribution */}
<div className="text-sm text-muted-foreground">Δ strong change</div> <div className="rounded-lg border p-3">
<div className="flex gap-2"> <div className="flex items-center justify-between">
<Badge className="bg-rose-50 text-rose-600 border-rose-200" variant="secondary"> <div className="text-sm text-muted-foreground">Δ distribution (right − left)</div>
Loss {fmtPct(change?.fracLoss)} <div className="flex gap-2">
<Badge className={cn("border-rose-200 bg-rose-50 text-rose-600")}>5th {fmtNDVI(changeP05)}</Badge>
<Badge variant="secondary">Median {fmtNDVI(changeMedian)}</Badge>
<Badge
className={cn("border-emerald-200 bg-emerald-50 text-emerald-600")}>95th {fmtNDVI(changeP95)}</Badge>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Negative values indicate vegetation loss; positive values indicate gain.
</div>
</div>
{/* Acquisition info */}
<div className="flex flex-wrap items-center gap-2 pt-1">
<Badge variant="outline" title="Cloud cover of the selected scenes">
{leftYear ?? ""} cloud {fmtPct(leftCloud)}
</Badge> </Badge>
<Badge className="bg-emerald-50 text-emerald-600 border-emerald-200" variant="secondary"> <Badge variant="outline" title="Cloud cover of the selected scenes">
Gain {fmtPct(change?.fracGain)} {rightYear ?? ""} cloud {fmtPct(rightCloud)}
</Badge> </Badge>
</div> </div>
</div> </div>
)}
<div className="space-y-2">
<div className="text-xs text-muted-foreground">Loss ≤ −0.1</div>
<Progress
value={Math.max(0, Math.min(100, (change?.fracLoss ?? 0) * 100))}
className="h-2 [&>div]:bg-rose-500"
/>
<div className="text-xs text-muted-foreground pt-2">Gain ≥ +0.1</div>
<Progress
value={Math.max(0, Math.min(100, (change?.fracGain ?? 0) * 100))}
className="h-2 [&>div]:bg-emerald-500"
/>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
......
...@@ -34,173 +34,208 @@ const DEFAULT_VIEW = { ...@@ -34,173 +34,208 @@ const DEFAULT_VIEW = {
pitch: 0 pitch: 0
}; };
const data = { // --- color helpers ---
year: 2025, function colorFromUnit(t: number): [number, number, number] {
collection: "sentinel-2-l2a", // NDVI-friendly ramp: black -> red/orange -> greens
native_res_m: 10, const stops = [
compare_res_m: 30, [0, 0, 0], // 0
ndvi_tif: "ndvi_2025_30m.tif", [165, 0, 38], // bare/built
workdir: "C:\\Users\\vinhd\\AppData\\Local\\Temp\\ndvi_2025_06ozaqja", [215, 48, 39],
stats: { [244, 109, 67],
count: 4347, [102, 189, 99], // healthy veg
mean: -57.32052993774414, [26, 152, 80], // max veg
median: 0.15449757874011993, ];
p05: 0.04584402218461037, const p = Math.max(0, Math.min(1, t)) * (stops.length - 1);
p95: 0.4455701410770416, const i = Math.floor(p);
frac_gt_0_2: 0.3556475730388774, const a = p - i;
frac_gt_0_4: 0.07292385553255118, const s0 = stops[i], s1 = stops[Math.min(i + 1, stops.length - 1)];
frac_gt_0_6: 0.0006901311249137336, return [
area_gt_0_4_m2: 285300, Math.round(s0[0] + a * (s1[0] - s0[0])),
area_gt_0_6_m2: 2700 Math.round(s0[1] + a * (s1[1] - s0[1])),
} Math.round(s0[2] + a * (s1[2] - s0[2])),
}; ] as [number, number, number];
function rdylgn(t: number): [number, number, number] {
// very small ramp: red->yellow->green
const clamp = (x: number) => Math.max(0, Math.min(1, x));
t = clamp(t);
let r = 0, g = 0, b = 0;
if (t < 0.5) {
r = 255;
g = Math.round(510 * t);
} // red->yellow
else {
r = Math.round(510 * (1 - t));
g = 255;
} // yellow->green
return [r, g, b];
} }
// Build proj4 string for common UTM EPSG codes (Sentinel/Landsat) // --- CRS helpers ---
function projFromEPSG(epsg: number) { function projFromEPSG(epsg: number) {
if (epsg === 4326) return "+proj=longlat +datum=WGS84 +no_defs"; // lon/lat
if (epsg === 3857) return "+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +a=6378137 +b=6378137 +units=m +no_defs"; // web mercator
if (epsg >= 32601 && epsg <= 32660) return `+proj=utm +zone=${epsg - 32600} +datum=WGS84 +units=m +no_defs`; if (epsg >= 32601 && epsg <= 32660) return `+proj=utm +zone=${epsg - 32600} +datum=WGS84 +units=m +no_defs`;
if (epsg >= 32701 && epsg <= 32760) return `+proj=utm +zone=${epsg - 32700} +south +datum=WGS84 +units=m +no_defs`; if (epsg >= 32701 && epsg <= 32760) return `+proj=utm +zone=${epsg - 32700} +south +datum=WGS84 +units=m +no_defs`;
return 'EPSG:4326'; // ETRS89 / UTM (common in Germany; e.g., 25832)
if (epsg >= 25801 && epsg <= 25838) return `+proj=utm +zone=${epsg - 25800} +datum=ETRS89 +units=m +no_defs`;
return ""; // unknown → assume bbox already lon/lat
} }
function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps) { // --- statistics & enhancement ---
function percentile(values: number[], p: number) {
const [imageUrl, setImageUrl] = React.useState<string>(); if (!values.length) return NaN;
const [coords, setCoords] = React.useState<ImageCorners | null>(null); const k = Math.max(0, Math.min(values.length - 1, Math.floor((p / 100) * (values.length - 1))));
const v = values.slice().sort((a, b) => a - b);
function percentileFrom(values: number[], p: number) { return v[k];
if (!values.length) return NaN; }
const k = Math.max(0, Math.min(values.length - 1, Math.floor((p / 100) * (values.length - 1))));
// partial sort via nth_element-ish: for simplicity use sort on a copy (AOIs are small)
const v = values.slice().sort((a, b) => a - b);
return v[k];
}
function median3x3NaN(buf: Float32Array, w: number, h: number) { function median3x3NaN(buf: Float32Array, w: number, h: number) {
const out = new Float32Array(buf.length); const out = new Float32Array(buf.length);
const win = new Float32Array(9); const win = new Float32Array(9);
let idx = 0; let idx = 0;
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++, idx++) { for (let x = 0; x < w; x++, idx++) {
let n = 0; let n = 0;
for (let dy = -1; dy <= 1; dy++) { for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy; const yy = y + dy;
if (yy < 0 || yy >= h) continue; if (yy < 0 || yy >= h) continue;
for (let dx = -1; dx <= 1; dx++) { for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx; const xx = x + dx;
if (xx < 0 || xx >= w) continue; if (xx < 0 || xx >= w) continue;
const v = buf[yy * w + xx]; const v = buf[yy * w + xx];
if (Number.isFinite(v)) win[n++] = v; if (Number.isFinite(v)) win[n++] = v;
}
} }
if (!n) {
out[idx] = NaN;
continue;
}
// median of n values in win[0..n)
const temp = Array.from(win.slice(0, n)).sort((a, b) => a - b);
out[idx] = temp[Math.floor(n / 2)];
} }
if (!n) {
out[idx] = NaN;
continue;
}
const temp = Array.from(win.slice(0, n)).sort((a, b) => a - b);
out[idx] = temp[Math.floor(n / 2)];
} }
buf.set(out);
} }
buf.set(out);
}
function enhanceNdviForDisplay( // Generic enhancer that does **not** assume NDVI range. It only drops nodata and
ndvi: Float32Array | number[], // stretches by percentiles of the actual data values. Optionally auto-inverts
width: number, // when the distribution looks left-skewed (common if the band highlights built-up).
height: number, function enhanceForDisplay(
{nodata = -9999, despeckle = true, gamma = 0.95}: { nodata?: number; despeckle?: boolean; gamma?: number } data: Float32Array | number[],
) { width: number,
const N = width * height; height: number,
// 1) Clean + collect valid values for robust limits opts: {
const cleaned = new Float32Array(N); nodata?: number;
const vals: number[] = []; despeckle?: boolean;
for (let i = 0; i < N; i++) { gamma?: number;
let v = (ndvi as any)[i]; lowPct?: number;
if (!Number.isFinite(v) || v === nodata || v < -1.5 || v > 1.5) { highPct?: number;
cleaned[i] = NaN; autoInvert?: boolean
continue; } = {}
} ) {
cleaned[i] = v; const {nodata, despeckle = true, gamma = 0.95, lowPct = 2, highPct = 98, autoInvert = false} = opts;
vals.push(v); const N = width * height;
const cleaned = new Float32Array(N);
const vals: number[] = [];
for (let i = 0; i < N; i++) {
const v = (data as any)[i];
if (!Number.isFinite(v) || (nodata !== undefined && v === nodata)) {
cleaned[i] = NaN;
continue;
} }
if (!vals.length) return {norm: cleaned, lo: -1, hi: 1}; cleaned[i] = v;
vals.push(v);
}
if (!vals.length) return {norm: cleaned, lo: 0, hi: 1};
// 2) Robust min/max (5th–95th) const lo = percentile(vals, lowPct);
const lo = percentileFrom(vals, 5); const hi = Math.max(lo + 1e-6, percentile(vals, highPct));
const hi = Math.max(lo + 1e-6, percentileFrom(vals, 95));
// 3) Optional despeckle (median 3×3) if (despeckle) median3x3NaN(cleaned, width, height);
if (despeckle) median3x3NaN(cleaned, width, height);
// 4) Normalize + gamma // simple skew-based inversion heuristic
const inv = 1 / (hi - lo); const q25 = percentile(vals, 25), q50 = percentile(vals, 50), q75 = percentile(vals, 75);
const norm = new Float32Array(N); const invert = autoInvert && (q75 - q50) < (q50 - q25);
for (let i = 0; i < N; i++) {
const v = cleaned[i]; const inv = 1 / (hi - lo);
if (!Number.isFinite(v)) { const norm = new Float32Array(N);
norm[i] = NaN; let finiteCount = 0;
continue; for (let i = 0; i < N; i++) {
} const v = cleaned[i];
let t = (v - lo) * inv; // 0..1 if (!Number.isFinite(v)) {
t = t < 0 ? 0 : t > 1 ? 1 : t; // clamp norm[i] = NaN;
norm[i] = Math.pow(t, gamma); // gamma continue;
} }
return {norm, lo, hi}; let t = (v - lo) * inv;
t = t < 0 ? 0 : t > 1 ? 1 : t; // clamp 0..1
if (invert) t = 1 - t;
norm[i] = Math.pow(t, gamma);
finiteCount++;
} }
console.log(`stretch: [${lo.toFixed(3)}, ${hi.toFixed(3)}], invert=${invert}, samples=`, finiteCount);
return {norm, lo, hi};
}
// optional: nicer NDVI colormap (cfastie-like) function mean(arr: Float32Array | number[]) {
function colorFromUnit(t: number): [number, number, number] { let s = 0, n = 0;
// t in [0..1]; quick 6-stop ramp for (let i = 0; i < (arr as any).length; i++) {
const stops = [ const v = (arr as any)[i];
[0, 0, 0], // black (nodata) if (Number.isFinite(v)) {
[165, 0, 38], // deep red s += v;
[215, 48, 39], // red n++;
[244, 109, 67], // orange }
[102, 189, 99], // green
[26, 152, 80], // deeper green
];
const p = t * (stops.length - 1);
const i = Math.floor(p);
const a = p - i;
const s0 = stops[i], s1 = stops[Math.min(i + 1, stops.length - 1)];
return [
Math.round(s0[0] + a * (s1[0] - s0[0])),
Math.round(s0[1] + a * (s1[1] - s0[1])),
Math.round(s0[2] + a * (s1[2] - s0[2])),
] as [number, number, number];
} }
return n ? s / n : 0;
}
// Try to compute a vegetation index from whatever bands exist.
// - 4+ bands: assume Sentinel-like [B02,B03,B04,B08] → NDVI using bands 3(NIR) & 2(RED)
// - 3 bands: compute Excess Green (ExG) from RGB (auto-detect BGR vs RGB by means)
// - 1 band: use as-is with autoInvert heuristics
function buildVegetationMetric(ras: (Float32Array | number[])[]) {
const n = ras.length;
if (n >= 4) {
const red = ras[2] as any, nir = ras[3] as any;
const N = (red as Float32Array).length;
const out = new Float32Array(N);
for (let i = 0; i < N; i++) {
const R = red[i], NIR = nir[i];
const den = NIR + R;
out[i] = (!Number.isFinite(den) || den === 0) ? NaN : (NIR - R) / den;
}
return {data: out, type: 'ndvi' as const};
}
if (n === 3) {
let R = ras[0] as any, G = ras[1] as any, B = ras[2] as any;
// If the third band has a higher mean than the first, assume BGR and swap R/B
if (mean(B) > mean(R)) {
const tmp = R;
R = B;
B = tmp;
}
const N = (R as Float32Array).length;
const out = new Float32Array(N);
for (let i = 0; i < N; i++) {
const rr = R[i], gg = G[i], bb = B[i];
out[i] = 2 * gg - rr - bb; // ExG (not normalized)
}
return {data: out, type: 'exg' as const};
}
// single band
return {data: ras[0] as Float32Array | number[], type: 'single' as const};
}
function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps) {
const [imageUrl, setImageUrl] = React.useState<string>();
const [coords, setCoords] = React.useState<ImageCorners | null>(null);
async function handleFile(file: File) { // Build the URL for the year-specific raster located in /public/rasters
const buf = await file.arrayBuffer(); function buildRasterUrl(y: number) {
return `/rasters/imagery_${y}_clipped_10m.tif`;
}
async function processTiffArrayBuffer(buf: ArrayBuffer) {
const tiff = await fromArrayBuffer(buf); const tiff = await fromArrayBuffer(buf);
const img = await tiff.getImage(); const img = await tiff.getImage();
const ras = await img.readRasters({samples: [0]}); // NDVI band // --- read all bands ---
const ras = await img.readRasters({interleave: false});
const width = img.getWidth(), height = img.getHeight(); const width = img.getWidth(), height = img.getHeight();
const bbox = img.getBoundingBox(); // [minX,minY,maxX,maxY] in the TIFF CRS
const gk = img.getGeoKeys() || {};
const epsg = gk.ProjectedCSTypeGeoKey || gk.ProjectedCRSGeoKey || 4326;
// corners -> lon/lat // --- compute corners in lon/lat ---
const src = proj4(projFromEPSG(Number(epsg))); const bbox = img.getBoundingBox(); // [minX,minY,maxX,maxY] in the TIFF CRS
const toLL = (x: number, y: number) => src.inverse([x, y]) as [number, number]; const gk: any = img.getGeoKeys() || {};
const epsg = gk.ProjectedCSTypeGeoKey || gk.ProjectedCRSGeoKey || gk.GeographicTypeGeoKey || 4326;
const def = projFromEPSG(Number(epsg));
const toLL = def
? ((x: number, y: number) => proj4(def).inverse([x, y]) as [number, number])
: ((x: number, y: number) => [x, y] as [number, number]);
const [minX, minY, maxX, maxY] = bbox; const [minX, minY, maxX, maxY] = bbox;
const corners: ImageCorners = [ const corners: ImageCorners = [
toLL(minX, maxY), // top-left toLL(minX, maxY), // top-left
...@@ -208,52 +243,78 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps ...@@ -208,52 +243,78 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
toLL(maxX, minY), // bottom-right toLL(maxX, minY), // bottom-right
toLL(minX, minY), // bottom-left toLL(minX, minY), // bottom-left
]; ];
console.log("EPSG:", epsg, "corners:", corners);
setCoords(corners); setCoords(corners);
// ...after you read rasters and get width/height (you already have this) :contentReference[oaicite:1]{index=1} // --- nodata detection (best-effort) ---
const ndviArr = ras[0] as Float32Array | number[]; const fd: any = (img as any).fileDirectory || {};
const {norm} = enhanceNdviForDisplay(ndviArr, width, height, { const nodataRaw = fd.GDAL_NODATA ?? undefined; // often a string
nodata: -9999, const nodata = nodataRaw !== undefined ? parseFloat(String(nodataRaw)) : undefined;
despeckle: true, // turn off if you need exact per-pixel values
gamma: 0.95 // --- derive vegetation metric ---
const metric = buildVegetationMetric(ras as any);
// --- enhancement: autoInvert only for non-NDVI cases ---
const {norm} = enhanceForDisplay(metric.data, width, height, {
nodata,
despeckle: true,
gamma: 0.95,
lowPct: 2,
highPct: 98,
autoInvert: metric.type !== 'ndvi',
}); });
// Paint to canvas // --- render to canvas ---
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
const imgData = ctx.createImageData(width, height); const imgData = ctx.createImageData(width, height);
let drawn = 0;
for (let i = 0; i < width * height; i++) { for (let i = 0; i < width * height; i++) {
const t = norm[i]; const t = norm[i];
if (!Number.isFinite(t)) { if (!Number.isFinite(t)) {
imgData.data[4 * i + 3] = 0; imgData.data[4 * i + 3] = 0;
continue; continue;
} // transparent }
const [r, g, b] = colorFromUnit(t); const [r, g, b] = colorFromUnit(t);
imgData.data[4 * i] = r; imgData.data[4 * i] = r;
imgData.data[4 * i + 1] = g; imgData.data[4 * i + 1] = g;
imgData.data[4 * i + 2] = b; imgData.data[4 * i + 2] = b;
imgData.data[4 * i + 3] = 255; imgData.data[4 * i + 3] = 255;
drawn++;
}
if (drawn === 0) {
console.warn("No finite pixels after normalization — drawing debug tile.");
ctx.fillStyle = 'rgba(255,0,0,0.8)';
ctx.fillRect(0, 0, width, height);
} else {
ctx.putImageData(imgData, 0, 0);
} }
ctx.putImageData(imgData, 0, 0);
setImageUrl(canvas.toDataURL()); setImageUrl(canvas.toDataURL());
} }
async function loadTiffFromUrl(url: string) {
setImageUrl(undefined);
const res = await fetch(url, {cache: 'no-store'});
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const buf = await res.arrayBuffer();
await processTiffArrayBuffer(buf);
}
// Auto-load whenever the year changes
React.useEffect(() => {
const url = buildRasterUrl(year);
loadTiffFromUrl(url).catch(err => {
console.error("TIFF load failed:", err);
});
}, [year]);
return ( return (
<div className='relative h-full w-full'> <div className='relative h-full w-full'>
<div style={{position: 'absolute', zIndex: 10, padding: 8}}>
<input
type="file"
accept=".tif,.tiff"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
<Map <Map
{...(viewState ? viewState : {})} {...(viewState ? viewState : {})}
{...(!viewState ? {initialViewState: DEFAULT_VIEW} : {})} {...(!viewState ? {initialViewState: DEFAULT_VIEW} : {})}
...@@ -262,8 +323,9 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps ...@@ -262,8 +323,9 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'} mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'}
> >
{imageUrl && coords && ( {imageUrl && coords && (
<Source id="ndvi-static" type="image" url={imageUrl} coordinates={coords}> <Source id="veg-index" type="image" url={imageUrl} coordinates={coords}>
<Layer id="ndvi-layer" type="raster" paint={{'raster-opacity': 0.5}}/> {/* Keep it a bit translucent over basemap; change to 1.0 if you prefer */}
<Layer id="veg-layer" type="raster" paint={{'raster-opacity': 0.65}}/>
</Source> </Source>
)} )}
</Map> </Map>
...@@ -271,4 +333,4 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps ...@@ -271,4 +333,4 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
); );
} }
export {MapViewer} export {MapViewer}
\ No newline at end of file
import {useCallback, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import {MapViewer} from '@/components/map/map-viewer'; import {MapViewer} from '@/components/map/map-viewer';
import type {ViewStateChangeEvent} from 'react-map-gl/maplibre'; import type {ViewStateChangeEvent} from 'react-map-gl/maplibre';
import {Card, CardContent} from '@/components/ui/card'; import {Card, CardContent} from '@/components/ui/card';
import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
type CameraState = { type CameraState = {
longitude: number; longitude: number;
...@@ -20,7 +17,8 @@ interface SplitMapProps { ...@@ -20,7 +17,8 @@ interface SplitMapProps {
maxYear: number; maxYear: number;
initialLeftYear?: number; initialLeftYear?: number;
initialRightYear?: number; initialRightYear?: number;
className?: string; // e.g., "h-full w-full" className?: string;
onYearsChange?: (leftYear: number, rightYear: number) => void;
} }
const FALLBACK_VIEW: CameraState = { const FALLBACK_VIEW: CameraState = {
...@@ -36,7 +34,8 @@ function SplitMap({ ...@@ -36,7 +34,8 @@ function SplitMap({
maxYear, maxYear,
initialLeftYear, initialLeftYear,
initialRightYear, initialRightYear,
className className,
onYearsChange,
}: SplitMapProps) { }: SplitMapProps) {
const years = useMemo( const years = useMemo(
() => Array.from({length: maxYear - minYear + 1}, (_, i) => minYear + i), () => Array.from({length: maxYear - minYear + 1}, (_, i) => minYear + i),
...@@ -53,6 +52,10 @@ function SplitMap({ ...@@ -53,6 +52,10 @@ function SplitMap({
setViewState({longitude, latitude, zoom, bearing, pitch}); setViewState({longitude, latitude, zoom, bearing, pitch});
}, []); }, []);
useEffect(() => {
onYearsChange?.(leftYear, rightYear);
}, [leftYear, rightYear, onYearsChange]);
return ( return (
<div className={`relative ${className ?? ''}`}> <div className={`relative ${className ?? ''}`}>
{/* Side-by-side panes */} {/* Side-by-side panes */}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment