diff --git a/src/modules/geoportal/v2/geoportal-v2.tsx b/src/modules/geoportal/v2/geoportal-v2.tsx index 21d1a15..009a5cd 100644 --- a/src/modules/geoportal/v2/geoportal-v2.tsx +++ b/src/modules/geoportal/v2/geoportal-v2.tsx @@ -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(null); const { data: session } = useSession(); const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel); + + // Primary (left) pane state const [basemap, setBasemap] = useState("liberty"); const [waybackRelease, setWaybackRelease] = useState(null); const [sentinelYear, setSentinelYear] = useState(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("wayback"); + const [waybackRelease2, setWaybackRelease2] = useState(null); + const [sentinelYear2, setSentinelYear2] = useState(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(null); + const [clicked, setClicked] = useState(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(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 ( -
- - - {/* Top-left: search */} -
- -
- - {/* Top-right: basemap + panel */} -
- + {/* Left / primary pane */} +
+ - {clicked && ( - setClicked(null)} - onSelectFeature={setClicked} - basic={basicPanel} + + {/* Search bar lives on the primary pane only. */} +
+ - )} +
+ + {/* Top-right of the primary pane: compare toggle + basemap + + (when not in compare mode) feature panel. */} +
+
+ +
+ + {!compareMode && clicked && ( + setClicked(null)} + onSelectFeature={setClicked} + basic={basicPanel} + /> + )} +
+ {/* Drag separator + right pane (only in compare mode) */} + {compareMode && ( + <> +
+
+
+
+
+ +
+ { + /* 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). */} +
+ + {clicked && ( + setClicked(null)} + onSelectFeature={setClicked} + basic={basicPanel} + /> + )} +
+
+ + )} + {/* Bottom-right: cutover badge (visible until full rollout) */}
gis.ac · v2 diff --git a/src/modules/geoportal/v2/map-viewer.tsx b/src/modules/geoportal/v2/map-viewer.tsx index 709ec1e..cedc087 100644 --- a/src/modules/geoportal/v2/map-viewer.tsx +++ b/src/modules/geoportal/v2/map-viewer.tsx @@ -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(function MapViewer( @@ -146,12 +164,25 @@ export const MapViewer = forwardRef(function MapViewer( onFeatureClick, selectedFeature, className, + viewState, + onViewChange, + disableFeatureClicks, }, ref, ) { const containerRef = useRef(null); const mapRef = useRef(null); - const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM }); + const viewStateRef = useRef({ + 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(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(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(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(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, () => ({