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:
@@ -8,8 +8,15 @@ import {
|
|||||||
latestWaybackRelease,
|
latestWaybackRelease,
|
||||||
type WaybackRelease,
|
type WaybackRelease,
|
||||||
} from "./wayback-catalog";
|
} 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 }> = [
|
const OPTIONS: Array<{ id: BasemapId; label: string }> = [
|
||||||
{ id: "liberty", label: "Liberty" },
|
{ id: "liberty", label: "Liberty" },
|
||||||
@@ -17,6 +24,7 @@ const OPTIONS: Array<{ id: BasemapId; label: string }> = [
|
|||||||
{ id: "satellite", label: "Satelit" },
|
{ id: "satellite", label: "Satelit" },
|
||||||
{ id: "google", label: "Google" },
|
{ id: "google", label: "Google" },
|
||||||
{ id: "wayback", label: "Istoric" },
|
{ id: "wayback", label: "Istoric" },
|
||||||
|
{ id: "sentinel", label: "S2" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +34,10 @@ interface Props {
|
|||||||
waybackReleaseId?: string | null;
|
waybackReleaseId?: string | null;
|
||||||
/** Fired when the user picks a different Wayback date. */
|
/** Fired when the user picks a different Wayback date. */
|
||||||
onWaybackReleaseChange?: (release: WaybackRelease) => void;
|
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({
|
export function BasemapSwitcher({
|
||||||
@@ -33,6 +45,8 @@ export function BasemapSwitcher({
|
|||||||
onChange,
|
onChange,
|
||||||
waybackReleaseId,
|
waybackReleaseId,
|
||||||
onWaybackReleaseChange,
|
onWaybackReleaseChange,
|
||||||
|
sentinelYear,
|
||||||
|
onSentinelYearChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [releases, setReleases] = useState<WaybackRelease[] | null>(null);
|
const [releases, setReleases] = useState<WaybackRelease[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -80,6 +94,36 @@ export function BasemapSwitcher({
|
|||||||
</div>
|
</div>
|
||||||
</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" && (
|
{value === "wayback" && (
|
||||||
<div className="rounded-md border bg-background/95 px-2 py-1.5 shadow-sm backdrop-blur">
|
<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">
|
<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 { useSession } from "next-auth/react";
|
||||||
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
||||||
import type { WaybackRelease } from "./wayback-catalog";
|
import type { WaybackRelease } from "./wayback-catalog";
|
||||||
|
import type { SentinelYear } from "./sentinel-catalog";
|
||||||
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
|
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
|
||||||
import {
|
import {
|
||||||
FeatureInfoPanel,
|
FeatureInfoPanel,
|
||||||
@@ -30,6 +31,7 @@ export function GeoportalV2() {
|
|||||||
const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel);
|
const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel);
|
||||||
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
const [waybackRelease, setWaybackRelease] = useState<WaybackRelease | null>(null);
|
const [waybackRelease, setWaybackRelease] = useState<WaybackRelease | null>(null);
|
||||||
|
const [sentinelYear, setSentinelYear] = useState<SentinelYear | null>(null);
|
||||||
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
||||||
|
|
||||||
const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => {
|
const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => {
|
||||||
@@ -62,6 +64,7 @@ export function GeoportalV2() {
|
|||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
basemap={basemap}
|
basemap={basemap}
|
||||||
waybackReleaseId={waybackRelease?.id ?? null}
|
waybackReleaseId={waybackRelease?.id ?? null}
|
||||||
|
sentinelYear={sentinelYear?.year ?? null}
|
||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
selectedFeature={clicked}
|
selectedFeature={clicked}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
@@ -82,6 +85,8 @@ export function GeoportalV2() {
|
|||||||
onChange={setBasemap}
|
onChange={setBasemap}
|
||||||
waybackReleaseId={waybackRelease?.id ?? null}
|
waybackReleaseId={waybackRelease?.id ?? null}
|
||||||
onWaybackReleaseChange={setWaybackRelease}
|
onWaybackReleaseChange={setWaybackRelease}
|
||||||
|
sentinelYear={sentinelYear?.year ?? null}
|
||||||
|
onSentinelYearChange={setSentinelYear}
|
||||||
/>
|
/>
|
||||||
{clicked && (
|
{clicked && (
|
||||||
<FeatureInfoPanel
|
<FeatureInfoPanel
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Protocol as PmtilesProtocol } from "pmtiles";
|
|||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { BasemapId } from "./basemap-switcher";
|
import type { BasemapId } from "./basemap-switcher";
|
||||||
import { waybackTileUrl } from "./wayback-catalog";
|
import { waybackTileUrl } from "./wayback-catalog";
|
||||||
|
import { sentinelTileUrl, SENTINEL_YEARS } from "./sentinel-catalog";
|
||||||
import type { ClickedFeatureLite } from "./feature-info-panel";
|
import type { ClickedFeatureLite } from "./feature-info-panel";
|
||||||
|
|
||||||
// Ensure MapLibre CSS loaded (static import fails in standalone build)
|
// Ensure MapLibre CSS loaded (static import fails in standalone build)
|
||||||
@@ -79,6 +80,16 @@ const BASEMAPS: Record<BasemapId, BasemapDef> = {
|
|||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
maxzoom: 19,
|
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: '© <a href="https://s2maps.eu">Sentinel-2 cloudless</a> / EOX',
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 17,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const PM_SRC = "gis-pmtiles";
|
const PM_SRC = "gis-pmtiles";
|
||||||
@@ -117,6 +128,9 @@ interface Props {
|
|||||||
/** Wayback release id; only honored when basemap=="wayback". null = use
|
/** Wayback release id; only honored when basemap=="wayback". null = use
|
||||||
* the basemap-default fallback (latest release baked in BASEMAPS). */
|
* the basemap-default fallback (latest release baked in BASEMAPS). */
|
||||||
waybackReleaseId?: string | null;
|
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;
|
onFeatureClick: (f: ClickedFeatureLite | null) => void;
|
||||||
/** Currently selected feature — drives the highlight overlay so the
|
/** Currently selected feature — drives the highlight overlay so the
|
||||||
* user sees which parcel/building corresponds to the open info panel. */
|
* 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(
|
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||||
{ basemap, waybackReleaseId, onFeatureClick, selectedFeature, className },
|
{
|
||||||
|
basemap,
|
||||||
|
waybackReleaseId,
|
||||||
|
sentinelYear,
|
||||||
|
onFeatureClick,
|
||||||
|
selectedFeature,
|
||||||
|
className,
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -403,6 +424,9 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
|||||||
let def = BASEMAPS[basemap];
|
let def = BASEMAPS[basemap];
|
||||||
if (basemap === "wayback" && waybackReleaseId && def.type === "raster") {
|
if (basemap === "wayback" && waybackReleaseId && def.type === "raster") {
|
||||||
def = { ...def, tiles: [waybackTileUrl(waybackReleaseId)] };
|
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);
|
setReady(false);
|
||||||
|
|
||||||
@@ -487,7 +511,7 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
|||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
}, [basemap, waybackReleaseId, addGisLayers, onFeatureClick]);
|
}, [basemap, waybackReleaseId, sentinelYear, addGisLayers, onFeatureClick]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
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]!;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user