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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: '© <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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user