feat(geoportal-v2): split-view compare mode (two basemaps, synced pan/zoom)
Adds a "Compară" toggle next to the basemap switcher. When active the
geoportal layout splits vertically into two MapViewer panes:
┌──────────────────┬─────────────────────┐
│ Primary pane │ Secondary pane │
│ - Search bar │ - Basemap switch │
│ - Basemap switch│ - Independent │
│ - Click→panel │ wayback/S2 │
│ │ - View-only │
└────────[<>]──────┴─────────────────────┘
▲
└── drag separator (10%-90% clamp)
Each pane keeps its own basemap + wayback release + sentinel year state,
so the user can show e.g. Google sat vs Esri Wayback 2018 side by side
or S2 2017 vs S2 2024 to spot rural changes.
MapViewer gains three optional props:
- viewState: controlled camera; jumpTo() on change with a sync flag
so the resulting moveend doesn't feed back into onViewChange.
- onViewChange: bubble user-driven moveend up to the parent so the
other pane stays in lockstep.
- disableFeatureClicks: secondary pane suppresses click→panel so the
panel only ever opens from primary clicks.
Behaviours preserved: when compareMode is off the layout collapses to
the original single full-width pane verbatim; basemap switcher + panel
sit in the same top-right slot. Clicking the toggle while in compare
mode auto-picks an alternative basemap for the secondary pane so the
comparison starts meaningful from the first frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Columns2, X } from "lucide-react";
|
||||
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
||||
import type { WaybackRelease } from "./wayback-catalog";
|
||||
import type { SentinelYear } from "./sentinel-catalog";
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
FeatureInfoPanel,
|
||||
type ClickedFeatureLite,
|
||||
} from "./feature-info-panel";
|
||||
import type { MapViewerHandle } from "./map-viewer";
|
||||
import type { MapViewerHandle, ViewState } from "./map-viewer";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const MapViewer = dynamic(
|
||||
() => import("./map-viewer").then((m) => ({ default: m.MapViewer })),
|
||||
@@ -29,26 +31,41 @@ export function GeoportalV2() {
|
||||
const mapRef = useRef<MapViewerHandle>(null);
|
||||
const { data: session } = useSession();
|
||||
const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel);
|
||||
|
||||
// Primary (left) pane state
|
||||
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||
const [waybackRelease, setWaybackRelease] = useState<WaybackRelease | null>(null);
|
||||
const [sentinelYear, setSentinelYear] = useState<SentinelYear | null>(null);
|
||||
|
||||
// Secondary (right) pane state — independent basemap choices so the
|
||||
// user can compare e.g. Google sat (azi) vs Wayback (2018).
|
||||
const [compareMode, setCompareMode] = useState(false);
|
||||
const [basemap2, setBasemap2] = useState<BasemapId>("wayback");
|
||||
const [waybackRelease2, setWaybackRelease2] = useState<WaybackRelease | null>(null);
|
||||
const [sentinelYear2, setSentinelYear2] = useState<SentinelYear | null>(null);
|
||||
|
||||
// Shared camera state — lifted up so both panes stay in lockstep on
|
||||
// pan/zoom. Initial value matches MapViewer's DEFAULT_CENTER/_ZOOM.
|
||||
const [viewState, setViewState] = useState<ViewState | null>(null);
|
||||
|
||||
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
||||
|
||||
// Split-pane position (0..1, fraction of the container that goes to
|
||||
// the left pane). Dragging the separator updates this live.
|
||||
const [splitRatio, setSplitRatio] = useState(0.5);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => {
|
||||
setClicked(f);
|
||||
}, []);
|
||||
|
||||
const handleUatSelect = useCallback((uat: UatHit) => {
|
||||
// gis-api search doesn't return UAT bounds today, so we can't flyTo
|
||||
// server-side. Workaround: deep-link to eterra.live/harta which has
|
||||
// bbox lookup (their /api/geoportal/uat-bounds + flyTo). Future:
|
||||
// add GET /api/v1/uat/:siruta/bounds to gis-api and fitBounds here.
|
||||
const url = `https://eterra.live/harta?siruta=${encodeURIComponent(uat.siruta)}`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
|
||||
const handleFeatureSelect = useCallback((f: FeatureHit) => {
|
||||
// Show panel directly (feature ID is known)
|
||||
setClicked({
|
||||
id: f.id,
|
||||
siruta: "",
|
||||
@@ -58,46 +75,185 @@ export function GeoportalV2() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Drag handlers for the split separator. Tracked on document so a
|
||||
// fast mouse-move that leaves the bar doesn't drop the drag.
|
||||
const startDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
document.body.style.cursor = "ew-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compareMode) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const ratio = Math.max(0.1, Math.min(0.9, x / rect.width));
|
||||
setSplitRatio(ratio);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [compareMode]);
|
||||
|
||||
// When entering compare mode, default the right pane to "Istoric" if
|
||||
// both panes would otherwise show the same basemap — uniqueness makes
|
||||
// the comparison meaningful from the first frame.
|
||||
const handleEnterCompare = () => {
|
||||
if (basemap2 === basemap) {
|
||||
const fallback: BasemapId = basemap === "wayback" ? "google" : "wayback";
|
||||
setBasemap2(fallback);
|
||||
}
|
||||
setCompareMode(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<MapViewer
|
||||
ref={mapRef}
|
||||
basemap={basemap}
|
||||
waybackReleaseId={waybackRelease?.id ?? null}
|
||||
sentinelYear={sentinelYear?.year ?? null}
|
||||
onFeatureClick={handleFeatureClick}
|
||||
selectedFeature={clicked}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
|
||||
{/* Top-left: search */}
|
||||
<div className="absolute left-3 top-3 z-10 flex flex-col gap-2">
|
||||
<SearchBar
|
||||
onUatSelect={handleUatSelect}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top-right: basemap + panel */}
|
||||
<div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2">
|
||||
<BasemapSwitcher
|
||||
value={basemap}
|
||||
onChange={setBasemap}
|
||||
<div ref={containerRef} className="absolute inset-0 flex">
|
||||
{/* Left / primary pane */}
|
||||
<div
|
||||
className="relative h-full"
|
||||
style={{ width: compareMode ? `${splitRatio * 100}%` : "100%" }}
|
||||
>
|
||||
<MapViewer
|
||||
ref={mapRef}
|
||||
basemap={basemap}
|
||||
waybackReleaseId={waybackRelease?.id ?? null}
|
||||
onWaybackReleaseChange={setWaybackRelease}
|
||||
sentinelYear={sentinelYear?.year ?? null}
|
||||
onSentinelYearChange={setSentinelYear}
|
||||
onFeatureClick={handleFeatureClick}
|
||||
selectedFeature={clicked}
|
||||
viewState={viewState}
|
||||
onViewChange={setViewState}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
{clicked && (
|
||||
<FeatureInfoPanel
|
||||
feature={clicked}
|
||||
onClose={() => setClicked(null)}
|
||||
onSelectFeature={setClicked}
|
||||
basic={basicPanel}
|
||||
|
||||
{/* Search bar lives on the primary pane only. */}
|
||||
<div className="absolute left-3 top-3 z-10 flex flex-col gap-2">
|
||||
<SearchBar
|
||||
onUatSelect={handleUatSelect}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right of the primary pane: compare toggle + basemap +
|
||||
(when not in compare mode) feature panel. */}
|
||||
<div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
compareMode ? setCompareMode(false) : handleEnterCompare()
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-background/95 px-2 py-1 text-xs font-medium shadow-sm backdrop-blur transition-colors",
|
||||
compareMode
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
title={
|
||||
compareMode
|
||||
? "Închide modul comparare"
|
||||
: "Compară 2 basemap-uri side-by-side"
|
||||
}
|
||||
>
|
||||
{compareMode ? (
|
||||
<>
|
||||
<X className="h-3 w-3" />
|
||||
Închide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Columns2 className="h-3 w-3" />
|
||||
Compară
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<BasemapSwitcher
|
||||
value={basemap}
|
||||
onChange={setBasemap}
|
||||
waybackReleaseId={waybackRelease?.id ?? null}
|
||||
onWaybackReleaseChange={setWaybackRelease}
|
||||
sentinelYear={sentinelYear?.year ?? null}
|
||||
onSentinelYearChange={setSentinelYear}
|
||||
/>
|
||||
{!compareMode && clicked && (
|
||||
<FeatureInfoPanel
|
||||
feature={clicked}
|
||||
onClose={() => setClicked(null)}
|
||||
onSelectFeature={setClicked}
|
||||
basic={basicPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag separator + right pane (only in compare mode) */}
|
||||
{compareMode && (
|
||||
<>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Trage pentru a redimensiona"
|
||||
onMouseDown={startDrag}
|
||||
className="relative z-20 h-full w-1 cursor-ew-resize bg-primary/70 hover:bg-primary active:bg-primary"
|
||||
>
|
||||
<div className="absolute left-1/2 top-1/2 flex h-12 w-3 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-primary shadow">
|
||||
<div className="h-6 w-0.5 bg-primary-foreground/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative h-full"
|
||||
style={{ width: `${(1 - splitRatio) * 100}%` }}
|
||||
>
|
||||
<MapViewer
|
||||
basemap={basemap2}
|
||||
waybackReleaseId={waybackRelease2?.id ?? null}
|
||||
sentinelYear={sentinelYear2?.year ?? null}
|
||||
onFeatureClick={() => {
|
||||
/* secondary pane is view-only; clicks don't open panel */
|
||||
}}
|
||||
selectedFeature={null}
|
||||
viewState={viewState}
|
||||
onViewChange={setViewState}
|
||||
disableFeatureClicks
|
||||
className="h-full w-full"
|
||||
/>
|
||||
|
||||
{/* Right pane basemap switcher — same UI, anchored to the
|
||||
right pane's top-right (which is the global top-right). */}
|
||||
<div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2">
|
||||
<BasemapSwitcher
|
||||
value={basemap2}
|
||||
onChange={setBasemap2}
|
||||
waybackReleaseId={waybackRelease2?.id ?? null}
|
||||
onWaybackReleaseChange={setWaybackRelease2}
|
||||
sentinelYear={sentinelYear2?.year ?? null}
|
||||
onSentinelYearChange={setSentinelYear2}
|
||||
/>
|
||||
{clicked && (
|
||||
<FeatureInfoPanel
|
||||
feature={clicked}
|
||||
onClose={() => setClicked(null)}
|
||||
onSelectFeature={setClicked}
|
||||
basic={basicPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bottom-right: cutover badge (visible until full rollout) */}
|
||||
<div className="pointer-events-none absolute bottom-2 right-2 z-10 rounded bg-primary/90 px-2 py-0.5 text-[10px] font-medium text-primary-foreground shadow">
|
||||
gis.ac · v2
|
||||
|
||||
@@ -123,6 +123,11 @@ export type MapViewerHandle = {
|
||||
getMap: () => maplibregl.Map | null;
|
||||
};
|
||||
|
||||
/** Plain camera state used to sync two MapViewer instances when the
|
||||
* compare mode is active. center + zoom is enough for our use case;
|
||||
* bearing/pitch stay at 0. */
|
||||
export type ViewState = { center: [number, number]; zoom: number };
|
||||
|
||||
interface Props {
|
||||
basemap: BasemapId;
|
||||
/** Wayback release id; only honored when basemap=="wayback". null = use
|
||||
@@ -136,6 +141,19 @@ interface Props {
|
||||
* user sees which parcel/building corresponds to the open info panel. */
|
||||
selectedFeature: ClickedFeatureLite | null;
|
||||
className?: string;
|
||||
/** Optional controlled camera. When provided, the map jumpTo()s here
|
||||
* any time the prop changes — used by compare mode to keep both
|
||||
* panes in lockstep. The internal moveend handler still updates
|
||||
* the parent via onViewChange so movement on either pane drives the
|
||||
* other. */
|
||||
viewState?: ViewState | null;
|
||||
/** Fired on every moveend (debounced to a single rAF). Use to lift
|
||||
* camera state up into a parent for compare-mode sync. */
|
||||
onViewChange?: (vs: ViewState) => void;
|
||||
/** When true, clicks on map features are ignored. Compare mode uses
|
||||
* this for the secondary pane so the info panel only opens from
|
||||
* primary-pane clicks. */
|
||||
disableFeatureClicks?: boolean;
|
||||
}
|
||||
|
||||
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
@@ -146,12 +164,25 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
onFeatureClick,
|
||||
selectedFeature,
|
||||
className,
|
||||
viewState,
|
||||
onViewChange,
|
||||
disableFeatureClicks,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
|
||||
const viewStateRef = useRef<ViewState>({
|
||||
center: viewState?.center ?? DEFAULT_CENTER,
|
||||
zoom: viewState?.zoom ?? DEFAULT_ZOOM,
|
||||
});
|
||||
// Set by the prop-driven sync effect right before a programmatic
|
||||
// jumpTo so the matching moveend doesn't loop back to onViewChange.
|
||||
const syncingRef = useRef(false);
|
||||
const onViewChangeRef = useRef<Props["onViewChange"]>(undefined);
|
||||
onViewChangeRef.current = onViewChange;
|
||||
const disableClicksRef = useRef(false);
|
||||
disableClicksRef.current = Boolean(disableFeatureClicks);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const addGisLayers = useCallback((map: maplibregl.Map) => {
|
||||
@@ -443,10 +474,19 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
|
||||
map.on("moveend", () => {
|
||||
const c = map.getCenter();
|
||||
viewStateRef.current = {
|
||||
const next: ViewState = {
|
||||
center: [c.lng, c.lat],
|
||||
zoom: map.getZoom(),
|
||||
};
|
||||
viewStateRef.current = next;
|
||||
// Bubble up unless this moveend was caused by our own prop-sync
|
||||
// jumpTo — that would create a feedback loop between the two
|
||||
// panes in compare mode.
|
||||
if (syncingRef.current) {
|
||||
syncingRef.current = false;
|
||||
return;
|
||||
}
|
||||
onViewChangeRef.current?.(next);
|
||||
});
|
||||
|
||||
map.on("load", () => {
|
||||
@@ -454,8 +494,11 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
// Click handler — prioritize terenuri, then cladiri
|
||||
// Click handler — prioritize terenuri, then cladiri. The secondary
|
||||
// pane in compare mode sets disableFeatureClicks so panel clicks
|
||||
// only ever originate from the primary pane.
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
if (disableClicksRef.current) return;
|
||||
const feats = map.queryRenderedFeatures(e.point, {
|
||||
layers: ["v2-terenuri-hit", "v2-cladiri-fill"],
|
||||
});
|
||||
@@ -513,6 +556,23 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||
};
|
||||
}, [basemap, waybackReleaseId, sentinelYear, addGisLayers, onFeatureClick]);
|
||||
|
||||
// Prop-driven camera sync. When the parent updates viewState (compare
|
||||
// mode pan/zoom from the OTHER pane), reflect it here via jumpTo so the
|
||||
// two panes stay in lockstep. Skip when the props match what we already
|
||||
// show — common when our own moveend bubbled up + parent echoed back.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !viewState) return;
|
||||
const current = viewStateRef.current;
|
||||
const sameCenter =
|
||||
Math.abs(current.center[0] - viewState.center[0]) < 1e-7 &&
|
||||
Math.abs(current.center[1] - viewState.center[1]) < 1e-7;
|
||||
const sameZoom = Math.abs(current.zoom - viewState.zoom) < 1e-4;
|
||||
if (sameCenter && sameZoom) return;
|
||||
syncingRef.current = true;
|
||||
map.jumpTo({ center: viewState.center, zoom: viewState.zoom });
|
||||
}, [viewState]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user