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";
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() {
const MIN_YEAR = 2020;
const MIN_YEAR = 2015;
const MAX_YEAR = 2025;
const INITIAL_YEAR = 2022;
const INITIAL_YEAR = 2025;
const [mode, setMode] = useState<Mode>('single');
const [leftYear, setLeftYear] = useState(INITIAL_YEAR - 1);
const [rightYear, setRightYear] = useState(INITIAL_YEAR);
return (
<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">
<MapLayerCard/>
<SplitMapCard mode={mode} setMode={setMode}/>
{mode === 'split' && (<KpiDialog {...kpiMock} />)}
{mode === 'split' && (<KpiDialog start_year={leftYear} end_year={rightYear} />)}
</div>
<div className="h-full w-full">
......@@ -59,6 +40,10 @@ function App() {
initialLeftYear={INITIAL_YEAR - 1}
initialRightYear={INITIAL_YEAR}
className="h-full w-full"
onYearsChange={(l, r) => {
setLeftYear(l);
setRightYear(r);
}}
/>
)}
</div>
......
......@@ -7,7 +7,7 @@ An API that returns a satellite image file for a given year and computes NDVI &
- Swagger UI: `/docs`
- ReDoc: `/redoc`
* OpenAPI spec version: 1.4.0
* OpenAPI spec version: 1.5.0
*/
import {
useMutation,
......@@ -201,10 +201,6 @@ start_year: number;
* @minimum 2013
*/
end_year: number;
/**
* NDVI summary to compare: mean | median | p05 | p95
*/
metric?: string;
/**
* @minimum 0
* @maximum 100
......@@ -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 = (
params: NdviChangeValueNdviChangeValueGetParams,
......@@ -636,7 +634,7 @@ export function useNdviChangeValueNdviChangeValueGet<TData = Awaited<ReturnType<
, queryClient?: QueryClient
): 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>(
......
......@@ -7,7 +7,6 @@ import {
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Badge} from "@/components/ui/badge";
import {Progress} from "@/components/ui/progress";
import {
ResponsiveContainer,
ComposedChart,
......@@ -18,32 +17,13 @@ import {
} from "recharts";
import {BarChart3} from "lucide-react";
import {cn} from "@/lib/utils";
import {useNdviChangeValueNdviChangeValueGet} from "@/api/__generated__";
type YearStats = {
year: number;
median: number;
p95: number;
frac_gt_0_4?: number; // 0..1
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) : "");
// ————————————————————————————————————————————————————————
// Helpers
// ————————————————————————————————————————————————————————
const fmtNDVI = (v?: number) =>
v != null && Number.isFinite(v) ? v.toFixed(3) : "";
const fmtPct = (f?: number) =>
f == null || !Number.isFinite(f) ? "" : `${(f * 100).toFixed(1)}%`;
......@@ -72,11 +52,7 @@ function SlopeMini({
<ResponsiveContainer width={width} height={height}>
<ComposedChart data={both} margin={{top: 6, bottom: 6, left: 6, right: 6}}>
<XAxis dataKey="x" type="category" hide/>
<YAxis
type="number"
domain={domain ?? [Math.min(a, b), Math.max(a, b) + 1e-6]}
hide
/>
<YAxis 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}/>
<Scatter data={[both[0]]} dataKey="v" fill="#9ca3af"/> {/* left year */}
<Scatter data={[both[1]]} dataKey="v" fill="#3b82f6"/> {/* right year */}
......@@ -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({
left,
right,
change,
start_year,
end_year,
title = "NDVI comparison",
className,
open,
onOpenChange,
triggerLabel = "KPIs",
}: 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
......@@ -141,66 +156,68 @@ export default function KpiDialog({
</DialogTrigger>
</div>
<DialogContent className="sm:max-w-[420px] w-[min(92vw,420px)]">
<DialogContent className="sm:max-w-[480px] w-[min(92vw,480px)]">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{title}</span>
<span className="flex items-center gap-3">
<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"/>
{left.year}
{leftYear ?? ""}
</span>
<span className="flex items-center gap-1 text-xs">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-blue-500"/>
{right.year}
{rightYear ?? ""}
</span>
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-1">
<Row label="Median NDVI" a={left.median} b={right.median} fmt={fmtNDVI} domain={ndviDomain}/>
<Row label="p95 NDVI" a={left.p95} b={right.p95} fmt={fmtNDVI} domain={ndviDomain}/>
<Row
label="% area > 0.4"
a={left.frac_gt_0_4 ?? NaN}
b={right.frac_gt_0_4 ?? NaN}
fmt={(v) => fmtPct(v)}
domain={[0, 1]}
/>
<Row
label="% area > 0.6"
a={left.frac_gt_0_6 ?? NaN}
b={right.frac_gt_0_6 ?? NaN}
fmt={(v) => fmtPct(v)}
domain={[0, 1]}
/>
<div className="pt-2 flex items-center justify-between">
<div className="text-sm text-muted-foreground">Δ strong change</div>
<div className="flex gap-2">
<Badge className="bg-rose-50 text-rose-600 border-rose-200" variant="secondary">
Loss {fmtPct(change?.fracLoss)}
{/* Loading / error states */}
{isPending ? (
<div className="space-y-3 pt-1 animate-pulse">
<div className="h-4 rounded bg-muted"/>
<div className="h-4 rounded bg-muted"/>
<div className="h-4 rounded bg-muted"/>
<div className="h-4 rounded bg-muted"/>
</div>
) : isError || !data ? (
<div className="text-sm text-rose-600">Failed to load NDVI KPIs. Please try again.</div>
) : (
<div className="space-y-4 pt-1">
{/* Top stats */}
<Row label="Median NDVI" a={leftMedian as number} b={rightMedian as number} fmt={fmtNDVI}
domain={ndviDomain}/>
<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}/>
{/* Change distribution */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Δ distribution (right − left)</div>
<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 className="bg-emerald-50 text-emerald-600 border-emerald-200" variant="secondary">
Gain {fmtPct(change?.fracGain)}
<Badge variant="outline" title="Cloud cover of the selected scenes">
{rightYear ?? ""} cloud {fmtPct(rightCloud)}
</Badge>
</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>
</Dialog>
);
......
......@@ -34,173 +34,208 @@ const DEFAULT_VIEW = {
pitch: 0
};
const data = {
year: 2025,
collection: "sentinel-2-l2a",
native_res_m: 10,
compare_res_m: 30,
ndvi_tif: "ndvi_2025_30m.tif",
workdir: "C:\\Users\\vinhd\\AppData\\Local\\Temp\\ndvi_2025_06ozaqja",
stats: {
count: 4347,
mean: -57.32052993774414,
median: 0.15449757874011993,
p05: 0.04584402218461037,
p95: 0.4455701410770416,
frac_gt_0_2: 0.3556475730388774,
frac_gt_0_4: 0.07292385553255118,
frac_gt_0_6: 0.0006901311249137336,
area_gt_0_4_m2: 285300,
area_gt_0_6_m2: 2700
}
};
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];
// --- color helpers ---
function colorFromUnit(t: number): [number, number, number] {
// NDVI-friendly ramp: black -> red/orange -> greens
const stops = [
[0, 0, 0], // 0
[165, 0, 38], // bare/built
[215, 48, 39],
[244, 109, 67],
[102, 189, 99], // healthy veg
[26, 152, 80], // max veg
];
const p = Math.max(0, Math.min(1, 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];
}
// Build proj4 string for common UTM EPSG codes (Sentinel/Landsat)
// --- CRS helpers ---
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 >= 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) {
const [imageUrl, setImageUrl] = React.useState<string>();
const [coords, setCoords] = React.useState<ImageCorners | null>(null);
function percentileFrom(values: number[], p: number) {
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];
}
// --- statistics & enhancement ---
function percentile(values: number[], p: number) {
if (!values.length) return NaN;
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);
return v[k];
}
function median3x3NaN(buf: Float32Array, w: number, h: number) {
const out = new Float32Array(buf.length);
const win = new Float32Array(9);
let idx = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++, idx++) {
let n = 0;
for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= h) continue;
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= w) continue;
const v = buf[yy * w + xx];
if (Number.isFinite(v)) win[n++] = v;
}
function median3x3NaN(buf: Float32Array, w: number, h: number) {
const out = new Float32Array(buf.length);
const win = new Float32Array(9);
let idx = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++, idx++) {
let n = 0;
for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= h) continue;
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= w) continue;
const v = buf[yy * w + xx];
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(
ndvi: Float32Array | number[],
width: number,
height: number,
{nodata = -9999, despeckle = true, gamma = 0.95}: { nodata?: number; despeckle?: boolean; gamma?: number }
) {
const N = width * height;
// 1) Clean + collect valid values for robust limits
const cleaned = new Float32Array(N);
const vals: number[] = [];
for (let i = 0; i < N; i++) {
let v = (ndvi as any)[i];
if (!Number.isFinite(v) || v === nodata || v < -1.5 || v > 1.5) {
cleaned[i] = NaN;
continue;
}
cleaned[i] = v;
vals.push(v);
// Generic enhancer that does **not** assume NDVI range. It only drops nodata and
// stretches by percentiles of the actual data values. Optionally auto-inverts
// when the distribution looks left-skewed (common if the band highlights built-up).
function enhanceForDisplay(
data: Float32Array | number[],
width: number,
height: number,
opts: {
nodata?: number;
despeckle?: boolean;
gamma?: number;
lowPct?: number;
highPct?: number;
autoInvert?: boolean
} = {}
) {
const {nodata, despeckle = true, gamma = 0.95, lowPct = 2, highPct = 98, autoInvert = false} = opts;
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 = percentileFrom(vals, 5);
const hi = Math.max(lo + 1e-6, percentileFrom(vals, 95));
const lo = percentile(vals, lowPct);
const hi = Math.max(lo + 1e-6, percentile(vals, highPct));
// 3) Optional despeckle (median 3×3)
if (despeckle) median3x3NaN(cleaned, width, height);
if (despeckle) median3x3NaN(cleaned, width, height);
// 4) Normalize + gamma
const inv = 1 / (hi - lo);
const norm = new Float32Array(N);
for (let i = 0; i < N; i++) {
const v = cleaned[i];
if (!Number.isFinite(v)) {
norm[i] = NaN;
continue;
}
let t = (v - lo) * inv; // 0..1
t = t < 0 ? 0 : t > 1 ? 1 : t; // clamp
norm[i] = Math.pow(t, gamma); // gamma
// simple skew-based inversion heuristic
const q25 = percentile(vals, 25), q50 = percentile(vals, 50), q75 = percentile(vals, 75);
const invert = autoInvert && (q75 - q50) < (q50 - q25);
const inv = 1 / (hi - lo);
const norm = new Float32Array(N);
let finiteCount = 0;
for (let i = 0; i < N; i++) {
const v = cleaned[i];
if (!Number.isFinite(v)) {
norm[i] = NaN;
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 colorFromUnit(t: number): [number, number, number] {
// t in [0..1]; quick 6-stop ramp
const stops = [
[0, 0, 0], // black (nodata)
[165, 0, 38], // deep red
[215, 48, 39], // red
[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];
function mean(arr: Float32Array | number[]) {
let s = 0, n = 0;
for (let i = 0; i < (arr as any).length; i++) {
const v = (arr as any)[i];
if (Number.isFinite(v)) {
s += v;
n++;
}
}
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) {
const buf = await file.arrayBuffer();
// Build the URL for the year-specific raster located in /public/rasters
function buildRasterUrl(y: number) {
return `/rasters/imagery_${y}_clipped_10m.tif`;
}
async function processTiffArrayBuffer(buf: ArrayBuffer) {
const tiff = await fromArrayBuffer(buf);
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 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
const src = proj4(projFromEPSG(Number(epsg)));
const toLL = (x: number, y: number) => src.inverse([x, y]) as [number, number];
// --- compute corners in lon/lat ---
const bbox = img.getBoundingBox(); // [minX,minY,maxX,maxY] in the TIFF CRS
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 corners: ImageCorners = [
toLL(minX, maxY), // top-left
......@@ -208,52 +243,78 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
toLL(maxX, minY), // bottom-right
toLL(minX, minY), // bottom-left
];
console.log("EPSG:", epsg, "corners:", corners);
setCoords(corners);
// ...after you read rasters and get width/height (you already have this) :contentReference[oaicite:1]{index=1}
const ndviArr = ras[0] as Float32Array | number[];
const {norm} = enhanceNdviForDisplay(ndviArr, width, height, {
nodata: -9999,
despeckle: true, // turn off if you need exact per-pixel values
gamma: 0.95
// --- nodata detection (best-effort) ---
const fd: any = (img as any).fileDirectory || {};
const nodataRaw = fd.GDAL_NODATA ?? undefined; // often a string
const nodata = nodataRaw !== undefined ? parseFloat(String(nodataRaw)) : undefined;
// --- 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');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
const imgData = ctx.createImageData(width, height);
let drawn = 0;
for (let i = 0; i < width * height; i++) {
const t = norm[i];
if (!Number.isFinite(t)) {
imgData.data[4 * i + 3] = 0;
continue;
} // transparent
}
const [r, g, b] = colorFromUnit(t);
imgData.data[4 * i] = r;
imgData.data[4 * i + 1] = g;
imgData.data[4 * i + 2] = b;
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());
}
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 (
<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
{...(viewState ? viewState : {})}
{...(!viewState ? {initialViewState: DEFAULT_VIEW} : {})}
......@@ -262,8 +323,9 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'}
>
{imageUrl && coords && (
<Source id="ndvi-static" type="image" url={imageUrl} coordinates={coords}>
<Layer id="ndvi-layer" type="raster" paint={{'raster-opacity': 0.5}}/>
<Source id="veg-index" type="image" url={imageUrl} coordinates={coords}>
{/* 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>
)}
</Map>
......@@ -271,4 +333,4 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
);
}
export {MapViewer}
\ No newline at end of file
export {MapViewer}
import {useCallback, useMemo, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {MapViewer} from '@/components/map/map-viewer';
import type {ViewStateChangeEvent} from 'react-map-gl/maplibre';
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 = {
longitude: number;
......@@ -20,7 +17,8 @@ interface SplitMapProps {
maxYear: number;
initialLeftYear?: number;
initialRightYear?: number;
className?: string; // e.g., "h-full w-full"
className?: string;
onYearsChange?: (leftYear: number, rightYear: number) => void;
}
const FALLBACK_VIEW: CameraState = {
......@@ -36,7 +34,8 @@ function SplitMap({
maxYear,
initialLeftYear,
initialRightYear,
className
className,
onYearsChange,
}: SplitMapProps) {
const years = useMemo(
() => Array.from({length: maxYear - minYear + 1}, (_, i) => minYear + i),
......@@ -53,6 +52,10 @@ function SplitMap({
setViewState({longitude, latitude, zoom, bearing, pitch});
}, []);
useEffect(() => {
onYearsChange?.(leftYear, rightYear);
}, [leftYear, rightYear, onYearsChange]);
return (
<div className={`relative ${className ?? ''}`}>
{/* 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