Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
HFT-informatics-weekend
SI - Time Series Map Viewer
Commits
bab0122d
Commit
bab0122d
authored
Oct 13, 2025
by
Heisenberg5124
Browse files
Initial commit
parent
2a1ad0b6
Changes
5
Show whitespace changes
Inline
Side-by-side
frontend/src/App.tsx
View file @
bab0122d
...
@@ -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
=
20
20
;
const
MIN_YEAR
=
20
15
;
const
MAX_YEAR
=
2025
;
const
MAX_YEAR
=
2025
;
const
INITIAL_YEAR
=
202
2
;
const
INITIAL_YEAR
=
202
5
;
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
>
...
...
frontend/src/api/__generated__/index.ts
View file @
bab0122d
...
@@ -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 value
s
(
mean/median/p05/p95
)
*/
*/
export
function
useNdviChangeValueNdviChangeValueGet
<
TData
=
Awaited
<
ReturnType
<
typeof
ndviChangeValueNdviChangeValueGet
>>
,
TError
=
HTTPValidationError
>
(
export
function
useNdviChangeValueNdviChangeValueGet
<
TData
=
Awaited
<
ReturnType
<
typeof
ndviChangeValueNdviChangeValueGet
>>
,
TError
=
HTTPValidationError
>
(
...
...
frontend/src/components/map/kpi-dialog.tsx
View file @
bab0122d
...
@@ -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
)
+
1
e
-
6
]
}
hide
/>
type
=
"number"
domain
=
{
domain
??
[
Math
.
min
(
a
,
b
),
Math
.
max
(
a
,
b
)
+
1
e
-
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-[4
2
0px] w-[min(92vw,4
2
0px)]"
>
<
DialogContent
className
=
"sm:max-w-[4
8
0px] w-[min(92vw,4
8
0px)]"
>
<
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
.
y
ear
}
{
left
Y
ear
??
"
—
"
}
</
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
.
y
ear
}
{
right
Y
ear
??
"
—
"
}
</
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 items-center justify-between"
>
<
div
className
=
"text-sm text-muted-foreground"
>
Δ distribution (right − left)
</
div
>
<
div
className
=
"flex gap-2"
>
<
div
className
=
"flex gap-2"
>
<
Badge
className
=
"bg-rose-50 text-rose-600 border-rose-200"
variant
=
"secondary"
>
<
Badge
className
=
{
cn
(
"
border-rose-200 bg-rose-50 text-rose-600
"
)
}
>
5th
{
fmtNDVI
(
changeP05
)
}
</
Badge
>
Loss
{
fmtPct
(
change
?.
fracLoss
)
}
<
Badge
variant
=
"secondary"
>
Median
{
fmtNDVI
(
changeMedian
)
}
</
Badge
>
</
Badge
>
<
Badge
<
Badge
className
=
"bg-emerald-50 text-emerald-600 border-emerald-200"
variant
=
"secondary"
>
className
=
{
cn
(
"
border-emerald-200 bg-emerald-50 text-emerald-600
"
)
}
>
95th
{
fmtNDVI
(
changeP95
)
}
</
Badge
>
Gain
{
fmtPct
(
change
?.
fracGain
)
}
</
div
>
</
Badge
>
</
div
>
<
div
className
=
"mt-2 text-xs text-muted-foreground"
>
Negative values indicate vegetation loss; positive values indicate gain.
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className
=
"space-y-2"
>
{
/* Acquisition info */
}
<
div
className
=
"text-xs text-muted-foreground"
>
Loss ≤ −0.1
</
div
>
<
div
className
=
"flex flex-wrap items-center gap-2 pt-1"
>
<
Progress
<
Badge
variant
=
"outline"
title
=
"Cloud cover of the selected scenes"
>
value
=
{
Math
.
max
(
0
,
Math
.
min
(
100
,
(
change
?.
fracLoss
??
0
)
*
100
))
}
{
leftYear
??
"
—
"
}
cloud
{
fmtPct
(
leftCloud
)
}
className
=
"h-2 [&>div]:bg-rose-500"
</
Badge
>
/>
<
Badge
variant
=
"outline"
title
=
"Cloud cover of the selected scenes"
>
<
div
className
=
"text-xs text-muted-foreground pt-2"
>
Gain ≥ +0.1
</
div
>
{
rightYear
??
"
—
"
}
cloud
{
fmtPct
(
rightCloud
)
}
<
Progress
</
Badge
>
value
=
{
Math
.
max
(
0
,
Math
.
min
(
100
,
(
change
?.
fracGain
??
0
)
*
100
))
}
className
=
"h-2 [&>div]:bg-emerald-500"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
</
DialogContent
>
</
DialogContent
>
</
Dialog
>
</
Dialog
>
);
);
...
...
frontend/src/components/map/map-viewer.tsx
View file @
bab0122d
...
@@ -34,64 +34,48 @@ const DEFAULT_VIEW = {
...
@@ -34,64 +34,48 @@ 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
>
();
const
[
coords
,
setCoords
]
=
React
.
useState
<
ImageCorners
|
null
>
(
null
);
function
percentileFrom
(
values
:
number
[],
p
:
number
)
{
if
(
!
values
.
length
)
return
NaN
;
if
(
!
values
.
length
)
return
NaN
;
const
k
=
Math
.
max
(
0
,
Math
.
min
(
values
.
length
-
1
,
Math
.
floor
((
p
/
100
)
*
(
values
.
length
-
1
))));
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
);
const
v
=
values
.
slice
().
sort
((
a
,
b
)
=>
a
-
b
);
return
v
[
k
];
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
;
...
@@ -112,95 +96,146 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
...
@@ -112,95 +96,146 @@ function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps
out
[
idx
]
=
NaN
;
out
[
idx
]
=
NaN
;
continue
;
continue
;
}
}
// median of n values in win[0..n)
const
temp
=
Array
.
from
(
win
.
slice
(
0
,
n
)).
sort
((
a
,
b
)
=>
a
-
b
);
const
temp
=
Array
.
from
(
win
.
slice
(
0
,
n
)).
sort
((
a
,
b
)
=>
a
-
b
);
out
[
idx
]
=
temp
[
Math
.
floor
(
n
/
2
)];
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
// when the distribution looks left-skewed (common if the band highlights built-up).
function
enhanceForDisplay
(
data
:
Float32Array
|
number
[],
width
:
number
,
width
:
number
,
height
:
number
,
height
:
number
,
{
nodata
=
-
9999
,
despeckle
=
true
,
gamma
=
0.95
}:
{
nodata
?:
number
;
despeckle
?:
boolean
;
gamma
?:
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
N
=
width
*
height
;
// 1) Clean + collect valid values for robust limits
const
cleaned
=
new
Float32Array
(
N
);
const
cleaned
=
new
Float32Array
(
N
);
const
vals
:
number
[]
=
[];
const
vals
:
number
[]
=
[];
for
(
let
i
=
0
;
i
<
N
;
i
++
)
{
for
(
let
i
=
0
;
i
<
N
;
i
++
)
{
le
t
v
=
(
ndvi
as
any
)[
i
];
cons
t
v
=
(
data
as
any
)[
i
];
if
(
!
Number
.
isFinite
(
v
)
||
v
===
nodata
||
v
<
-
1.5
||
v
>
1.5
)
{
if
(
!
Number
.
isFinite
(
v
)
||
(
nodata
!==
undefined
&&
v
===
nodata
)
)
{
cleaned
[
i
]
=
NaN
;
cleaned
[
i
]
=
NaN
;
continue
;
continue
;
}
}
cleaned
[
i
]
=
v
;
cleaned
[
i
]
=
v
;
vals
.
push
(
v
);
vals
.
push
(
v
);
}
}
if
(
!
vals
.
length
)
return
{
norm
:
cleaned
,
lo
:
-
1
,
hi
:
1
};
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
+
1
e
-
6
,
percentile
(
vals
,
highPct
));
const
hi
=
Math
.
max
(
lo
+
1
e
-
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
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
inv
=
1
/
(
hi
-
lo
);
const
norm
=
new
Float32Array
(
N
);
const
norm
=
new
Float32Array
(
N
);
let
finiteCount
=
0
;
for
(
let
i
=
0
;
i
<
N
;
i
++
)
{
for
(
let
i
=
0
;
i
<
N
;
i
++
)
{
const
v
=
cleaned
[
i
];
const
v
=
cleaned
[
i
];
if
(
!
Number
.
isFinite
(
v
))
{
if
(
!
Number
.
isFinite
(
v
))
{
norm
[
i
]
=
NaN
;
norm
[
i
]
=
NaN
;
continue
;
continue
;
}
}
let
t
=
(
v
-
lo
)
*
inv
;
// 0..1
let
t
=
(
v
-
lo
)
*
inv
;
t
=
t
<
0
?
0
:
t
>
1
?
1
:
t
;
// clamp
t
=
t
<
0
?
0
:
t
>
1
?
1
:
t
;
// clamp 0..1
norm
[
i
]
=
Math
.
pow
(
t
,
gamma
);
// gamma
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
};
return
{
norm
,
lo
,
hi
};
}
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
;
}
// optional: nicer NDVI colormap (cfastie-like)
// Try to compute a vegetation index from whatever bands exist.
function
colorFromUnit
(
t
:
number
):
[
number
,
number
,
number
]
{
// - 4+ bands: assume Sentinel-like [B02,B03,B04,B08] → NDVI using bands 3(NIR) & 2(RED)
// t in [0..1]; quick 6-stop ramp
// - 3 bands: compute Excess Green (ExG) from RGB (auto-detect BGR vs RGB by means)
const
stops
=
[
// - 1 band: use as-is with autoInvert heuristics
[
0
,
0
,
0
],
// black (nodata)
function
buildVegetationMetric
(
ras
:
(
Float32Array
|
number
[])[])
{
[
165
,
0
,
38
],
// deep red
const
n
=
ras
.
length
;
[
215
,
48
,
39
],
// red
if
(
n
>=
4
)
{
[
244
,
109
,
67
],
// orange
const
red
=
ras
[
2
]
as
any
,
nir
=
ras
[
3
]
as
any
;
[
102
,
189
,
99
],
// green
const
N
=
(
red
as
Float32Array
).
length
;
[
26
,
152
,
80
],
// deeper green
const
out
=
new
Float32Array
(
N
);
];
for
(
let
i
=
0
;
i
<
N
;
i
++
)
{
const
p
=
t
*
(
stops
.
length
-
1
);
const
R
=
red
[
i
],
NIR
=
nir
[
i
];
const
i
=
Math
.
floor
(
p
);
const
den
=
NIR
+
R
;
const
a
=
p
-
i
;
out
[
i
]
=
(
!
Number
.
isFinite
(
den
)
||
den
===
0
)
?
NaN
:
(
NIR
-
R
)
/
den
;
const
s0
=
stops
[
i
],
s1
=
stops
[
Math
.
min
(
i
+
1
,
stops
.
length
-
1
)];
}
return
[
return
{
data
:
out
,
type
:
'
ndvi
'
as
const
};
Math
.
round
(
s0
[
0
]
+
a
*
(
s1
[
0
]
-
s0
[
0
])),
}
Math
.
round
(
s0
[
1
]
+
a
*
(
s1
[
1
]
-
s0
[
1
])),
if
(
n
===
3
)
{
Math
.
round
(
s0
[
2
]
+
a
*
(
s1
[
2
]
-
s0
[
2
])),
let
R
=
ras
[
0
]
as
any
,
G
=
ras
[
1
]
as
any
,
B
=
ras
[
2
]
as
any
;
]
as
[
number
,
number
,
number
];
// 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
>
...
...
frontend/src/components/map/split-map.tsx
View file @
bab0122d
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 */
}
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment