feat(geoportal-v2): "S2" basemap — Sentinel-2 cloudless annual mosaics

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>
This commit is contained in:
Claude VM
2026-05-25 07:03:51 +03:00
parent 47d6ba329c
commit 04f666638e
4 changed files with 126 additions and 3 deletions
+45 -1
View File
@@ -8,8 +8,15 @@ import {
latestWaybackRelease,
type WaybackRelease,
} from "./wayback-catalog";
import { SENTINEL_YEARS, type SentinelYear } from "./sentinel-catalog";
export type BasemapId = "liberty" | "dark" | "satellite" | "google" | "wayback";
export type BasemapId =
| "liberty"
| "dark"
| "satellite"
| "google"
| "wayback"
| "sentinel";
const OPTIONS: Array<{ id: BasemapId; label: string }> = [
{ id: "liberty", label: "Liberty" },
@@ -17,6 +24,7 @@ const OPTIONS: Array<{ id: BasemapId; label: string }> = [
{ id: "satellite", label: "Satelit" },
{ id: "google", label: "Google" },
{ id: "wayback", label: "Istoric" },
{ id: "sentinel", label: "S2" },
];
interface Props {
@@ -26,6 +34,10 @@ interface Props {
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({
@@ -33,6 +45,8 @@ export function BasemapSwitcher({
onChange,
waybackReleaseId,
onWaybackReleaseChange,
sentinelYear,
onSentinelYearChange,
}: Props) {
const [releases, setReleases] = useState<WaybackRelease[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -80,6 +94,36 @@ export function BasemapSwitcher({
</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">
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { useSession } from "next-auth/react";
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
import type { WaybackRelease } from "./wayback-catalog";
import type { SentinelYear } from "./sentinel-catalog";
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
import {
FeatureInfoPanel,
@@ -30,6 +31,7 @@ export function GeoportalV2() {
const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel);
const [basemap, setBasemap] = useState<BasemapId>("liberty");
const [waybackRelease, setWaybackRelease] = useState<WaybackRelease | null>(null);
const [sentinelYear, setSentinelYear] = useState<SentinelYear | null>(null);
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => {
@@ -62,6 +64,7 @@ export function GeoportalV2() {
ref={mapRef}
basemap={basemap}
waybackReleaseId={waybackRelease?.id ?? null}
sentinelYear={sentinelYear?.year ?? null}
onFeatureClick={handleFeatureClick}
selectedFeature={clicked}
className="h-full w-full"
@@ -82,6 +85,8 @@ export function GeoportalV2() {
onChange={setBasemap}
waybackReleaseId={waybackRelease?.id ?? null}
onWaybackReleaseChange={setWaybackRelease}
sentinelYear={sentinelYear?.year ?? null}
onSentinelYearChange={setSentinelYear}
/>
{clicked && (
<FeatureInfoPanel
+26 -2
View File
@@ -8,6 +8,7 @@ import { Protocol as PmtilesProtocol } from "pmtiles";
import { cn } from "@/shared/lib/utils";
import type { BasemapId } from "./basemap-switcher";
import { waybackTileUrl } from "./wayback-catalog";
import { sentinelTileUrl, SENTINEL_YEARS } from "./sentinel-catalog";
import type { ClickedFeatureLite } from "./feature-info-panel";
// Ensure MapLibre CSS loaded (static import fails in standalone build)
@@ -79,6 +80,16 @@ const BASEMAPS: Record<BasemapId, BasemapDef> = {
tileSize: 256,
maxzoom: 19,
},
// Sentinel-2 cloudless yearly mosaic from EOX. Default fallback is the
// latest year; actual year picked by the sentinelYear prop and applied
// through buildStyle below.
sentinel: {
type: "raster",
tiles: [sentinelTileUrl(SENTINEL_YEARS[0]!.layerId)],
attribution: '&copy; <a href="https://s2maps.eu">Sentinel-2 cloudless</a> / EOX',
tileSize: 256,
maxzoom: 17,
},
};
const PM_SRC = "gis-pmtiles";
@@ -117,6 +128,9 @@ interface Props {
/** Wayback release id; only honored when basemap=="wayback". null = use
* the basemap-default fallback (latest release baked in BASEMAPS). */
waybackReleaseId?: string | null;
/** Sentinel-2 cloudless year string ("2024", "2023", …); only honored
* when basemap=="sentinel". null = latest year (basemap default). */
sentinelYear?: string | null;
onFeatureClick: (f: ClickedFeatureLite | null) => void;
/** Currently selected feature — drives the highlight overlay so the
* user sees which parcel/building corresponds to the open info panel. */
@@ -125,7 +139,14 @@ interface Props {
}
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
{ basemap, waybackReleaseId, onFeatureClick, selectedFeature, className },
{
basemap,
waybackReleaseId,
sentinelYear,
onFeatureClick,
selectedFeature,
className,
},
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -403,6 +424,9 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
let def = BASEMAPS[basemap];
if (basemap === "wayback" && waybackReleaseId && def.type === "raster") {
def = { ...def, tiles: [waybackTileUrl(waybackReleaseId)] };
} else if (basemap === "sentinel" && sentinelYear && def.type === "raster") {
const y = SENTINEL_YEARS.find((s) => s.year === sentinelYear);
if (y) def = { ...def, tiles: [sentinelTileUrl(y.layerId)] };
}
setReady(false);
@@ -487,7 +511,7 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
map.remove();
mapRef.current = null;
};
}, [basemap, waybackReleaseId, addGisLayers, onFeatureClick]);
}, [basemap, waybackReleaseId, sentinelYear, addGisLayers, onFeatureClick]);
useImperativeHandle(
ref,
@@ -0,0 +1,50 @@
// EOX Sentinel-2 cloudless — public, CORS-open annual mosaics of
// cloud-free Sentinel-2 imagery. Free for non-commercial use; commercial
// licence at cloudless.eox.at. Rezolutie 10 m/pixel — bun pentru
// schimbari rurale large-scale (defrisari, sere, hale, terenuri agri-
// cole), nu pentru detaliu de cladire/strada.
//
// Layer IDs follow EOX's convention `s2cloudless-YYYY_3857` for Web
// Mercator (which MapLibre uses). 2016 is served under the legacy
// `s2cloudless_3857` ID (pooled with 2017). Available years update
// when EOX ships the new mosaic — refresh the constants here when
// 2025/2026 ship.
//
// Tile URL template:
// https://tiles.maps.eox.at/wmts/1.0.0/<layerId>/default/g/{z}/{y}/{x}.jpg
export type SentinelYear = {
/** ISO year string for the picker label. Special: "2016" maps to the
* legacy pooled `s2cloudless` layer. */
year: string;
/** WMTS layer identifier on tiles.maps.eox.at. */
layerId: string;
};
// Hardcoded for now — EOX adds one mosaic per year, no live catalog to
// poll (their /WMTSCapabilities.xml works but is ~150 KB and the year
// list grows by exactly one entry per year). Update this array when
// EOX ships a new mosaic; until then we save the round-trip on every
// map load.
export const SENTINEL_YEARS: SentinelYear[] = [
{ year: "2024", layerId: "s2cloudless-2024_3857" },
{ year: "2023", layerId: "s2cloudless-2023_3857" },
{ year: "2022", layerId: "s2cloudless-2022_3857" },
{ year: "2021", layerId: "s2cloudless-2021_3857" },
{ year: "2020", layerId: "s2cloudless-2020_3857" },
{ year: "2019", layerId: "s2cloudless-2019_3857" },
{ year: "2018", layerId: "s2cloudless-2018_3857" },
{ year: "2017", layerId: "s2cloudless-2017_3857" },
// EOX pools 2016 + 2017 under the legacy bare identifier.
{ year: "2016", layerId: "s2cloudless_3857" },
];
/** Build the MapLibre raster tile URL template for a given EOX layer. */
export function sentinelTileUrl(layerId: string): string {
return `https://tiles.maps.eox.at/wmts/1.0.0/${layerId}/default/g/{z}/{y}/{x}.jpg`;
}
/** Default to the latest available year. */
export function latestSentinelYear(): SentinelYear {
return SENTINEL_YEARS[0]!;
}