04f666638e
Adds a 6th basemap option ("S2") backed by EOX's free, public,
CORS-open Sentinel-2 cloudless WMTS service. Annual mosaics from 2016
to 2024 (2025/2026 not yet shipped by EOX); 10 m/pixel resolution
good for large-scale rural change detection (deforestation,
greenhouses, halls, agriculture) but not for individual buildings.
Companion to the Wayback basemap shipped earlier — Wayback gives
high-res city detail at irregular snapshot dates, Sentinel-2 gives
predictable yearly cadence at coarse rural-scale resolution.
UI mirrors Wayback: when "S2" is selected the switcher reveals a year
dropdown beneath the basemap row; the map-viewer rebuilds the raster
source with the right EOX layer ID. Default year = latest (2024).
Note on licensing: EOX's 2018+ mosaics are CC BY-NC-SA 4.0 — non-
commercial. The UI surfaces this + the commercial-licence pointer
(cloudless.eox.at). 2016 (s2cloudless) + 2017 are CC BY 4.0, no
non-commercial restriction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Clock } from "lucide-react";
|
|
import { cn } from "@/shared/lib/utils";
|
|
import {
|
|
loadWaybackReleases,
|
|
latestWaybackRelease,
|
|
type WaybackRelease,
|
|
} from "./wayback-catalog";
|
|
import { SENTINEL_YEARS, type SentinelYear } from "./sentinel-catalog";
|
|
|
|
export type BasemapId =
|
|
| "liberty"
|
|
| "dark"
|
|
| "satellite"
|
|
| "google"
|
|
| "wayback"
|
|
| "sentinel";
|
|
|
|
const OPTIONS: Array<{ id: BasemapId; label: string }> = [
|
|
{ id: "liberty", label: "Liberty" },
|
|
{ id: "dark", label: "Întunecat" },
|
|
{ id: "satellite", label: "Satelit" },
|
|
{ id: "google", label: "Google" },
|
|
{ id: "wayback", label: "Istoric" },
|
|
{ id: "sentinel", label: "S2" },
|
|
];
|
|
|
|
interface Props {
|
|
value: BasemapId;
|
|
onChange: (id: BasemapId) => void;
|
|
/** Selected Wayback release id (only meaningful when value=wayback). */
|
|
waybackReleaseId?: string | null;
|
|
/** Fired when the user picks a different Wayback date. */
|
|
onWaybackReleaseChange?: (release: WaybackRelease) => void;
|
|
/** Selected Sentinel-2 cloudless year (only meaningful when value=sentinel). */
|
|
sentinelYear?: string | null;
|
|
/** Fired when the user picks a different Sentinel-2 year. */
|
|
onSentinelYearChange?: (year: SentinelYear) => void;
|
|
}
|
|
|
|
export function BasemapSwitcher({
|
|
value,
|
|
onChange,
|
|
waybackReleaseId,
|
|
onWaybackReleaseChange,
|
|
sentinelYear,
|
|
onSentinelYearChange,
|
|
}: Props) {
|
|
const [releases, setReleases] = useState<WaybackRelease[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (value !== "wayback") return;
|
|
if (releases || loading) return;
|
|
setLoading(true);
|
|
loadWaybackReleases()
|
|
.then((rels) => {
|
|
setReleases(rels);
|
|
// Auto-select the latest release if caller hasn't picked one.
|
|
if (!waybackReleaseId && onWaybackReleaseChange) {
|
|
const latest = latestWaybackRelease(rels);
|
|
if (latest) onWaybackReleaseChange(latest);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.warn("[basemap] wayback catalog load failed:", err);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [value, releases, loading, waybackReleaseId, onWaybackReleaseChange]);
|
|
|
|
const selectedRelease = releases?.find((r) => r.id === waybackReleaseId);
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="rounded-md border bg-background/95 shadow-sm backdrop-blur">
|
|
<div className="flex items-center gap-1 p-1">
|
|
{OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
onClick={() => onChange(opt.id)}
|
|
className={cn(
|
|
"rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
value === opt.id
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{value === "sentinel" && (
|
|
<div className="rounded-md border bg-background/95 px-2 py-1.5 shadow-sm backdrop-blur">
|
|
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
An mosaic Sentinel-2
|
|
</label>
|
|
<select
|
|
value={sentinelYear ?? SENTINEL_YEARS[0]!.year}
|
|
onChange={(e) => {
|
|
const y = SENTINEL_YEARS.find((s) => s.year === e.target.value);
|
|
if (y && onSentinelYearChange) onSentinelYearChange(y);
|
|
}}
|
|
className="mt-1 w-full rounded border bg-background px-1.5 py-1 text-xs"
|
|
>
|
|
{SENTINEL_YEARS.map((y) => (
|
|
<option key={y.year} value={y.year}>
|
|
{y.year}
|
|
{y.year === "2016" ? " (+2017)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-1 text-[9px] leading-tight text-muted-foreground">
|
|
Mozaic anual cloud-free Sentinel-2 (rezoluție ~10 m). Bun
|
|
pentru schimbări rurale large-scale (defrișări, hale,
|
|
sere). Nu vezi case sau detalii fine. © EOX / Copernicus.
|
|
Uz comercial: cloudless.eox.at.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{value === "wayback" && (
|
|
<div className="rounded-md border bg-background/95 px-2 py-1.5 shadow-sm backdrop-blur">
|
|
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
Dată snapshot
|
|
</label>
|
|
{loading && !releases ? (
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
Se încarcă lista…
|
|
</div>
|
|
) : releases && releases.length > 0 ? (
|
|
<select
|
|
value={waybackReleaseId ?? ""}
|
|
onChange={(e) => {
|
|
const r = releases.find((rel) => rel.id === e.target.value);
|
|
if (r && onWaybackReleaseChange) onWaybackReleaseChange(r);
|
|
}}
|
|
className="mt-1 w-full rounded border bg-background px-1.5 py-1 text-xs"
|
|
title={
|
|
selectedRelease
|
|
? `Release ${selectedRelease.id} — ${selectedRelease.title}`
|
|
: undefined
|
|
}
|
|
>
|
|
{releases.map((rel) => (
|
|
<option key={rel.id} value={rel.id}>
|
|
{rel.date}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="mt-1 text-xs text-destructive">
|
|
Catalog Wayback indisponibil.
|
|
</div>
|
|
)}
|
|
<p className="mt-1 text-[9px] leading-tight text-muted-foreground">
|
|
Imagini satelit Esri arhivate (193+ snapshot-uri din 2014).
|
|
Acoperirea diferă per zonă — la unele date locația ta poate
|
|
avea imagine identică cu un release anterior.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|