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,41 +1,127 @@
|
||||
"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";
|
||||
|
||||
export type BasemapId = "liberty" | "dark" | "satellite" | "google";
|
||||
export type BasemapId = "liberty" | "dark" | "satellite" | "google" | "wayback";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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 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 === "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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user