Commit b6e87b59 authored by Heisenberg5124's avatar Heisenberg5124
Browse files

Initial commit

parent b2e6db41
This diff is collapsed.
...@@ -13,8 +13,10 @@ ...@@ -13,8 +13,10 @@
"@napi-rs/canvas": "^0.1.80", "@napi-rs/canvas": "^0.1.80",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-router": "^1.132.47", "@tanstack/react-router": "^1.132.47",
"canvas": "^3.2.0", "canvas": "^3.2.0",
......
import './App.css' import './App.css'
import {MapViewer} from "@/components/map/map-viewer.tsx";
import {MapLayerCard} from "@/components/map/map-layer-card.tsx"; import {MapLayerCard} from "@/components/map/map-layer-card.tsx";
import {useState} from "react"; import {useState} from "react";
import TimelineSlider from "@/components/timeline/timeline-slider.tsx"; import {SingleMap} from "@/components/map/single-map.tsx";
import {SplitMap} from "@/components/map/split-map.tsx";
import {Card, CardContent} from "@/components/ui/card.tsx";
import {Switch} from "@/components/ui/switch.tsx";
import {Label} from "@/components/ui/label.tsx";
type Mode = 'single' | 'split';
function App() { function App() {
const MIN_YEAR = 2020; const MIN_YEAR = 2020;
const MAX_YEAR = 2025; const MAX_YEAR = 2025;
const INITIAL_YEAR = 2022; const INITIAL_YEAR = 2022;
const [year, setYear] = useState<number>(INITIAL_YEAR);
const [mode, setMode] = useState<Mode>('single');
return ( return (
<div className="h-screen w-screen overflow-hidden flex flex-col"> <div className="h-screen w-screen overflow-hidden relative">
<div className="relative flex-1 min-h-0 min-w-0 overflow-hidden"> <div className="absolute right-4 top-4 z-20 w-[min(320px,42vw)] space-y-3">
<div className="absolute inset-0"> <MapLayerCard/>
<MapViewer year={year}/> <Card className="bg-white/90 backdrop-blur">
</div> <CardContent className="p-4">
<div className="absolute right-4 top-4 z-10 w-1/6"> <div className="flex items-center justify-between gap-3">
<MapLayerCard/> <Label htmlFor="mode-switch" className="text-xs text-muted-foreground">
</div> Split mode
</Label>
<Switch
id="mode-switch"
checked={mode === 'split'}
onCheckedChange={(checked) => setMode(checked ? 'split' : 'single')}
/>
</div>
</CardContent>
</Card>
</div> </div>
<div <div className="h-full w-full">
className=" {mode === 'single' ? (
absolute left-1/2 bottom-5 -translate-x-1/2 z-30 <SingleMap
pointer-events-none minYear={MIN_YEAR}
pb-[env(safe-area-inset-bottom)] maxYear={MAX_YEAR}
w-full initialYear={INITIAL_YEAR}
" className="h-full w-full"
> />
<div ) : (
className=" <SplitMap
pointer-events-auto mx-auto minYear={MIN_YEAR}
max-w-[min(1000px,92vw)] maxYear={MAX_YEAR}
" initialLeftYear={INITIAL_YEAR - 1}
> initialRightYear={INITIAL_YEAR}
<TimelineSlider className="h-full w-full"
min={MIN_YEAR}
max={MAX_YEAR}
value={year}
onChange={setYear}
step={1}
loop
label={(v) => `${v}`}
/> />
</div> )}
</div> </div>
</div> </div>
) );
} }
export default App export default App
import {Map, Source, Layer} from 'react-map-gl/maplibre'; import {Layer, Map, Source, type ViewStateChangeEvent} from 'react-map-gl/maplibre';
import * as React from "react";
interface CameraState {
longitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
}
interface MapViewerProps { interface MapViewerProps {
year: number year: number;
viewState?: CameraState;
onMove?: (e: ViewStateChangeEvent) => void;
style?: React.CSSProperties;
mapStyleUrl?: string;
} }
function MapViewer({year}: MapViewerProps) { const DEFAULT_VIEW = {
latitude: 48.78035,
longitude: 9.17289,
zoom: 13,
bearing: 0,
pitch: 0
};
function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps) {
console.log('year', year);
const tilesUrl = `/tiles/ndvi_${year}/{z}/{x}/{y}.png`; const tilesUrl = `/tiles/ndvi_${year}/{z}/{x}/{y}.png`;
return ( return (
<Map initialViewState={{latitude: 48.78035, longitude: 9.17289, zoom: 13}} <Map
mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"> {...(viewState ? viewState : {})}
{...(!viewState ? { initialViewState: DEFAULT_VIEW } : {})}
onMove={onMove}
style={style}
mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'}>
<Source key={year} id="ndvi" type="raster" tiles={[tilesUrl]} tileSize={256}/> <Source key={year} id="ndvi" type="raster" tiles={[tilesUrl]} tileSize={256}/>
<Layer id="ndvi-layer" type="raster" source="ndvi" paint={{'raster-opacity': 0.85}}/> <Layer id="ndvi-layer" type="raster" source="ndvi" paint={{'raster-opacity': 0.85}}/>
</Map> </Map>
......
import {useState} from "react";
import {MapViewer} from "@/components/map/map-viewer.tsx";
import TimelineSlider from "@/components/timeline/timeline-slider.tsx";
interface SingleMapProps {
minYear: number;
maxYear: number;
initialYear?: number;
className?: string;
}
function SingleMap({
minYear,
maxYear,
initialYear,
className
}: SingleMapProps) {
const start = initialYear ?? maxYear;
const [year, setYear] = useState<number>(start);
return (
<div className={`relative ${className ?? ''}`}>
<div className="absolute inset-0">
<MapViewer year={year}/>
</div>
{/* bottom timeline slider */}
<div
className="absolute left-1/2 bottom-5 -translate-x-1/2 z-30 pointer-events-none pb-[env(safe-area-inset-bottom)] w-full">
<div className="pointer-events-auto mx-auto max-w-[min(1000px,92vw)]">
<TimelineSlider
min={minYear}
max={maxYear}
value={year}
onChange={setYear}
step={1}
loop
label={(v) => `${v}`}
/>
</div>
</div>
</div>
);
}
export { SingleMap };
\ No newline at end of file
import {useCallback, useEffect, useRef, useState} from 'react';
import type {ViewState, ViewStateChangeEvent} from 'react-map-gl/maplibre';
import * as React from "react";
import {MapViewer} from "@/components/map/map-viewer.tsx";
interface SplitMapViewerProps {
leftYear: number;
rightYear: number;
initialDivider?: number;
initialViewState?: ViewState;
style?: React.CSSProperties;
}
interface CameraState {
longitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
}
const FALLBACK_VIEW = {
latitude: 48.78035,
longitude: 9.17289,
zoom: 13,
bearing: 0,
pitch: 0
};
export function SplitMapViewer({
leftYear,
rightYear,
initialDivider = 0.5,
initialViewState,
style
}: SplitMapViewerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [divider, setDivider] = useState(Math.min(0.95, Math.max(0.05, initialDivider)));
const [viewState, setViewState] = useState<CameraState>(initialViewState ?? FALLBACK_VIEW);
const [dragging, setDragging] = useState(false);
const onMove = useCallback((e: ViewStateChangeEvent) => {
setViewState(e.viewState);
}, []);
useEffect(() => {
if (!dragging) return;
const onMoveDoc = (ev: MouseEvent) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = (ev.clientX - rect.left) / rect.width;
setDivider(Math.min(0.98, Math.max(0.02, pct)));
};
const onUpDoc = () => setDragging(false);
window.addEventListener('mousemove', onMoveDoc);
window.addEventListener('mouseup', onUpDoc);
return () => {
window.removeEventListener('mousemove', onMoveDoc);
window.removeEventListener('mouseup', onUpDoc);
};
}, [dragging]);
const leftClip = `inset(0 ${100 - divider * 100}% 0 0)`;
const rightClip = `inset(0 0 0 ${divider * 100}%)`;
return (
<div
ref={containerRef}
style={{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
...style
}}
>
{/* Right map (bottom layer) */}
<div
style={{
position: 'absolute',
inset: 0,
clipPath: rightClip
}}
><MapViewer
year={rightYear}
viewState={viewState}
onMove={onMove}
style={{height: '100%'}}
mapStyleUrl="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
/>
</div>
{/* Left map (top layer) */}
<div
style={{
position: 'absolute',
inset: 0,
clipPath: leftClip,
pointerEvents: 'auto'
}}
>
<MapViewer
year={leftYear}
viewState={viewState}
onMove={onMove}
style={{height: '100%'}}
mapStyleUrl="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
/>
</div>
{/* Divider */}
<div
role="separator"
aria-orientation="vertical"
onMouseDown={() => setDragging(true)}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${divider * 100}%`,
width: 2,
transform: 'translateX(-1px)',
background: '#fff',
boxShadow: '0 0 0 1px rgba(0,0,0,0.25)',
cursor: 'col-resize',
zIndex: 10
}}
>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 14,
height: 14,
borderRadius: 9999,
background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.35)'
}}
/>
</div>
</div>
);
}
import {useCallback, 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';
type CameraState = {
longitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
};
interface SplitMapProps {
minYear: number;
maxYear: number;
initialLeftYear?: number;
initialRightYear?: number;
className?: string; // e.g., "h-full w-full"
}
const FALLBACK_VIEW: CameraState = {
latitude: 48.78035,
longitude: 9.17289,
zoom: 13,
bearing: 0,
pitch: 0
};
function SplitMap({
minYear,
maxYear,
initialLeftYear,
initialRightYear,
className
}: SplitMapProps) {
const years = useMemo(
() => Array.from({length: maxYear - minYear + 1}, (_, i) => minYear + i),
[minYear, maxYear]
);
const [leftYear, setLeftYear] = useState<number>(initialLeftYear ?? years[0]);
const [rightYear, setRightYear] = useState<number>(initialRightYear ?? years.at(-1)!);
// Shared camera so the two maps stay in sync
const [viewState, setViewState] = useState<CameraState>(FALLBACK_VIEW);
const onMove = useCallback((e: ViewStateChangeEvent) => {
const {longitude, latitude, zoom, bearing, pitch} = e.viewState;
setViewState({longitude, latitude, zoom, bearing, pitch});
}, []);
return (
<div className={`relative ${className ?? ''}`}>
{/* Side-by-side panes */}
<div className="grid grid-cols-2 h-full w-full">
{/* LEFT MAP PANE */}
<div className="relative min-w-0 min-h-0">
<div className="absolute inset-0">
<MapViewer
year={leftYear}
viewState={viewState}
onMove={onMove}
style={{height: '100%', width: '100%'}}
/>
</div>
{/* Bottom-centered selector (robust centering, like slider) */}
<div
className="absolute inset-x-0 bottom-5 z-50 flex justify-center pointer-events-none pb-[env(safe-area-inset-bottom)]">
<div className="pointer-events-auto w-[90%] max-w-sm">
<Card className="bg-white/90 backdrop-blur">
<CardContent>
<Label htmlFor="leftYear" className="text-xs text-muted-foreground mb-1 block">
Left year
</Label>
<Select value={String(leftYear)} onValueChange={(v) => setLeftYear(Number(v))}>
<SelectTrigger id="leftYear" className="h-9">
<SelectValue placeholder="Select year"/>
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={`left-${y}`} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
</div>
</div>
{/* RIGHT MAP PANE */}
<div className="relative min-w-0 min-h-0">
<div className="absolute inset-0">
<MapViewer
year={rightYear}
viewState={viewState}
onMove={onMove}
style={{height: '100%', width: '100%'}}
/>
</div>
{/* Bottom-centered selector */}
<div
className="absolute inset-x-0 bottom-5 z-50 flex justify-center pointer-events-none pb-[env(safe-area-inset-bottom)]">
<div className="pointer-events-auto w-[90%] max-w-sm">
<Card className="bg-white/90 backdrop-blur">
<CardContent>
<Label htmlFor="rightYear" className="text-xs text-muted-foreground mb-1 block">
Right year
</Label>
<Select value={String(rightYear)} onValueChange={(v) => setRightYear(Number(v))}>
<SelectTrigger id="rightYear" className="h-9">
<SelectValue placeholder="Select year"/>
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={`right-${y}`} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
);
}
export {SplitMap};
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
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