feat(geoportal-v2): "Istoric" basemap — ESRI Wayback with date picker

Adds a 5th basemap option ("Istoric") that loads historical ESRI World
Imagery snapshots from the public Wayback service. Free, CORS-open,
193+ releases dating back to 2014; each release is identified by a
numeric id baked into the tile URL pattern.

How it works:
- wayback-catalog.ts fetches the public waybackconfig.json once per
  24h, parses each release's title for an ISO date, and exposes a
  newest-first list of { id, date, title, itemUrl }.
- BasemapSwitcher reveals a date dropdown beneath the basemap buttons
  when "Istoric" is selected. Auto-picks the latest release on first
  show; user can pick any past date.
- map-viewer rebuilds the MapLibre style when basemap=="wayback" with
  the user-picked release id patched into the raster source tiles[].

Tile URL format (WMTS, {z}/{y}/{x} not {z}/{x}/{y}):
  https://wayback.maptiles.arcgis.com/arcgis/rest/services
    /World_Imagery/WMTS/1.0.0/default028mm/MapServer
    /tile/<releaseId>/{z}/{y}/{x}

Esri's edge deduplicates identical tiles via 301 → another release id
(MapLibre/browser follows the redirect transparently), so picking a
random old date doesn't always show a different image when nothing
changed in that pixel — that's a feature of Wayback, not a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-25 00:49:12 +03:00
parent 9c496419fd
commit 47d6ba329c
4 changed files with 231 additions and 24 deletions
+88 -2
View File
@@ -1,23 +1,65 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { Clock } from "lucide-react";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
import {
loadWaybackReleases,
latestWaybackRelease,
type WaybackRelease,
} from "./wayback-catalog";
export type BasemapId = "liberty" | "dark" | "satellite" | "google"; export type BasemapId = "liberty" | "dark" | "satellite" | "google" | "wayback";
const OPTIONS: Array<{ id: BasemapId; label: string }> = [ const OPTIONS: Array<{ id: BasemapId; label: string }> = [
{ id: "liberty", label: "Liberty" }, { id: "liberty", label: "Liberty" },
{ id: "dark", label: "Întunecat" }, { id: "dark", label: "Întunecat" },
{ id: "satellite", label: "Satelit" }, { id: "satellite", label: "Satelit" },
{ id: "google", label: "Google" }, { id: "google", label: "Google" },
{ id: "wayback", label: "Istoric" },
]; ];
interface Props { interface Props {
value: BasemapId; value: BasemapId;
onChange: (id: BasemapId) => void; 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;
} }
export function BasemapSwitcher({ value, onChange }: Props) { export function BasemapSwitcher({
value,
onChange,
waybackReleaseId,
onWaybackReleaseChange,
}: 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 ( return (
<div className="space-y-1">
<div className="rounded-md border bg-background/95 shadow-sm backdrop-blur"> <div className="rounded-md border bg-background/95 shadow-sm backdrop-blur">
<div className="flex items-center gap-1 p-1"> <div className="flex items-center gap-1 p-1">
{OPTIONS.map((opt) => ( {OPTIONS.map((opt) => (
@@ -37,5 +79,49 @@ export function BasemapSwitcher({ value, onChange }: Props) {
))} ))}
</div> </div>
</div> </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>
); );
} }
+9 -1
View File
@@ -4,6 +4,7 @@ import { useRef, useState, useCallback } from "react";
import dynamic from "next/dynamic"; 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 { SearchBar, type FeatureHit, type UatHit } from "./search-bar"; import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
import { import {
FeatureInfoPanel, FeatureInfoPanel,
@@ -28,6 +29,7 @@ export function GeoportalV2() {
const { data: session } = useSession(); const { data: session } = useSession();
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 [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) => {
@@ -59,6 +61,7 @@ export function GeoportalV2() {
<MapViewer <MapViewer
ref={mapRef} ref={mapRef}
basemap={basemap} basemap={basemap}
waybackReleaseId={waybackRelease?.id ?? null}
onFeatureClick={handleFeatureClick} onFeatureClick={handleFeatureClick}
selectedFeature={clicked} selectedFeature={clicked}
className="h-full w-full" className="h-full w-full"
@@ -74,7 +77,12 @@ export function GeoportalV2() {
{/* Top-right: basemap + panel */} {/* Top-right: basemap + panel */}
<div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2"> <div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2">
<BasemapSwitcher value={basemap} onChange={setBasemap} /> <BasemapSwitcher
value={basemap}
onChange={setBasemap}
waybackReleaseId={waybackRelease?.id ?? null}
onWaybackReleaseChange={setWaybackRelease}
/>
{clicked && ( {clicked && (
<FeatureInfoPanel <FeatureInfoPanel
feature={clicked} feature={clicked}
+25 -4
View File
@@ -7,6 +7,7 @@ import maplibregl from "maplibre-gl";
import { Protocol as PmtilesProtocol } from "pmtiles"; 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 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)
@@ -67,6 +68,17 @@ const BASEMAPS: Record<BasemapId, BasemapDef> = {
tileSize: 256, tileSize: 256,
maxzoom: 20, maxzoom: 20,
}, },
// Wayback is a per-release ESRI World Imagery snapshot. The tile URL
// baked in here is just a fallback used before the catalog loads — the
// actual release id is threaded in via the waybackReleaseId prop and
// applied through buildStyle below.
wayback: {
type: "raster",
tiles: [waybackTileUrl("49059")],
attribution: '&copy; <a href="https://livingatlas.arcgis.com/wayback/">Esri Wayback</a>',
tileSize: 256,
maxzoom: 19,
},
}; };
const PM_SRC = "gis-pmtiles"; const PM_SRC = "gis-pmtiles";
@@ -102,6 +114,9 @@ export type MapViewerHandle = {
interface Props { interface Props {
basemap: BasemapId; basemap: BasemapId;
/** Wayback release id; only honored when basemap=="wayback". null = use
* the basemap-default fallback (latest release baked in BASEMAPS). */
waybackReleaseId?: 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. */
@@ -110,7 +125,7 @@ interface Props {
} }
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer( export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
{ basemap, onFeatureClick, selectedFeature, className }, { basemap, waybackReleaseId, onFeatureClick, selectedFeature, className },
ref, ref,
) { ) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -379,10 +394,16 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
}); });
}, []); }, []);
// Initialize / rebuild on basemap change // Initialize / rebuild on basemap change. For Wayback we patch the
// tiles[] with the user-selected release id so each date snapshot
// shows the right historical imagery; the BASEMAPS entry just has a
// placeholder fallback used before the catalog loads.
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const def = BASEMAPS[basemap]; let def = BASEMAPS[basemap];
if (basemap === "wayback" && waybackReleaseId && def.type === "raster") {
def = { ...def, tiles: [waybackTileUrl(waybackReleaseId)] };
}
setReady(false); setReady(false);
const map = new maplibregl.Map({ const map = new maplibregl.Map({
@@ -466,7 +487,7 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
}; };
}, [basemap, addGisLayers, onFeatureClick]); }, [basemap, waybackReleaseId, addGisLayers, onFeatureClick]);
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -0,0 +1,92 @@
// ESRI World Imagery Wayback — catalog helper for the "Satelit istoric"
// basemap. Wayback is a free, public, CORS-open WMTS service that keeps
// every snapshot of Esri's World Imagery layer back to 2014 (~193
// releases as of 2026-05). Each release is identified by a numeric
// `releaseId` baked into the tile URL:
//
// https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery
// /WMTS/1.0.0/default028mm/MapServer/tile/<releaseId>/{z}/{y}/{x}
//
// The catalog at /waybackconfig.json lists every release with its title
// ("World Imagery (Wayback 2026-04-30)") which is the only place the
// date lives. We parse the title to expose a sortable timeline.
//
// Caching: catalog is ~100 KB and updates monthly when Esri ships a new
// release. Module-level cache + lazy fetch keeps the picker responsive
// while still picking up new releases without a panel reload.
export type WaybackRelease = {
/** Numeric release id baked into the tile URL (e.g. 49059). */
id: string;
/** Parsed release date — "2026-04-30" — from the item title. */
date: string;
/** Human-readable title verbatim from catalog. */
title: string;
/** Templated tile URL with {level}/{row}/{col} placeholders. */
itemUrl: string;
};
const CATALOG_URL =
"https://s3-us-west-2.amazonaws.com/config.maptiles.arcgis.com/waybackconfig.json";
type CatalogEntry = {
itemID?: string;
itemTitle?: string;
itemURL?: string;
};
type Catalog = Record<string, CatalogEntry>;
let cache: { at: number; data: WaybackRelease[] } | null = null;
let inflight: Promise<WaybackRelease[]> | null = null;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
function parseDate(title: string): string | null {
// "World Imagery (Wayback 2026-04-30)" → 2026-04-30
const m = /(\d{4}-\d{2}-\d{2})/.exec(title);
return m?.[1] ?? null;
}
/** Fetch + parse the Wayback catalog. Memoized 24h. */
export async function loadWaybackReleases(): Promise<WaybackRelease[]> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await fetch(CATALOG_URL, { cache: "no-store" });
if (!res.ok) throw new Error(`wayback catalog ${res.status}`);
const json = (await res.json()) as Catalog;
const releases: WaybackRelease[] = [];
for (const [id, entry] of Object.entries(json)) {
if (!entry?.itemTitle || !entry?.itemURL) continue;
const date = parseDate(entry.itemTitle);
if (!date) continue;
releases.push({
id,
date,
title: entry.itemTitle,
itemUrl: entry.itemURL,
});
}
// Newest first — date string comparison works because ISO yyyy-mm-dd.
releases.sort((a, b) => (a.date < b.date ? 1 : -1));
cache = { at: Date.now(), data: releases };
return releases;
} finally {
inflight = null;
}
})();
return inflight;
}
/** Build the tile-URL template MapLibre expects for a given release id.
* WMTS uses {z}/{y}/{x} order in the path (NOT {z}/{x}/{y}). */
export function waybackTileUrl(releaseId: string): string {
return `https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/WMTS/1.0.0/default028mm/MapServer/tile/${releaseId}/{z}/{y}/{x}`;
}
/** Pick a reasonable default release: the most recent one. */
export function latestWaybackRelease(releases: WaybackRelease[]): WaybackRelease | null {
return releases[0] ?? null;
}