From 47d6ba329c203ffde0b1d6e0672b10781243c5e6 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Mon, 25 May 2026 00:49:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(geoportal-v2):=20"Istoric"=20basemap=20?= =?UTF-8?q?=E2=80=94=20ESRI=20Wayback=20with=20date=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//{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) --- src/modules/geoportal/v2/basemap-switcher.tsx | 124 +++++++++++++++--- src/modules/geoportal/v2/geoportal-v2.tsx | 10 +- src/modules/geoportal/v2/map-viewer.tsx | 29 +++- src/modules/geoportal/v2/wayback-catalog.ts | 92 +++++++++++++ 4 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 src/modules/geoportal/v2/wayback-catalog.ts diff --git a/src/modules/geoportal/v2/basemap-switcher.tsx b/src/modules/geoportal/v2/basemap-switcher.tsx index a96a89a..79df8ef 100644 --- a/src/modules/geoportal/v2/basemap-switcher.tsx +++ b/src/modules/geoportal/v2/basemap-switcher.tsx @@ -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(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 ( -
-
- {OPTIONS.map((opt) => ( - - ))} +
+
+
+ {OPTIONS.map((opt) => ( + + ))} +
+ + {value === "wayback" && ( +
+ + {loading && !releases ? ( +
+ Se încarcă lista… +
+ ) : releases && releases.length > 0 ? ( + + ) : ( +
+ Catalog Wayback indisponibil. +
+ )} +

+ 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. +

+
+ )}
); } diff --git a/src/modules/geoportal/v2/geoportal-v2.tsx b/src/modules/geoportal/v2/geoportal-v2.tsx index 1d9db77..ced98e9 100644 --- a/src/modules/geoportal/v2/geoportal-v2.tsx +++ b/src/modules/geoportal/v2/geoportal-v2.tsx @@ -4,6 +4,7 @@ import { useRef, useState, useCallback } from "react"; 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 { SearchBar, type FeatureHit, type UatHit } from "./search-bar"; import { FeatureInfoPanel, @@ -28,6 +29,7 @@ export function GeoportalV2() { const { data: session } = useSession(); const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel); const [basemap, setBasemap] = useState("liberty"); + const [waybackRelease, setWaybackRelease] = useState(null); const [clicked, setClicked] = useState(null); const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => { @@ -59,6 +61,7 @@ export function GeoportalV2() { - + {clicked && ( = { tileSize: 256, 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: '© Esri Wayback', + tileSize: 256, + maxzoom: 19, + }, }; const PM_SRC = "gis-pmtiles"; @@ -102,6 +114,9 @@ export type MapViewerHandle = { interface Props { 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; /** Currently selected feature — drives the highlight overlay so the * user sees which parcel/building corresponds to the open info panel. */ @@ -110,7 +125,7 @@ interface Props { } export const MapViewer = forwardRef(function MapViewer( - { basemap, onFeatureClick, selectedFeature, className }, + { basemap, waybackReleaseId, onFeatureClick, selectedFeature, className }, ref, ) { const containerRef = useRef(null); @@ -379,10 +394,16 @@ export const MapViewer = forwardRef(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(() => { 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); const map = new maplibregl.Map({ @@ -466,7 +487,7 @@ export const MapViewer = forwardRef(function MapViewer( map.remove(); mapRef.current = null; }; - }, [basemap, addGisLayers, onFeatureClick]); + }, [basemap, waybackReleaseId, addGisLayers, onFeatureClick]); useImperativeHandle( ref, diff --git a/src/modules/geoportal/v2/wayback-catalog.ts b/src/modules/geoportal/v2/wayback-catalog.ts new file mode 100644 index 0000000..8080998 --- /dev/null +++ b/src/modules/geoportal/v2/wayback-catalog.ts @@ -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//{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; + +let cache: { at: number; data: WaybackRelease[] } | null = null; +let inflight: Promise | 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 { + 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; +}