diff --git a/libraries/appframeworks/portals/src/lib/components/react-cismap/tools/layerFactory.tsx b/libraries/appframeworks/portals/src/lib/components/react-cismap/tools/layerFactory.tsx index 110027da14..3143148abd 100644 --- a/libraries/appframeworks/portals/src/lib/components/react-cismap/tools/layerFactory.tsx +++ b/libraries/appframeworks/portals/src/lib/components/react-cismap/tools/layerFactory.tsx @@ -489,6 +489,9 @@ export const defaultLayerConf = { layers: "GIS-102:trueortho2024", maxNativeZoom: 22, transparent: true, + // geoserver-cloud rejects `styles=default` for this cascaded layer; + // empty string means "server default style" per WMS spec. + styles: "", }, trueOrtho2021: { type: "wms", diff --git a/libraries/mapping/core/src/components/compare/CarmaMapCompare.tsx b/libraries/mapping/core/src/components/compare/CarmaMapCompare.tsx new file mode 100644 index 0000000000..e9cca7af29 --- /dev/null +++ b/libraries/mapping/core/src/components/compare/CarmaMapCompare.tsx @@ -0,0 +1,539 @@ +import { + useEffect, + useRef, + useState, + useCallback, + useMemo, +} from "react"; +import maplibregl from "maplibre-gl"; +import { ComparePanel, CompareMapConfig } from "./ComparePanel"; +import { SwipeOverlay } from "./SwipeOverlay"; +import { SpyglassOverlay } from "./SpyglassOverlay"; +import "./compare.css"; + +/** DIAGNOSTIC: console.log (not console.debug) so it survives Chrome's + * default level filter. Flip to false once sync is confirmed working. */ +const SYNC_LOG = true; +const log = (...args: unknown[]) => { + if (SYNC_LOG) console.log("[COMPARE]", ...args); +}; + +// Attach a stable id to each map instance the first time we see it. +// Lets every log identify which specific instance produced an event. +let _nextCompareIdx = 0; +function tagMap(map: maplibregl.Map): number { + const tagged = map as unknown as { _compareIdx?: number }; + if (tagged._compareIdx == null) { + tagged._compareIdx = _nextCompareIdx++; + } + return tagged._compareIdx; +} +function idOf(map: maplibregl.Map): number | string { + return (map as unknown as { _compareIdx?: number })._compareIdx ?? "?"; +} + +export type CompareMode = + | { + type: "spyglass"; + radius?: number; + overlayMapIndex?: number; + } + | { + type: "side-by-side"; + orientation?: "horizontal" | "vertical"; + /** + * Initial split positions as fractions in (0,1), length = maps.length - 1. + * When omitted, panels are distributed equally. + */ + initialSplit?: number[]; + }; + +export interface CarmaMapCompareProps { + /** Two or more map configurations to compare. */ + maps: CompareMapConfig[]; + /** Comparison mode and its options. */ + mode: CompareMode; + overrideGlyphs?: string; + preserveDrawingBuffer?: boolean; + interactive?: boolean; + debugLog?: boolean; + logErrors?: boolean; + /** Fires once all map instances have been created. */ + onMapsReady?: (maps: Array) => void; + /** Fires when the user wheels over the spyglass ring. Parent should + * update its `mode.radius` on the next render so the Lupe visually + * reflects the new size. Optional; when omitted, wheel-over-Lupe is + * a no-op (and falls through to whatever is beneath). */ + onSpyglassRadiusChange?: (radius: number) => void; +} + +function equalSplit(n: number): number[] { + if (n <= 1) return []; + const out: number[] = []; + for (let i = 1; i < n; i++) out.push(i / n); + return out; +} + +/** + * Compare UI modelled after mapbox-gl-compare. + * + * Every map renders the SAME geographic area into its own full-size + * absolute container, stacked on top of each other inside a + * `position: relative` wrapper. A `clip-path` on each panel hides the + * columns / rows / everything-outside-a-circle that belong to another + * panel. + * + * ── Camera sync ─────────────────────────────────────────────── + * Subscription to each map's `move` event happens INSIDE + * `handlePanelReady`, not in a `useEffect`. A useEffect-based + * subscription races with the LibreMap-init useEffect that destroys + * and recreates the MapLibre instance under ``, so we + * end up with `move` handlers on destroyed v1 maps and nothing on + * the live v2 maps the user is actually dragging. Subscribing the + * moment a map arrives at this callback is timing-agnostic. + * + * The sync itself follows the mapbox-gl-sync-move pattern: turn all + * listeners off, `jumpTo` every target from the source's camera, + * turn all listeners back on. We also filter by `e.originalEvent` + * as defence in depth: programmatic moves (our own `jumpTo`) don't + * carry one, user drags / wheels / keystrokes do. + */ +export function CarmaMapCompare({ + maps, + mode, + overrideGlyphs, + preserveDrawingBuffer, + interactive, + debugLog, + logErrors, + onMapsReady, + onSpyglassRadiusChange, +}: CarmaMapCompareProps) { + // Stable per-index div refs for the stacked panels. + const panelRefsContainer = useRef< + Array<{ current: HTMLDivElement | null }> + >([]); + if (panelRefsContainer.current.length !== maps.length) { + const next: Array<{ current: HTMLDivElement | null }> = []; + for (let i = 0; i < maps.length; i++) { + next.push(panelRefsContainer.current[i] ?? { current: null }); + } + panelRefsContainer.current = next; + } + const panelRefs = panelRefsContainer.current; + + const containerRef = useRef(null); + + // ─── Sync state kept in refs (not React state) ───────────────── + // liveMapsRef: which map instance is currently assigned to which panel index. + // Updated in handlePanelReady. In StrictMode we replace v1 with v2 + // here (and detach v1's listener first). + // syncHandlersRef: the `move` handler each map is subscribed with, so + // we can detach it by identity later. + // Both are refs so the `move` handler closes over the current state of + // the comparison at the moment the event fires, not at the moment the + // subscription happened. + const liveMapsRef = useRef>(new Map()); + const syncHandlersRef = useRef< + Map void> + >(new Map()); + + // React state that mirrors "how many panels have reported in, and which + // instances". Used for the resize-on-mode-change effect and for + // onMapsReady. The sync itself does NOT depend on this state. + const [mapInstances, setMapInstances] = useState([]); + + // Off/jump/on sync. Always reads from liveMapsRef so it sees the + // current generation of maps even if the subscription was attached + // against an older snapshot. + const syncFrom = useCallback((source: maplibregl.Map) => { + // Off ALL. During the jumpTo pass, no target (or source) can fire + // "move" back into the handler. This is the mapbox-gl-sync-move + // pattern and is what makes recursion impossible: jumped targets + // emit "move" events, but their listeners are detached, so nothing + // pings back. + syncHandlersRef.current.forEach((fn, m) => { + try { + m.off("move", fn); + } catch { + /* disposed */ + } + }); + + const center = source.getCenter(); + const zoom = source.getZoom(); + const bearing = source.getBearing(); + const pitch = source.getPitch(); + + let jumped = 0; + liveMapsRef.current.forEach((target) => { + if (target === source) return; + try { + target.jumpTo({ + center: [center.lng, center.lat], + zoom, + bearing, + pitch, + }); + jumped++; + log(" jumped target id", idOf(target)); + } catch (err) { + log(" jumpTo FAILED on target id", idOf(target), err); + } + }); + log( + "syncFrom source id", + idOf(source), + "-> jumped", + jumped, + "of", + liveMapsRef.current.size - 1, + "target(s)" + ); + + // On ALL, using the SAME handler references (keyed by map). + syncHandlersRef.current.forEach((fn, m) => { + try { + m.on("move", fn); + } catch { + /* disposed */ + } + }); + }, []); + + const handlePanelReady = useCallback( + (index: number, map: maplibregl.Map) => { + const tag = tagMap(map); + log("handlePanelReady called for panel index", index, "map id", tag); + + // If the same panel index already has a map (StrictMode remount or + // any future re-mount), detach that old map's handler before we + // lose the reference. + const prev = liveMapsRef.current.get(index); + if (prev && prev !== map) { + const prevFn = syncHandlersRef.current.get(prev); + if (prevFn) { + try { + prev.off("move", prevFn); + } catch { + /* disposed */ + } + syncHandlersRef.current.delete(prev); + } + log( + " replaced old map (id", + idOf(prev), + ") at index", + index, + "with new map id", + tag + ); + } + + liveMapsRef.current.set(index, map); + + // Subscribe this map if it isn't already. The move handler does + // NOT filter by originalEvent — the off/on dance in syncFrom is + // what prevents recursion, and originalEvent is unreliable across + // MapLibre event shapes. + if (!syncHandlersRef.current.has(map)) { + const fn = () => { + log("move fired on map id", idOf(map), "(panel index", index, ")"); + syncFrom(map); + }; + syncHandlersRef.current.set(map, fn); + try { + map.on("move", fn); + log(" subscribed move listener on map id", tag, "(index", index, ")"); + } catch (err) { + log(" subscribe FAILED on map id", tag, err); + } + } + + // React state mirror for the rest of the component (resize effect, + // onMapsReady). Only fires when every panel has contributed. + if (liveMapsRef.current.size === maps.length) { + const allMaps: maplibregl.Map[] = []; + for (let i = 0; i < maps.length; i++) { + const m = liveMapsRef.current.get(i); + if (m) allMaps.push(m); + } + log( + "all", + maps.length, + "panels registered, setMapInstances with ids", + allMaps.map(idOf) + ); + setMapInstances(allMaps); + onMapsReady?.(allMaps); + } + }, + [maps.length, onMapsReady, syncFrom] + ); + + // Real-unmount cleanup. Also covers StrictMode's mount-unmount-mount + // dance: when the strict unmount fires this cleanup, refs are cleared, + // and the strict remount lets the freshly created LibreMap instances + // re-register via handlePanelReady. + // + // We deliberately do NOT have a separate [maps.length] effect that + // wipes refs on mount: child useEffects (LibreMap's map-creation + // effect) run BEFORE parent useEffects, so the parent firing a body + // on mount would detach the listeners that handlePanelReady just + // attached, leaving the visible maps with no `move` subscriber. If + // maps.length grows or shrinks at runtime, handlePanelReady absorbs + // the new panel, and disposed entries left in liveMapsRef are caught + // by the try/catch in syncFrom. + useEffect(() => { + return () => { + syncHandlersRef.current.forEach((fn, m) => { + try { + m.off("move", fn); + } catch { + /* disposed */ + } + }); + syncHandlersRef.current.clear(); + liveMapsRef.current.clear(); + }; + }, []); + + // ─── Side-by-side split positions ────────────────────────────── + const isSideBySide = mode.type === "side-by-side"; + const sbsOrientation = + mode.type === "side-by-side" + ? mode.orientation ?? "horizontal" + : "horizontal"; + + const [splitPositions, setSplitPositions] = useState(() => + equalSplit(maps.length) + ); + + useEffect(() => { + setSplitPositions(equalSplit(maps.length)); + }, [maps.length, mode.type, sbsOrientation]); + + // ─── 2x2 grid mode (side-by-side with exactly 4 maps) ───────── + // The linear splitPositions array isn't expressive enough here; a + // grid needs one fraction for the column split (x) and one for the + // row split (y). When isGrid is false splitPositions wins and + // gridSplit is ignored; when isGrid is true we flip over. + const isGrid = isSideBySide && maps.length === 4; + const [gridSplit, setGridSplit] = useState<{ x: number; y: number }>({ + x: 0.5, + y: 0.5, + }); + + useEffect(() => { + if (isGrid) setGridSplit({ x: 0.5, y: 0.5 }); + }, [isGrid]); + + // ─── Spyglass pointer position ───────────────────────────────── + const [spyglassPos, setSpyglassPos] = useState<{ + x: number; + y: number; + } | null>(null); + + useEffect(() => { + if (mode.type !== "spyglass") setSpyglassPos(null); + }, [mode.type]); + + useEffect(() => { + if (mode.type !== "spyglass") return; + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + setSpyglassPos({ x: rect.width / 2, y: rect.height / 2 }); + }, [mode.type]); + + // ─── Resize maps after mode / layout changes ────────────────── + useEffect(() => { + if (mapInstances.length === 0) return; + const raf = requestAnimationFrame(() => { + mapInstances.forEach((m) => { + try { + m.resize(); + } catch { + /* disposed */ + } + }); + }); + return () => cancelAnimationFrame(raf); + }, [mode.type, mapInstances, maps.length]); + + // ─── Per-panel clip-path ─────────────────────────────────────── + const clipPaths = useMemo(() => { + if (mode.type === "spyglass") { + const overlayIdx = mode.overlayMapIndex ?? 1; + const radius = mode.radius ?? 150; + return maps.map((_, idx) => { + if (idx !== overlayIdx) return "none"; + if (!spyglassPos) return "circle(0px at 0 0)"; + return `circle(${radius}px at ${spyglassPos.x}px ${spyglassPos.y}px)`; + }); + } + if (isGrid) { + // 2x2 grid: M0 top-left, M1 top-right, M2 bottom-left, M3 + // bottom-right. inset(top right bottom left), where each value + // is the distance from the respective edge as a percentage. + const { x, y } = gridSplit; + const xPct = (x * 100).toFixed(4); + const yPct = (y * 100).toFixed(4); + const rxPct = ((1 - x) * 100).toFixed(4); + const ryPct = ((1 - y) * 100).toFixed(4); + return [ + `inset(0 ${rxPct}% ${ryPct}% 0)`, + `inset(0 0 ${ryPct}% ${xPct}%)`, + `inset(${yPct}% ${rxPct}% 0 0)`, + `inset(${yPct}% 0 0 ${xPct}%)`, + ]; + } + return maps.map((_, idx) => { + const leftFrac = idx === 0 ? 0 : splitPositions[idx - 1] ?? 0; + const rightFrac = + idx === maps.length - 1 ? 1 : splitPositions[idx] ?? 1; + const leftPct = (leftFrac * 100).toFixed(4); + const rightPct = ((1 - rightFrac) * 100).toFixed(4); + if (sbsOrientation === "horizontal") { + return `inset(0 ${rightPct}% 0 ${leftPct}%)`; + } + return `inset(${leftPct}% 0 ${rightPct}% 0)`; + }); + }, [mode, maps, splitPositions, sbsOrientation, spyglassPos, isGrid, gridSplit]); + + const spyglassOverlayIdx = + mode.type === "spyglass" ? mode.overlayMapIndex ?? 1 : -1; + + return ( +
+ {maps.map((config, index) => ( +
{ + const entry = panelRefs[index]; + if (entry) entry.current = el; + }} + className="carma-compare-stacked-panel" + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + overflow: "hidden", + clipPath: clipPaths[index], + WebkitClipPath: clipPaths[index], + zIndex: index === spyglassOverlayIdx ? 1 : 0, + }} + > + handlePanelReady(index, map)} + overrideGlyphs={overrideGlyphs} + preserveDrawingBuffer={preserveDrawingBuffer} + interactive={interactive} + debugLog={debugLog} + logErrors={logErrors} + /> +
+ ))} + + {isSideBySide && !isGrid && + maps.map((config, index) => { + if (!config.label) return null; + const leftFrac = index === 0 ? 0 : splitPositions[index - 1] ?? 0; + const style: React.CSSProperties = { + position: "absolute", + zIndex: 3, + pointerEvents: "none", + }; + if (sbsOrientation === "horizontal") { + style.left = `calc(${(leftFrac * 100).toFixed(4)}% + 16px)`; + style.bottom = 16; + } else { + style.top = `calc(${(leftFrac * 100).toFixed(4)}% + 16px)`; + style.left = 16; + } + return ( +
+ {config.label} +
+ ); + })} + + {isGrid && + maps.map((config, index) => { + if (!config.label) return null; + // Top-left corner of each quadrant, offset by 16 px inset. + const leftFrac = index % 2 === 0 ? 0 : gridSplit.x; + const topFrac = index < 2 ? 0 : gridSplit.y; + const style: React.CSSProperties = { + position: "absolute", + zIndex: 3, + pointerEvents: "none", + left: `calc(${(leftFrac * 100).toFixed(4)}% + 16px)`, + top: `calc(${(topFrac * 100).toFixed(4)}% + 16px)`, + }; + return ( +
+ {config.label} +
+ ); + })} + + {isSideBySide && !isGrid && maps.length > 1 && ( + + )} + + {isGrid && ( + <> + {/* Vertical line splitting the two columns. */} + + setGridSplit((prev) => ({ ...prev, x })) + } + /> + {/* Horizontal line splitting the two rows. */} + + setGridSplit((prev) => ({ ...prev, y })) + } + /> + + )} + + {mode.type === "spyglass" && ( + + )} +
+ ); +} diff --git a/libraries/mapping/core/src/components/compare/ComparePanel.tsx b/libraries/mapping/core/src/components/compare/ComparePanel.tsx new file mode 100644 index 0000000000..859feb1d81 --- /dev/null +++ b/libraries/mapping/core/src/components/compare/ComparePanel.tsx @@ -0,0 +1,104 @@ +import { useCallback } from "react"; +import maplibregl from "maplibre-gl"; +import { + SelectionProvider, +} from "@carma-appframeworks/portals"; +import { + LibreContextProvider, + type LibreLayer, +} from "@carma-mapping/engines/maplibre"; +import { CarmaMap } from "../CarmaMap"; + +export interface CompareMapConfig { + engine: "maplibre" | "cesium"; + label?: string; + backgroundLayers?: string | null; + layers?: LibreLayer[]; + layerMode?: "merged" | "imperative"; + // Reserved for Phase 4 (Cesium) + tilesetUrl?: string; + cesiumPosition?: { longitude: number; latitude: number; altitude?: number }; + cesiumRange?: number; +} + +interface ComparePanelProps { + config: CompareMapConfig; + onMapReady?: (map: maplibregl.Map) => void; + overrideGlyphs?: string; + preserveDrawingBuffer?: boolean; + interactive?: boolean; + debugLog?: boolean; + logErrors?: boolean; +} + +/** + * Compare panel for a single map. Follows the same provider pattern as + * every other playground route (`SelectionProvider` > `LibreContextProvider` + * > `CarmaMap`), so each panel has its own isolated map ref and selection + * state while still sharing the outer `TopicMapContextProvider` from main.tsx. + * + * `CarmaMap` already wraps its subtree in `HashStateProvider` and + * `MapFrameworkSwitcherProvider`, so no extra plumbing is needed here. + */ +export function ComparePanel({ + config, + onMapReady, + overrideGlyphs, + preserveDrawingBuffer, + interactive, + debugLog, + logErrors, +}: ComparePanelProps) { + const handleMapReady = useCallback( + (map: maplibregl.Map) => { + // Trace that LibreMap → CarmaMap → ComparePanel chain landed here. + // If you see [COMPARE-PANEL] but NOT the [COMPARE] handlePanelReady + // line that follows it, the break is between ComparePanel and + // CarmaMapCompare (stale / undefined onMapReady prop). + console.log( + "[COMPARE-PANEL] handleMapReady received map; onMapReady defined?", + typeof onMapReady === "function" + ); + onMapReady?.(map); + }, + [onMapReady] + ); + + if (config.engine === "maplibre") { + // The label lives outside this component now: CarmaMapCompare renders + // it as a sibling overlay pinned to the visible strip's corner. A + // label rendered here would be clipped away with the panel in the + // parent's clip-path. + return ( +
+ + + + + +
+ ); + } + + // Phase 4: Cesium support goes here. + return
; +} diff --git a/libraries/mapping/core/src/components/compare/SpyglassOverlay.tsx b/libraries/mapping/core/src/components/compare/SpyglassOverlay.tsx new file mode 100644 index 0000000000..cff1397be3 --- /dev/null +++ b/libraries/mapping/core/src/components/compare/SpyglassOverlay.tsx @@ -0,0 +1,173 @@ +import { useRef } from "react"; + +interface SpyglassOverlayProps { + /** Pixel coords of the circle center, relative to the compare container. + * When null (e.g. before the first seed) the ring is not drawn. */ + position: { x: number; y: number } | null; + /** Circle radius in pixels. Default: 150. */ + radius?: number; + /** Called while the user drags the ring. The parent owns the position + * state so the clip-path on the overlay panel stays in sync. */ + onPositionChange: (position: { x: number; y: number }) => void; + /** Optional: when provided, wheeling over the Lupe grows / shrinks + * the radius instead of falling through to the map's wheel zoom. */ + onRadiusChange?: (radius: number) => void; +} + +// Clamp range for wheel-driven radius changes. Matches the slider range +// used in the compare playground so keyboard / wheel / slider all agree +// on the min / max the user can reach. +const MIN_RADIUS = 60; +const MAX_RADIUS = 400; +// Pixels of radius per unit of deltaY. Mouse wheels deliver ~100 per +// notch, trackpads much smaller, so 0.3 feels snappy on mice and is +// still controllable on trackpads. +const WHEEL_STEP = 0.3; + +/** + * Draggable spyglass ring. + * + * The ring is an SVG with two concentric circles: + * 1. A thin (2 px) visible stroke that paints the Lupe outline. + * 2. A wide (16 px) fully-transparent hit circle on top, with + * `pointer-events: all` so BOTH the interior and the edge band + * act as a drag handle. + * + * The SVG itself is `pointer-events: none`. The hit circle opts back + * in via `pointer-events: all`. That means: + * - clicks / drags anywhere on or inside the ring: grab and drag + * the Lupe (i.e. the Lupe moves, the maps stay still), + * - clicks / drags outside the ring: pass through to the base map. + * + * Trade-off: because the interior of the ring now consumes pointer + * events, panning or click-selecting the overlay map *through* the + * Lupe is no longer possible. To restore click-through while keeping + * drag-to-move, we'd need a movement-threshold guard in onPointerMove + * and only `setPointerCapture` once the threshold is exceeded. + * + * We deliberately do NOT track the mouse pointer. An earlier version + * installed a follow-mouse listener, but that made panning the map + * impossible (the Lupe kept racing ahead of the cursor). Drag-based + * positioning is the standard cartographic Lupe UX. + */ +export function SpyglassOverlay({ + position, + radius = 150, + onPositionChange, + onRadiusChange, +}: SpyglassOverlayProps) { + // Delta-based drag: record pointer clientX / Y and the Lupe position + // at pointerDown, then translate by the raw delta on every move. We + // don't need the container's bounding rect; clientX is in viewport + // coords on both sides of the subtraction and cancels out. + const dragStart = useRef<{ + clientX: number; + clientY: number; + spyX: number; + spyY: number; + } | null>(null); + + if (!position) return null; + + const onPointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + dragStart.current = { + clientX: e.clientX, + clientY: e.clientY, + spyX: position.x, + spyY: position.y, + }; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragStart.current) return; + const dx = e.clientX - dragStart.current.clientX; + const dy = e.clientY - dragStart.current.clientY; + onPositionChange({ + x: dragStart.current.spyX + dx, + y: dragStart.current.spyY + dy, + }); + }; + + const onPointerUp = (e: React.PointerEvent) => { + dragStart.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }; + + // Wheeling over the Lupe scales its radius. Target is the hit circle + // (pointer-events: all), so maplibre's canvas below never sees the + // event; stopPropagation is belt-and-suspenders for parent listeners. + // Upward wheel (deltaY < 0) grows the Lupe, downward shrinks it. + const onWheel = (e: React.WheelEvent) => { + if (!onRadiusChange) return; + e.preventDefault(); + e.stopPropagation(); + const next = Math.max( + MIN_RADIUS, + Math.min(MAX_RADIUS, radius - e.deltaY * WHEEL_STEP) + ); + if (next !== radius) onRadiusChange(next); + }; + + // Give the SVG a bit of extra room around the radius so the 16 px + // hit-stroke isn't clipped by the SVG's own viewport. + const PAD = 12; + const svgSize = (radius + PAD) * 2; + const center = svgSize / 2; + + return ( + + {/* Visible ring. */} + + {/* Wide transparent hit circle. pointerEvents: all makes BOTH the + * interior (fill) and the edge band (16 px stroke) into drag + * handles, so the user can grab the Lupe from anywhere on or + * inside the ring. */} + + + ); +} diff --git a/libraries/mapping/core/src/components/compare/SwipeOverlay.tsx b/libraries/mapping/core/src/components/compare/SwipeOverlay.tsx new file mode 100644 index 0000000000..704d449aba --- /dev/null +++ b/libraries/mapping/core/src/components/compare/SwipeOverlay.tsx @@ -0,0 +1,163 @@ +import { useRef, useCallback } from "react"; + +/** + * Controlled splitter overlay. For each split between adjacent panels, + * renders a thin visible white divider line plus an invisible wider + * hit strip sitting on top of it, so the user can grab the line + * directly, no visible knob required. The cursor changes to + * col-resize / row-resize on hover over the hit strip so the + * interaction is discoverable. + * + * `orientation` here refers to the LAYOUT of the panels, matching + * `side-by-side.orientation`: + * - "horizontal": panels are in a row, the split is a vertical line + * that slides left/right + * - "vertical": panels are stacked, the split is a horizontal line + * that slides up/down + */ +interface SwipeOverlayProps { + orientation?: "horizontal" | "vertical"; + positions: number[]; + onPositionsChange: (positions: number[]) => void; +} + +const LINE_THICKNESS = 2; +const LINE_COLOR = "rgba(255, 255, 255, 0.9)"; +const LINE_SHADOW = "0 0 0 1px rgba(0,0,0,0.25)"; +// Invisible hit target centered on the visible line; wide enough that +// grabbing the 2 px line is comfortable on both mouse and touch. +const HIT_THICKNESS = 16; + +export function SwipeOverlay({ + orientation = "horizontal", + positions, + onPositionsChange, +}: SwipeOverlayProps) { + const containerRef = useRef(null); + const draggingIndex = useRef(null); + + // "horizontal" layout = grabber moves along X (the split is a vertical line). + const isRowLayout = orientation === "horizontal"; + + const handlePointerDown = useCallback( + (index: number) => (e: React.PointerEvent) => { + draggingIndex.current = index; + const handle = e.currentTarget as HTMLElement; + handle.setPointerCapture(e.pointerId); + e.preventDefault(); + }, + [] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (draggingIndex.current === null || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const pos = isRowLayout + ? (e.clientX - rect.left) / rect.width + : (e.clientY - rect.top) / rect.height; + + const clampedPos = Math.max(0.05, Math.min(0.95, pos)); + + const next = [...positions]; + next[draggingIndex.current] = clampedPos; + + // Keep positions monotonically increasing so splits never cross. + for (let i = 1; i < next.length; i++) { + next[i] = Math.max(next[i], next[i - 1] + 0.02); + } + for (let i = next.length - 2; i >= 0; i--) { + next[i] = Math.min(next[i], next[i + 1] - 0.02); + } + + onPositionsChange(next); + }, + [isRowLayout, positions, onPositionsChange] + ); + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + draggingIndex.current = null; + const handle = e.currentTarget as HTMLElement; + if (handle.hasPointerCapture(e.pointerId)) { + handle.releasePointerCapture(e.pointerId); + } + }, []); + + return ( +
+ {positions.map((pos, index) => ( +
+ {/* Thin white divider line so the split is visible everywhere, + * not just where the grabber sits. */} +
+ {/* Invisible wide hit strip centered on the line. This is + * the actual drag target; the visible line above is purely + * cosmetic. Cursor changes to col-/row-resize on hover so + * the interaction is discoverable. */} +
+
+ ))} +
+ ); +} diff --git a/libraries/mapping/core/src/components/compare/compare.css b/libraries/mapping/core/src/components/compare/compare.css new file mode 100644 index 0000000000..15c574397a --- /dev/null +++ b/libraries/mapping/core/src/components/compare/compare.css @@ -0,0 +1,42 @@ +/* Scoped overrides for LibreMap's default CSS inside CarmaMapCompare. + * LibreMap's .map-wrap defaults to height: 100vh which breaks compare + * layouts (each panel would try to be a full viewport tall). Inside a + * compare container every panel fills its own wrapper instead. */ + +.carma-compare, +.carma-compare * { + box-sizing: border-box; +} + +.carma-compare .map-wrap { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.carma-compare .map { + position: absolute; + width: 100%; + height: 100%; +} + +.carma-compare-panel { + position: absolute; + inset: 0; + overflow: hidden; +} + +/* Per-panel label. CarmaMapCompare sets position / left / top / bottom + * inline so the label sits inside its own visible strip; this rule only + * owns look-and-feel. */ +.carma-compare-panel-label { + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + pointer-events: none; + font-family: system-ui, -apple-system, sans-serif; +} diff --git a/libraries/mapping/core/src/hooks/useMapLibreSync.ts b/libraries/mapping/core/src/hooks/useMapLibreSync.ts new file mode 100644 index 0000000000..0b425bd1a2 --- /dev/null +++ b/libraries/mapping/core/src/hooks/useMapLibreSync.ts @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import maplibregl from "maplibre-gl"; + +/** DIAGNOSTIC: leave true while you're debugging sync; flip back to false + * once the bug is understood. */ +const LOG = true; +const log = (...args: unknown[]) => { + if (LOG) console.debug("[MAPLIBRE-SYNC]", ...args); +}; + +/** + * Mirror multiple MapLibre cameras. + * + * Every map's center / zoom / bearing / pitch is copied from whichever + * map the USER interacted with. Used by CarmaMapCompare so every panel + * shows the same geographic area; the stacked-with-clip layout then + * decides which pixels from which panel actually reach the screen. + * + * Implementation: an `e.originalEvent` filter on the `move` handler. + * MapLibre attaches `originalEvent` (the underlying pointer / wheel / + * keyboard / touch DOM event) to `move` events that come from user + * interaction, and leaves it undefined on `move` events emitted from + * programmatic camera moves (our own `jumpTo`). So every listener can + * simply skip events without `originalEvent`. No off / jump / on dance, + * no reentrance guard. This sidesteps StrictMode's double-invoke + * cleanup ordering problems entirely. + */ +export function useMapLibreSync(maps: maplibregl.Map[]) { + useEffect(() => { + log("effect", { count: maps?.length }); + if (!maps || maps.length < 2) return; + + // map -> its move handler, so we can detach the right one in cleanup. + const handlers = new Map void>(); + + const syncOthers = (source: maplibregl.Map, sourceIdx: number) => { + const center = source.getCenter(); + const zoom = source.getZoom(); + const bearing = source.getBearing(); + const pitch = source.getPitch(); + log("user move from", sourceIdx, "->", maps.length - 1, "targets"); + maps.forEach((target, targetIdx) => { + if (target === source) return; + try { + target.jumpTo({ + center: [center.lng, center.lat], + zoom, + bearing, + pitch, + }); + log(" jumped target", targetIdx); + } catch (err) { + log(" jumpTo failed on", targetIdx, err); + } + }); + }; + + maps.forEach((map, idx) => { + // The `any`-ish parameter is intentional: MapLibre's union of move + // event shapes depends on the trigger, and we only care about the + // presence of originalEvent. + const fn = (e: unknown) => { + const hasOriginal = + typeof e === "object" && + e !== null && + "originalEvent" in e && + (e as { originalEvent: unknown }).originalEvent != null; + if (!hasOriginal) return; // programmatic move (our own jumpTo), ignore + syncOthers(map, idx); + }; + handlers.set(map, fn); + try { + map.on("move", fn); + log("attached listener to map", idx); + } catch (err) { + log("attach failed on", idx, err); + } + }); + + return () => { + maps.forEach((map, idx) => { + const fn = handlers.get(map); + if (!fn) return; + try { + map.off("move", fn); + log("detached listener from map", idx); + } catch { + /* map may already be disposed */ + } + }); + }; + }, [maps]); +} diff --git a/libraries/mapping/core/src/index.ts b/libraries/mapping/core/src/index.ts index c5f1d7d7d8..1a6e76ba55 100644 --- a/libraries/mapping/core/src/index.ts +++ b/libraries/mapping/core/src/index.ts @@ -9,6 +9,9 @@ export { FeatureDataView } from "./components/FeatureDataView"; export type { FeatureDataViewProps } from "./components/FeatureDataView"; export { DatasheetLayout } from "./components/DatasheetLayout"; export type { DatasheetLayoutProps } from "./components/DatasheetLayout"; +export { CarmaMapCompare } from "./components/compare/CarmaMapCompare"; +export type { CarmaMapCompareProps, CompareMode } from "./components/compare/CarmaMapCompare"; +export type { CompareMapConfig } from "./components/compare/ComparePanel"; // Re-export types from maplibre engine for convenience export type { VectorStyle, LibreLayer } from "@carma-mapping/engines/maplibre"; diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 94a2e5247c..47d15c3426 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -416,9 +416,17 @@ export const LibreMap = ({ layers, version = "1.1.1", format = "image/png", + // Some WMS servers (e.g. geoserver-cloud with cascaded layers) reject + // `styles=default` because they try to forward "default" as a remote + // style name and the upstream layer has no such style. Per the WMS + // spec an empty `STYLES=` means "use the server's default style". + // We keep "default" as the fallback so every existing caller's URL + // shape stays identical; set `styles: ""` on a layer's config to + // opt into the spec-compliant empty form. + styles = "default", } = layerConfig; const baseUrl = url.endsWith("?") ? url : url + "?"; - return `${baseUrl}SERVICE=WMS&REQUEST=GetMap&VERSION=${version}&LAYERS=${layers}&FORMAT=${format}&styles=default&TRANSPARENT=true&WIDTH=256&HEIGHT=256&crs=EPSG:3857&&srs=EPSG:3857&BBOX={bbox-epsg-3857}`; + return `${baseUrl}SERVICE=WMS&REQUEST=GetMap&VERSION=${version}&LAYERS=${layers}&FORMAT=${format}&styles=${styles}&TRANSPARENT=true&WIDTH=256&HEIGHT=256&crs=EPSG:3857&&srs=EPSG:3857&BBOX={bbox-epsg-3857}`; }; // Helper function to build WMTS tile URL from layer config diff --git a/playgrounds/ng-topicmap-playground/src/app/ComparePlayground.tsx b/playgrounds/ng-topicmap-playground/src/app/ComparePlayground.tsx new file mode 100644 index 0000000000..a24f61e974 --- /dev/null +++ b/playgrounds/ng-topicmap-playground/src/app/ComparePlayground.tsx @@ -0,0 +1,386 @@ +import { useEffect, useMemo, useState } from "react"; +import { Segmented, Select, Slider } from "antd"; +import { + CarmaMapCompare, + type CompareMapConfig, + type CompareMode, +} from "@carma-mapping/core"; +import "bootstrap/dist/css/bootstrap.min.css"; +import "react-bootstrap-typeahead/css/Typeahead.css"; +import "react-cismap/topicMaps.css"; +import "leaflet/dist/leaflet.css"; + +// ───────────────────────────────────────────────────────────── +// Available background layer presets. +// Keys follow the same "namedLayer@opacity" syntax the rest of the +// monorepo uses (resolved by LibreMap via defaultLayerConf.namedLayers). +// ───────────────────────────────────────────────────────────── + +const LAYER_PRESETS: { label: string; value: string }[] = [ + { label: "Stadtplan (Wuppertal)", value: "wupp-plan-live@100" }, + { + label: "Luftbildkarte 2024", + value: "rvrGrundriss@100|trueOrtho2024Alternative@75|rvrSchriftNT@100", + }, + { label: "True Ortho 2024", value: "trueOrtho2024Alternative@75" }, + { label: "basemap.de Farbe", value: "our_basemap_color@100" }, + { label: "basemap.de Grau", value: "our_basemap_grey@100" }, + { label: "basemap.de Relief", value: "our_basemap_relief@100" }, +]; + +const DEFAULT_LEFT = LAYER_PRESETS[0].value; +const DEFAULT_MIDDLE = LAYER_PRESETS[4].value; +const DEFAULT_RIGHT = LAYER_PRESETS[1].value; + +// ───────────────────────────────────────────────────────────── +// LocalStorage persistence (scoped to playground to avoid leakage). +// ───────────────────────────────────────────────────────────── + +const LS_PREFIX = "ng-topicmap-playground:compare:"; +const LS_MODE = `${LS_PREFIX}mode`; +const LS_NUM_PANELS = `${LS_PREFIX}numPanels`; +const LS_LAYERS = `${LS_PREFIX}layers`; +const LS_SPY_RADIUS = `${LS_PREFIX}spyRadius`; + +type ModeKey = "spyglass" | "sbs-h" | "sbs-v"; + +const MODE_OPTIONS: { label: string; value: ModeKey }[] = [ + { label: "Side-by-Side ⇔", value: "sbs-h" }, + { label: "Side-by-Side ⇕", value: "sbs-v" }, + { label: "Lupe 🔍", value: "spyglass" }, +]; + +function buildMode(key: ModeKey, spyRadius: number): CompareMode { + switch (key) { + case "spyglass": + return { + type: "spyglass", + radius: spyRadius, + overlayMapIndex: 1, + }; + case "sbs-h": + return { type: "side-by-side", orientation: "horizontal" }; + case "sbs-v": + return { type: "side-by-side", orientation: "vertical" }; + } +} + +// ───────────────────────────────────────────────────────────── +// LocalStorage helpers +// ───────────────────────────────────────────────────────────── + +function loadMode(): ModeKey { + try { + const stored = localStorage.getItem(LS_MODE); + if (stored && MODE_OPTIONS.some((o) => o.value === stored)) { + return stored as ModeKey; + } + } catch { + /* ignore */ + } + return "sbs-h"; +} + +function loadNumPanels(): 2 | 3 | 4 { + try { + const n = parseInt(localStorage.getItem(LS_NUM_PANELS) ?? "2", 10); + return n === 3 ? 3 : n === 4 ? 4 : 2; + } catch { + return 2; + } +} + +function loadLayers(count: number): string[] { + try { + const stored = localStorage.getItem(LS_LAYERS); + if (stored) { + const parsed = JSON.parse(stored) as string[]; + if (Array.isArray(parsed)) { + const result: string[] = []; + for (let i = 0; i < count; i++) { + result.push( + parsed[i] ?? + (i === 0 + ? DEFAULT_LEFT + : i === 1 + ? DEFAULT_RIGHT + : DEFAULT_MIDDLE) + ); + } + return result; + } + } + } catch { + /* ignore */ + } + if (count === 4) { + return [DEFAULT_LEFT, DEFAULT_RIGHT, DEFAULT_MIDDLE, DEFAULT_MIDDLE]; + } + if (count === 3) { + return [DEFAULT_LEFT, DEFAULT_MIDDLE, DEFAULT_RIGHT]; + } + return [DEFAULT_LEFT, DEFAULT_RIGHT]; +} + +function loadSpyRadius(): number { + try { + const n = parseInt(localStorage.getItem(LS_SPY_RADIUS) ?? "180", 10); + return Number.isFinite(n) ? Math.max(60, Math.min(400, n)) : 180; + } catch { + return 180; + } +} + +// ───────────────────────────────────────────────────────────── +// Control bar styling (pill buttons, similar to TreesPlayground) +// ───────────────────────────────────────────────────────────── + +const PILL_SHADOW = + "0 1px 2px rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15)"; + +function PanelLayerPicker({ + index, + value, + onChange, + accent, +}: { + index: number; + value: string; + onChange: (v: string) => void; + accent: string; +}) { + return ( +
+ + + Panel {index + 1} + +