diff --git a/apps/belis/desktop/src/components/ui/FilePreview.tsx b/apps/belis/desktop/src/components/ui/FilePreview.tsx index 5c1f8acf91..4b86f77c7c 100644 --- a/apps/belis/desktop/src/components/ui/FilePreview.tsx +++ b/apps/belis/desktop/src/components/ui/FilePreview.tsx @@ -15,9 +15,7 @@ import { CloseCircleFilled, PlusCircleFilled, } from "@ant-design/icons"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import { getDocumentBlobUrl, getSecureDocumentUrl, diff --git a/apps/belis/desktop/src/main.tsx b/apps/belis/desktop/src/main.tsx index 5aa0ff73b3..615488be6f 100644 --- a/apps/belis/desktop/src/main.tsx +++ b/apps/belis/desktop/src/main.tsx @@ -21,6 +21,7 @@ import store from "./store"; import persistStore from "redux-persist/es/persistStore"; import { PersistGate } from "redux-persist/integration/react"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import MainPage from "./components/pages/MainPage"; import KeyTablesPage from "./components/pages/KeyTablesPage"; import ArbeitsauftraegeePage from "./components/pages/ArbeitsauftraegeePage"; @@ -160,7 +161,9 @@ root.render( - + + + diff --git a/apps/belis/online/src/App.jsx b/apps/belis/online/src/App.jsx index bb46f7ab74..428e808bc6 100644 --- a/apps/belis/online/src/App.jsx +++ b/apps/belis/online/src/App.jsx @@ -2,6 +2,7 @@ import { ConfigProvider } from "antd"; import deDE from "antd/lib/locale/de_DE"; import React from "react"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { LightBoxContextProvider } from "@carma-mapping/lightbox"; import { defaultLayerConf } from "react-cismap/tools/layerFactory"; import { ErrorBoundary } from "react-error-boundary"; import { Provider } from "react-redux"; @@ -190,7 +191,9 @@ function App() { offlinelayers: ["vectorLayerOfflineEnabled"], }} > - + + + diff --git a/apps/belis/online/src/components/commons/InfoBox.jsx b/apps/belis/online/src/components/commons/InfoBox.jsx index 581a02a4db..65eb31ec6a 100644 --- a/apps/belis/online/src/components/commons/InfoBox.jsx +++ b/apps/belis/online/src/components/commons/InfoBox.jsx @@ -5,7 +5,7 @@ import React, { useContext, useEffect } from "react"; import Button from "react-bootstrap/Button"; import Icon from "react-cismap/commons/Icon"; import IconLink from "react-cismap/commons/IconLink"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import { UIContext, UIDispatchContext, diff --git a/apps/belis/online/src/components/commons/secondaryinfo/SecondaryInfo.jsx b/apps/belis/online/src/components/commons/secondaryinfo/SecondaryInfo.jsx index 74bcb5cc86..a12abf770f 100644 --- a/apps/belis/online/src/components/commons/secondaryinfo/SecondaryInfo.jsx +++ b/apps/belis/online/src/components/commons/secondaryinfo/SecondaryInfo.jsx @@ -1,5 +1,5 @@ import React, { useContext, useState } from "react"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import { version as reactCismapVersion } from "react-cismap/meta"; import { useDispatch, useSelector } from "react-redux"; diff --git a/apps/belis/online/src/containers/MobileApp.jsx b/apps/belis/online/src/containers/MobileApp.jsx index 85ab03cd0e..611b0a26d4 100644 --- a/apps/belis/online/src/containers/MobileApp.jsx +++ b/apps/belis/online/src/containers/MobileApp.jsx @@ -3,7 +3,7 @@ import useComponentSize from "@rehooks/component-size"; import useOnlineStatus from "@rehooks/online-status"; import React, { useContext, useEffect, useRef, useState } from "react"; import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; -import PhotoLightBox from "react-cismap/topicmaps/PhotoLightbox"; +import { PhotoLightBox } from "@carma-mapping/lightbox"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate, useLocation } from "react-router-dom"; diff --git a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx index e207c470da..ad0b9d2cc7 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/GeoportalMap.tsx @@ -1129,6 +1129,7 @@ const GeoportalMapInner = ({ height, width, allow3d }: MapProps) => { locatorControl={false} fullScreenControl={false} zoomControls={false} + photoLightBox={false} mapStyle={{ width, height, diff --git a/apps/geoportal/src/app/components/GeoportalMap/hooks/useCreateCismapLayer.ts b/apps/geoportal/src/app/components/GeoportalMap/hooks/useCreateCismapLayer.ts index f4720c3d13..b28ee2d3b1 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/hooks/useCreateCismapLayer.ts +++ b/apps/geoportal/src/app/components/GeoportalMap/hooks/useCreateCismapLayer.ts @@ -584,6 +584,18 @@ export const useCreateCismapLayers = ( }); }, onSelectionChanged: (e) => { + // [PANORAMA] debug: the RAW native maplibre features for this + // layer, before carma maps them into the redux selectedFeature. + // Stashed on window for interactive inspection. + console.log("[PANORAMA] native maplibre features", { + layerId: layer.id, + hits: e.hits, + hit: e.hit, + properties: (e.hits ?? []).map((h) => h.properties), + }); + ( + window as typeof window & { __panoramaHits?: unknown } + ).__panoramaHits = e.hits; const currentLayer = layersRef.current.find((l) => l.id === layer.id) || layer; const clickKey = e.latlng diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 82085e9a16..e97ee69bde 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -1,9 +1,24 @@ -import { useContext, useEffect, useState, useRef, type ReactNode } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useState, + useRef, + type ReactNode, +} from "react"; import { useDispatch, useSelector } from "react-redux"; -import InfoBoxFotoPreview from "react-cismap/topicmaps/InfoBoxFotoPreview"; +import { + InfoBoxFotoPreview, + LightBoxContext, + LightBoxDispatchContext, + defaultLightBoxCaptionFactory, + type LightBoxCustomSlide, + type LightBoxImageSlide, + type LightBoxSlide, +} from "@carma-mapping/lightbox"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; import { additionalInfoFactory } from "@carma-collab/wuppertal/geoportal"; import { genericSecondaryInfoFooterFactory } from "@carma-collab/wuppertal/commons"; @@ -22,7 +37,7 @@ import { moveFeatureToFront, setPreferredVectorLayerId, } from "../../store/slices/features"; -import { getLayers } from "../../store/slices/mapping"; +import { getLayers, getMaplibreMaps } from "../../store/slices/mapping"; import { truncateString } from "./featureInfoHelper"; import "../infoBox.css"; @@ -39,13 +54,128 @@ import { InfoBoxHeader, utils, getActionLinksForFeature, + PanoramaViewer, + PanoramaPreview, + type PanoramaHotspot, } from "@carma-appframeworks/portals"; +import { createFeature } from "../GeoportalMap/libremap.utils"; import { parseColor } from "../../helper/color"; import { useFeatureFlags } from "@carma-providers/feature-flag"; import { addCustomFeatureFlags } from "../../store/slices/layers"; import type { FeatureInfo } from "@carma-mapping/utils"; import { selectionPadding } from "../../constants/selection"; +// Panorama: live-rotate the selected arrow on the map to follow the viewing +// direction inside the 360° viewer. The style layer that renders the selected +// arrow (see oelberg_panorama style.json). Only the selected feature is visible +// in that layer, so overriding the layer-wide icon-rotate to a constant rotates +// just the visible arrow; we restore the data-driven value on deselect. +const PANORAMA_SELECTION_ARROW_LAYER = "selection-arrow"; +// Calibration: map icon-rotate is clockwise-from-north (deg); pannellum yaw is +// degrees from the image centre (positive to the right). Tune sign/offset once +// against the live behaviour. +const PANORAMA_YAW_SIGN = 1; +const PANORAMA_YAW_OFFSET = 0; +// The panorama image's yaw=0 (centre) faces the BACK of the survey car, so the +// viewer must START rotated 180° to look forward (down the street / toward the +// next pano). This lives on the IMAGE (pannellum initial yaw), NOT on the arrow: +// the arrow already follows getYaw(), so with the image rotated the arrow tracks +// correctly and stays in sync with what is on screen. +const PANORAMA_INITIAL_YAW = 180; + +// Street-View navigation: pick the surrounding panos and render a clickable +// ground cross at each. The radius is generous so the line of panos down the +// street shows several steps out, receding toward the horizon. +const PANORAMA_NEIGHBOR_RADIUS_M = 30; +const PANORAMA_MAX_HOTSPOTS = 8; +// Drop a candidate that lands within this on-screen angular distance (combined +// yaw+pitch, deg) of an already-kept one. Crosses straight down the street +// share a yaw but differ in pitch, so thinning in (yaw, pitch) space keeps the +// receding line of targets while still removing true overlaps. +const PANORAMA_MIN_HOTSPOT_SCREEN_SEP_DEG = 7; +// Assumed camera height above ground; together with the horizontal distance and +// the proj_z difference it sets how far below the horizon a hotspot sits. +const PANORAMA_CAMERA_HEIGHT_M = 2.2; +const PANORAMA_HOTSPOT_MIN_PITCH = -45; +const PANORAMA_HOTSPOT_MAX_PITCH = 5; +// Arrow-key navigation: ↑ jumps to the neighbour nearest the current view, ↓ to +// the one behind it. Only consider neighbours within this angle of the target +// direction (a hemisphere), so ↑ never jumps to a pano clearly behind you. +const PANORAMA_NAV_HEMISPHERE_DEG = 90; + +// Wrap an angle (deg) into (-180, 180]. +const normalizeDeg = (deg: number): number => { + let x = deg % 360; + if (x > 180) x -= 360; + if (x < -180) x += 360; + return x; +}; +const angularDiffDeg = (a: number, b: number): number => + Math.abs(normalizeDeg(a - b)); + +// A pano's file_name is fully derivable from its integer fid: the names are +// always `pano_NNNNNN_NNNNNN` (6+6 digits) and fid is those 12 digits parsed as +// an int. So the baked neighbour list can carry only the fid (an int) and we +// reconstruct the file name here for the image URL / preload. +const fidToFileName = (fid: number): string => { + const s = String(fid).padStart(12, "0"); + return `pano_${s.slice(0, 6)}_${s.slice(6)}`; +}; + +// A single baked neighbour: [fid, bearing(deg, 0=N CW), distance(m), dz(m), +// heading(deg)]. heading is the neighbour's own street orientation; optional on +// older tiles, so callers treat it as maybe-undefined. +type BakedNeighbour = [number, number, number, number, number?]; + +// Parse the per-feature `nb` property baked by the tiling pipeline (a compact +// JSON string of [fid, bearing, dist, dz] tuples). Returns null when absent or +// malformed so the caller can fall back to a live querySourceFeatures search. +const parseBakedNeighbours = (nb: unknown): BakedNeighbour[] | null => { + if (typeof nb !== "string" || nb.length === 0) return null; + try { + const parsed = JSON.parse(nb); + return Array.isArray(parsed) ? (parsed as BakedNeighbour[]) : null; + } catch { + return null; + } +}; + +// The pano source of a panorama style (the oelberg_panorama style has exactly +// one). Supports both a vector-tile source (needs a source-layer) and a +// client-side geojson source (no source-layer). querySourceFeatures / +// feature-state accept an undefined sourceLayer for geojson sources. +const resolvePanoSource = ( + map: { getStyle?: () => unknown } | undefined +): { sourceId: string; sourceLayer?: string } | null => { + try { + const style = map?.getStyle?.() as + | { + sources?: Record; + layers?: Array<{ source?: string; "source-layer"?: string }>; + } + | undefined; + const sources = style?.sources ?? {}; + // Prefer a vector source with its source-layer. + const vectorId = Object.keys(sources).find( + (id) => sources[id]?.type === "vector" + ); + if (vectorId) { + const sourceLayer = style?.layers?.find( + (l) => l.source === vectorId && l["source-layer"] + )?.["source-layer"]; + if (sourceLayer) return { sourceId: vectorId, sourceLayer }; + } + // Fall back to a geojson source (no source-layer). + const geojsonId = Object.keys(sources).find( + (id) => sources[id]?.type === "geojson" + ); + if (geojsonId) return { sourceId: geojsonId }; + return null; + } catch { + return null; + } +}; + interface InfoBoxProps { pos?: [number, number]; onZoomToFeature?: (feature: FeatureInfo) => void; @@ -77,7 +207,9 @@ const FeatureInfoBox = ({ const layers = useSelector(getLayers); const numOfLayers = layers.length; const infoText = useSelector(getInfoText); + const maplibreMaps = useSelector(getMaplibreMaps); const lightBoxDispatchContext = useContext(LightBoxDispatchContext); + const lightBoxState = useContext(LightBoxContext); const { routedMapRef } = useContext(TopicMapContext); @@ -244,6 +376,546 @@ const FeatureInfoBox = ({ updateHeaderAndColor(); }, [selectedFeature]); + useEffect(() => { + console.log("[PANORAMA] selected feature", { + selectedFeature, + panorama: selectedFeature?.properties?.panorama, + sourceProps: selectedFeature?.properties?.sourceProps, + }); + }, [selectedFeature]); + + // The maplibre map for the currently selected layer (holds the panorama + // arrow layers); selectedFeature.id is the layer id. + const selectedLayerMap = useMemo( + () => maplibreMaps?.find((entry) => entry.id === selectedFeature?.id)?.map, + [maplibreMaps, selectedFeature?.id] + ); + + // Latest panorama view yaw (deg), captured from the viewer yaw poll, so the + // arrow-key navigation can pick the neighbour nearest the current view. + const panoramaYawRef = useRef(null); + + // Rotate the selected arrow to follow the panorama view direction. The value + // is an expression on the feature's own `heading` (not a precomputed scalar), + // so the visible arrow stays correct even in the single frame where the + // selection moves before the snap effect runs (avoids a heading-flip flicker). + const handlePanoramaYaw = useCallback( + (yaw: number) => { + panoramaYawRef.current = yaw; + if ( + !selectedLayerMap || + typeof selectedLayerMap.getLayer !== "function" || + !selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) + ) { + return; + } + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + ["+", ["get", "heading"], yaw * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET] + ); + }, + [selectedLayerMap] + ); + + // Restore the data-driven heading and clear the highlight when the selection + // leaves this layer (the map changes) or the box unmounts; we overrode + // icon-rotate to a constant and drive feature-state programmatically. + useEffect(() => { + if (!selectedLayerMap) return; + return () => { + // The map may already be torn down here (e.g. the user removed this + // layer): getLayer/setLayoutProperty then throw because the map's style + // is gone. A typeof check doesn't prevent that, so guard the call. + try { + if ( + typeof selectedLayerMap.getLayer === "function" && + selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) + ) { + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + [ + "+", + ["get", "heading"], + PANORAMA_INITIAL_YAW * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET, + ] + ); + } + } catch { + // map already removed — nothing to restore + } + const src = resolvePanoSource(selectedLayerMap); + if (src) { + try { + selectedLayerMap.removeFeatureState({ + source: src.sourceId, + sourceLayer: src.sourceLayer, + }); + } catch { + // ignore feature-state errors + } + } + }; + }, [selectedLayerMap]); + + // Keep the pano selection highlight in sync with the redux selection. The + // click path sets feature-state itself, but programmatic tour hops do not, so + // we make the highlight a pure function of the current selection. + useEffect(() => { + if (!selectedLayerMap) return; + const fid = selectedFeature?.properties?.sourceProps?.fid; + const src = resolvePanoSource(selectedLayerMap); + if ( + !src || + fid == null || + typeof selectedLayerMap.getLayer !== "function" || + !selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) + ) { + return; + } + try { + // The pano feature's id is its `fid` natively: vector tiles bake it in, + // and the geojson dataset carries it as the GeoJSON feature `id` (so the + // style must NOT set generateId, which would override it). + selectedLayerMap.removeFeatureState({ + source: src.sourceId, + sourceLayer: src.sourceLayer, + }); + selectedLayerMap.setFeatureState( + { source: src.sourceId, sourceLayer: src.sourceLayer, id: fid }, + { selected: true } + ); + // Snap the arrow to the new feature's initial (forward) orientation right + // away. Data-driven on heading so it is correct the instant the selection + // moves, before the new viewer's first yaw tick arrives. + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + [ + "+", + ["get", "heading"], + PANORAMA_INITIAL_YAW * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET, + ] + ); + } catch { + // ignore feature-state errors + } + }, [selectedFeature, selectedLayerMap]); + + // Jump to a neighbouring panorama (tour hop): build its carma feature from the + // raw vector feature via the layer's infoBoxMapping, move the map highlight, + // and select it — the viewer, infobox and arrow all follow. + const handlePanoramaNavigate = useCallback( + async (targetFid: number) => { + if (!selectedLayerMap || !selectedFeature) return; + const layer = layers.find((l) => l.id === selectedFeature.id); + const src = resolvePanoSource(selectedLayerMap); + if (!layer || !src) return; + let raw; + try { + raw = selectedLayerMap.querySourceFeatures(src.sourceId, { + sourceLayer: src.sourceLayer, + filter: ["==", "fid", targetFid], + })?.[0]; + } catch { + return; + } + if (!raw) return; + const neighborFeature = await createFeature(raw, layer); + if (!neighborFeature) return; + // Recenter the (Leaflet) base map on the pano we jump to, so that when the + // fullscreen viewer is closed the just-visited point sits in the map + // centre. Navigation hotspots only exist in the fullscreen viewer, so this + // path only runs for in-viewer tour hops. Keep the current zoom; no + // animation since the map is hidden behind the lightbox. + const geom = neighborFeature.geometry; + const leaflet = routedMapRef?.leafletMap?.leafletElement; + if (leaflet && geom?.type === "Point" && Array.isArray(geom.coordinates)) { + const [lng, lat] = geom.coordinates as number[]; + if (Number.isFinite(lng) && Number.isFinite(lat)) { + leaflet.setView([lat, lng], leaflet.getZoom(), { animate: false }); + } + } + // The map highlight (selection-arrow feature-state) is kept in sync with + // the redux selection by the effect below, so just select the neighbour. + dispatch(setSelectedFeature(neighborFeature)); + }, + [selectedLayerMap, selectedFeature, layers, dispatch, routedMapRef] + ); + + // Build navigation hotspots from the panos surrounding the selected one, and + // surface the kept neighbours' file names so their images can be preloaded. + const { hotspots: panoramaHotspots, neighbourFileNames } = useMemo<{ + hotspots: PanoramaHotspot[]; + neighbourFileNames: string[]; + }>(() => { + const empty = { hotspots: [], neighbourFileNames: [] }; + const props = selectedFeature?.properties?.sourceProps; + if ( + !selectedLayerMap || + !props || + typeof selectedLayerMap.querySourceFeatures !== "function" + ) { + return empty; + } + const cHeading = Number(props.heading); + const cFid = props.fid; + if (!Number.isFinite(cHeading)) return empty; + + type Candidate = { + fid: number; + dist: number; + dz: number; + bearing: number; + fileName?: string; + /** The neighbour's own heading (deg, 0=N CW): its street orientation. */ + headingDeg?: number; + }; + const candidates: Candidate[] = []; + + // Prefer the baked neighbour list: it rides inside the pano's own vector + // tile, so it is viewport-independent and available the instant the pano is + // selected. querySourceFeatures only sees the current viewport, so it drops + // neighbours across a tile border or when zoomed in. The fallback below + // keeps things working for data that does not carry `nb` yet (e.g. before + // the pipeline is redeployed). + const baked = parseBakedNeighbours((props as { nb?: unknown }).nb); + if (baked) { + for (const entry of baked) { + if (!Array.isArray(entry) || entry.length < 4) continue; + const fid = Number(entry[0]); + const bearing = Number(entry[1]); + const dist = Number(entry[2]); + const dz = Number(entry[3]); + const headingDeg = Number(entry[4]); + if (!Number.isFinite(fid) || fid === cFid) continue; + if (!Number.isFinite(dist) || dist < 0.01) continue; + if (dist > PANORAMA_NEIGHBOR_RADIUS_M) continue; + candidates.push({ + fid, + dist, + dz: Number.isFinite(dz) ? dz : 0, + bearing: Number.isFinite(bearing) ? bearing : 0, + fileName: fidToFileName(fid), + headingDeg: Number.isFinite(headingDeg) ? headingDeg : undefined, + }); + } + } else { + const cx = Number(props.proj_x); + const cy = Number(props.proj_y); + const cz = Number(props.proj_z); + if (Number.isNaN(cx) || Number.isNaN(cy)) return empty; + const src = resolvePanoSource(selectedLayerMap); + if (!src) return empty; + + let all: Array<{ properties?: Record }> = []; + try { + all = selectedLayerMap.querySourceFeatures(src.sourceId, { + sourceLayer: src.sourceLayer, + }); + } catch { + return empty; + } + + const seen = new Set(); + for (const f of all) { + const p = f?.properties; + if (!p || p.fid === cFid || seen.has(p.fid)) continue; + seen.add(p.fid); + const px = Number(p.proj_x); + const py = Number(p.proj_y); + if (Number.isNaN(px) || Number.isNaN(py)) continue; + const dE = px - cx; + const dN = py - cy; + const dist = Math.hypot(dE, dN); + if (dist < 0.01 || dist > PANORAMA_NEIGHBOR_RADIUS_M) continue; + const dz = Number(p.proj_z) - cz; + const bearing = (Math.atan2(dE, dN) * 180) / Math.PI; // 0=N, clockwise + const rawFileName = (p as { file_name?: unknown }).file_name; + const pHeading = Number(p.heading); + candidates.push({ + fid: p.fid, + dist, + dz: Number.isNaN(dz) ? 0 : dz, + bearing, + fileName: typeof rawFileName === "string" ? rawFileName : undefined, + headingDeg: Number.isFinite(pHeading) ? pHeading : undefined, + }); + } + } + + // Project each candidate into the panorama: yaw from the bearing/heading + // calibration, pitch from the camera height and distance. We need both up + // front so the declutter can work in on-screen space. + type Projected = Candidate & { yaw: number; pitch: number }; + const projected: Projected[] = candidates.map((c) => { + const yaw = normalizeDeg( + (c.bearing - cHeading - PANORAMA_YAW_OFFSET) / PANORAMA_YAW_SIGN + ); + const pitch = Math.max( + PANORAMA_HOTSPOT_MIN_PITCH, + Math.min( + PANORAMA_HOTSPOT_MAX_PITCH, + (Math.atan2(c.dz - PANORAMA_CAMERA_HEIGHT_M, c.dist) * 180) / Math.PI + ) + ); + return { ...c, yaw, pitch }; + }); + + // Nearest first, then thin by on-screen separation in (yaw, pitch): panos + // straight down the street share a yaw but differ in pitch, so this keeps + // the receding line of crosses toward the horizon instead of collapsing it + // to a single target. + projected.sort((a, b) => a.dist - b.dist); + const kept: Projected[] = []; + for (const c of projected) { + if (kept.length >= PANORAMA_MAX_HOTSPOTS) break; + const clashes = kept.some((k) => { + const dYaw = normalizeDeg(k.yaw - c.yaw); + const dPitch = k.pitch - c.pitch; + return Math.hypot(dYaw, dPitch) < PANORAMA_MIN_HOTSPOT_SCREEN_SEP_DEG; + }); + if (!clashes) kept.push(c); + } + + // Make sure each target carries its neighbour's heading so the cursor arrow + // can visualise that point's orientation. Baked `nb` written before the + // heading field won't have it, so read it from the live source (the + // neighbour is within 30 m, hence in a loaded tile). No-ops once the tiles + // bake heading and on the fallback path (which already has it). + if (kept.some((c) => c.headingDeg == null)) { + const src = resolvePanoSource(selectedLayerMap); + if (src && typeof selectedLayerMap.querySourceFeatures === "function") { + try { + const all = selectedLayerMap.querySourceFeatures(src.sourceId, { + sourceLayer: src.sourceLayer, + }) as Array<{ properties?: Record }>; + const headingByFid = new Map(); + for (const f of all) { + const p = f?.properties; + if (!p || p.fid == null || headingByFid.has(p.fid)) continue; + const hd = Number(p.heading); + if (Number.isFinite(hd)) headingByFid.set(p.fid, hd); + } + for (const c of kept) { + if (c.headingDeg == null) { + const hd = headingByFid.get(c.fid); + if (hd != null) c.headingDeg = hd; + } + } + } catch { + // ignore — heading stays undefined; arrow falls back to the bearing + } + } + } + + const hotspots = kept.map((c) => { + // Aim the cursor arrow along the neighbour's OWN street (its heading), not + // the bearing to it — at a junction those differ. Put the heading into the + // same yaw frame as the cross, then pick the sense (heading or +180) that + // points toward the neighbour, so the arrow points the way you'd travel. + let streetYaw = c.yaw; + if (c.headingDeg != null && Number.isFinite(c.headingDeg)) { + const hy = normalizeDeg( + (c.headingDeg - cHeading - PANORAMA_YAW_OFFSET) / PANORAMA_YAW_SIGN + ); + const hyOpp = normalizeDeg(hy + 180); + streetYaw = + angularDiffDeg(hy, c.yaw) <= angularDiffDeg(hyOpp, c.yaw) ? hy : hyOpp; + } + return { + id: `pano-nav-${c.fid}`, + pitch: c.pitch, + yaw: c.yaw, + streetYaw, + distanceM: c.dist, + cssClass: "carma-pano-cross-hotspot", + clickHandlerFunc: () => handlePanoramaNavigate(c.fid), + }; + }); + + const neighbourFileNames = kept + .map((c) => c.fileName) + .filter((n): n is string => !!n); + + // [PANORAMA] dev: prove which neighbour source is live, how many targets + // survive declutter, and whether each cross has a distinct street direction + // (streetYaw != yaw means the neighbour heading reached the arrow). Strip + // before merge. + console.log("[PANORAMA] neighbours", { + source: baked ? "baked-nb" : "fallback-querySourceFeatures", + found: candidates.length, + shown: kept.length, + cHeading: Math.round(cHeading), + crosses: hotspots.map((h) => ({ + fid: h.id, + yaw: Math.round(h.yaw), + streetYaw: Math.round(h.streetYaw), + headingReached: Math.round(h.streetYaw) !== Math.round(h.yaw), + })), + }); + + return { hotspots, neighbourFileNames }; + }, [selectedFeature, selectedLayerMap, handlePanoramaNavigate]); + + // Preload the reachable neighbours' panorama images so a tour hop is + // near-instant (fetch + decode happen while the user looks at the current + // pano). Each neighbour URL is derived by swapping the current file name in + // the current panorama URL — the file name appears verbatim once. The Image + // objects are kept in a ref so their in-flight downloads aren't GC'd. + const preloadedPanoramasRef = useRef([]); + useEffect(() => { + const currentUrl = selectedFeature?.properties?.panorama; + const currentFile = selectedFeature?.properties?.sourceProps?.file_name; + if ( + typeof currentUrl !== "string" || + typeof currentFile !== "string" || + !currentFile || + neighbourFileNames.length === 0 + ) { + preloadedPanoramasRef.current = []; + return; + } + const images: HTMLImageElement[] = []; + for (const fileName of neighbourFileNames) { + if (fileName === currentFile) continue; + const url = currentUrl.replace(currentFile, fileName); + if (url === currentUrl) continue; + const img = new Image(); + img.src = url; + img.decode?.().catch(() => { + // ignore decode failures (e.g. aborted when selection changes) + }); + images.push(img); + } + preloadedPanoramasRef.current = images; + }, [selectedFeature, neighbourFileNames]); + + // ↑/↓ in the fullscreen viewer: hop to the neighbour nearest the current view + // direction (↑) or its opposite (↓). The hotspots already carry each + // neighbour's panorama yaw + its navigate handler, so reuse them. + const navigatePanoramaTowardView = useCallback( + (offsetDeg: number) => { + const viewYaw = panoramaYawRef.current ?? PANORAMA_INITIAL_YAW; + const targetYaw = viewYaw + offsetDeg; + let best: PanoramaHotspot | null = null; + let bestDiff = Infinity; + for (const hotspot of panoramaHotspots) { + const diff = angularDiffDeg(hotspot.yaw, targetYaw); + if (diff < bestDiff) { + bestDiff = diff; + best = hotspot; + } + } + if (best && bestDiff <= PANORAMA_NAV_HEMISPHERE_DEG) { + best.clickHandlerFunc?.(); + } + }, + [panoramaHotspots] + ); + const handlePanoramaNext = useCallback( + () => navigatePanoramaTowardView(0), + [navigatePanoramaTowardView] + ); + const handlePanoramaPrevious = useCallback( + () => navigatePanoramaTowardView(180), + [navigatePanoramaTowardView] + ); + + // The panorama as a custom lightbox slide: it hosts the embeddable + // PanoramaViewer (the media lightbox supplies the surrounding chrome). Only + // the active slide is rendered, so the viewer mounts once and tears down when + // the user pages to a photo. + const panoramaSlide = useMemo(() => { + const props = selectedFeature?.properties; + if (!props?.panorama) { + return null; + } + const src = props.panorama as string; + const multiResConfigUrl = props.panoramaMultiResConfig as string | undefined; + return { + type: "custom", + key: "panorama", + render: ({ active }) => + active ? ( + + ) : null, + }; + }, [ + selectedFeature, + handlePanoramaYaw, + handlePanoramaNext, + handlePanoramaPrevious, + panoramaHotspots, + ]); + + // Simple photo images (single `foto` or a `fotos` array) as image slides, so + // they sit alongside the panorama in one lightbox. The harvested-fotostrecke + // path stays on the legacy react-image-lightbox flow (used when there is no + // panorama). + const photoSlides = useMemo(() => { + const props = selectedFeature?.properties; + if (!props) { + return []; + } + const urls: string[] = []; + if (Array.isArray(props.fotos)) { + urls.push(...props.fotos); + } else if (props.foto) { + urls.push(props.foto); + } + return urls + .filter((u): u is string => typeof u === "string" && u.length > 0) + .map((u) => ({ type: "image", src: updateUrl(u) })); + }, [selectedFeature]); + + const mediaSlides = useMemo( + () => (panoramaSlide ? [panoramaSlide, ...photoSlides] : photoSlides), + [panoramaSlide, photoSlides] + ); + + // While the unified (panorama) lightbox is open, keep its slides current: a + // tour hop swaps selectedFeature, so src/hotspots/handlers change and the + // embedded viewer must follow. Guarded to the custom-slide case so the + // photo-only react-image-lightbox path keeps managing its own slides. + const lightboxVisible = !!lightBoxState?.visible; + const lightboxHasCustomSlides = !!lightBoxState?.slides?.some( + (slide) => slide.type === "custom" + ); + useEffect(() => { + if (lightboxVisible && lightboxHasCustomSlides) { + lightBoxDispatchContext?.setSlides(mediaSlides); + } + }, [ + mediaSlides, + lightboxVisible, + lightboxHasCustomSlides, + lightBoxDispatchContext, + ]); + + const openPanoramaLightBox = useCallback(() => { + lightBoxDispatchContext?.setAll({ + // No title — matches the standalone photo lightbox, which shows none here. + title: "", + photourls: [], + // Same Stadt-Wuppertal attribution the photo lightbox shows, so the two + // viewers read identically. Slides may override per-slide if needed. + caption: defaultLightBoxCaptionFactory(), + index: 0, // the panorama is always the first slide + visible: true, + slides: mediaSlides, + }); + }, [lightBoxDispatchContext, selectedFeature, mediaSlides]); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; @@ -293,18 +965,38 @@ const FeatureInfoBox = ({ return <>; } + const panoramaElements = selectedFeature.properties.panorama + ? [ + , + ] + : []; + const visibleSecondaryInfoBoxElements = selectedFeature.properties.foto || selectedFeature.properties.fotos ? [ ...additionalSecondaryInfoBoxElements, ...featureHeaders, + ...panoramaElements, , ] - : [...additionalSecondaryInfoBoxElements, ...featureHeaders]; + : [ + ...additionalSecondaryInfoBoxElements, + ...featureHeaders, + ...panoramaElements, + ]; return ( <> diff --git a/apps/geoportal/src/app/components/feature-info/LibreFeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/LibreFeatureInfoBox.tsx index a2f03de5c6..10d61f4afb 100644 --- a/apps/geoportal/src/app/components/feature-info/LibreFeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/LibreFeatureInfoBox.tsx @@ -5,11 +5,13 @@ import { isEqual } from "lodash"; import envelope from "@turf/envelope"; // import InfoBox from "react-cismap/topicmaps/InfoBox"; -import InfoBoxFotoPreview from "react-cismap/topicmaps/InfoBoxFotoPreview"; +import { + InfoBoxFotoPreview, + LightBoxDispatchContext, +} from "@carma-mapping/lightbox"; import { getActionLinksForFeature } from "react-cismap/tools/uiHelper"; import InfoBoxHeader from "react-cismap/topicmaps/InfoBoxHeader"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; import { additionalInfoFactory } from "@carma-collab/wuppertal/geoportal"; diff --git a/apps/topicmaps/baederkarte/src/app/App.jsx b/apps/topicmaps/baederkarte/src/app/App.jsx index 1e7c3515af..c2ed3f19f7 100644 --- a/apps/topicmaps/baederkarte/src/app/App.jsx +++ b/apps/topicmaps/baederkarte/src/app/App.jsx @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { MappingConstants } from "react-cismap"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import convertItemToFeature from "./helper/convertItemToFeature"; @@ -51,7 +52,9 @@ function App() { backgroundConfigurations={backgroundConfWithFastOrtho2024} > - + + + ); } diff --git a/apps/topicmaps/baederkarte/src/app/Baederkarte.jsx b/apps/topicmaps/baederkarte/src/app/Baederkarte.jsx index 61a7e9e9a6..456a291734 100644 --- a/apps/topicmaps/baederkarte/src/app/Baederkarte.jsx +++ b/apps/topicmaps/baederkarte/src/app/Baederkarte.jsx @@ -89,6 +89,7 @@ const Baederkarte = () => { } gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} diff --git a/apps/topicmaps/e-auto-ladestation/src/app/App.jsx b/apps/topicmaps/e-auto-ladestation/src/app/App.jsx index ecbaf55480..e01efe5f34 100644 --- a/apps/topicmaps/e-auto-ladestation/src/app/App.jsx +++ b/apps/topicmaps/e-auto-ladestation/src/app/App.jsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useState } from "react"; import { MappingConstants } from "react-cismap"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import convertItemToFeature from "./helper/convertItemToFeature"; @@ -62,7 +63,9 @@ function App() { backgroundConfigurations={backgroundConfWithFastOrtho2024} > - + + + ); } else { diff --git a/apps/topicmaps/e-auto-ladestation/src/app/EMobiKarte.jsx b/apps/topicmaps/e-auto-ladestation/src/app/EMobiKarte.jsx index 6c7f381a7f..a1b949b1f4 100644 --- a/apps/topicmaps/e-auto-ladestation/src/app/EMobiKarte.jsx +++ b/apps/topicmaps/e-auto-ladestation/src/app/EMobiKarte.jsx @@ -111,6 +111,7 @@ const EMobiKarte = () => { - + + + ); } diff --git a/apps/topicmaps/e-bikes/src/app/components/Map.tsx b/apps/topicmaps/e-bikes/src/app/components/Map.tsx index 29c6581442..25613869d3 100644 --- a/apps/topicmaps/e-bikes/src/app/components/Map.tsx +++ b/apps/topicmaps/e-bikes/src/app/components/Map.tsx @@ -116,7 +116,7 @@ const Map = () => { locatorControl={false} fullScreenControl={false} zoomControls={false} - photoLightBox + photoLightBox={false} applicationMenuTooltipString={} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} diff --git a/apps/topicmaps/ehrenamtskarte/src/app/Ehrenamtkarte.jsx b/apps/topicmaps/ehrenamtskarte/src/app/Ehrenamtkarte.jsx index 55f174ce51..25788c3644 100644 --- a/apps/topicmaps/ehrenamtskarte/src/app/Ehrenamtkarte.jsx +++ b/apps/topicmaps/ehrenamtskarte/src/app/Ehrenamtkarte.jsx @@ -4,7 +4,6 @@ import { FeatureCollectionContext, FeatureCollectionDispatchContext, } from "react-cismap/contexts/FeatureCollectionContextProvider"; -import { LightBoxContext } from "react-cismap/contexts/LightBoxContextProvider"; import { TopicMapStylingContext } from "react-cismap/contexts/TopicMapStylingContextProvider"; import FeatureCollection from "react-cismap/FeatureCollection"; import TopicMapComponent from "react-cismap/topicmaps/TopicMapComponent"; @@ -88,6 +87,7 @@ const Ehrenamtkarte = ({ bookmarks, setBookmarks }) => { } locatorControl={true} + photoLightBox={false} applicationMenuTooltipString={} infoBox={
diff --git a/apps/topicmaps/generic/src/app/App.jsx b/apps/topicmaps/generic/src/app/App.jsx index fab49b592d..da1ade66e0 100644 --- a/apps/topicmaps/generic/src/app/App.jsx +++ b/apps/topicmaps/generic/src/app/App.jsx @@ -9,6 +9,7 @@ import { md5FetchText } from "react-cismap/tools/fetching"; import { getGazDataForTopicIds } from "react-cismap/tools/gazetteerHelper"; import { pointOnFeature } from "@turf/point-on-feature"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import { getClusterIconCreatorFunction } from "react-cismap/tools/uiHelper"; import { getSimpleHelpForGenericTM } from "react-cismap/tools/genericTopicMapHelper"; import getGTMFeatureStyler, { @@ -1296,13 +1297,15 @@ function App({ name }) { appKey="GenericTopicMap" > - + + + diff --git a/apps/topicmaps/generic/src/app/Map.jsx b/apps/topicmaps/generic/src/app/Map.jsx index 96116b70f9..86acce9480 100644 --- a/apps/topicmaps/generic/src/app/Map.jsx +++ b/apps/topicmaps/generic/src/app/Map.jsx @@ -290,6 +290,13 @@ const Map = ({ getSymbolSVG = undefined; } + // The carma FeatureInfobox dispatches to the CarmaLightBox viewer, while the + // react-cismap GenericInfoBoxFromFeature relies on TopicMapComponent's + // built-in lightbox. Drive photoLightBox off the same condition that picks + // the infobox so exactly one viewer is active. + const useCarmaInfobox = + config.tm.vectorLayers && config.tm.noFeatureCollection === true; + return (
@@ -353,8 +360,9 @@ const Map = ({ fullScreenControl={false} zoomControls={false} gazetteerSearchComponent={EmptySearchComponent} + photoLightBox={!useCarmaInfobox} infoBox={ - config.tm.vectorLayers && config.tm.noFeatureCollection === true ? ( + useCarmaInfobox ? ( - + + + ); } diff --git a/apps/topicmaps/kita-finder/src/app/Kitakarte.jsx b/apps/topicmaps/kita-finder/src/app/Kitakarte.jsx index d32bc1085c..b73a14bca3 100644 --- a/apps/topicmaps/kita-finder/src/app/Kitakarte.jsx +++ b/apps/topicmaps/kita-finder/src/app/Kitakarte.jsx @@ -124,6 +124,7 @@ const KitaKarte = () => {
} diff --git a/apps/topicmaps/klimaorte/src/app/App.jsx b/apps/topicmaps/klimaorte/src/app/App.jsx index 7c66ee9f45..c94fe4aaa8 100644 --- a/apps/topicmaps/klimaorte/src/app/App.jsx +++ b/apps/topicmaps/klimaorte/src/app/App.jsx @@ -5,6 +5,7 @@ import "react-bootstrap-typeahead/css/Typeahead.css"; import { MappingConstants } from "react-cismap"; import Icon from "react-cismap/commons/Icon"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import StyledWMSTileLayer from "react-cismap/StyledWMSTileLayer"; import { getClusterIconCreatorFunction } from "react-cismap/tools/uiHelper"; import "react-cismap/topicMaps.css"; @@ -95,7 +96,9 @@ function App() { disableClusteringAtZoom: 20, }} > - + + + ); } diff --git a/apps/topicmaps/klimaorte/src/app/KlimaorteMap.jsx b/apps/topicmaps/klimaorte/src/app/KlimaorteMap.jsx index 5139fc4cd0..a4fbb8bfff 100644 --- a/apps/topicmaps/klimaorte/src/app/KlimaorteMap.jsx +++ b/apps/topicmaps/klimaorte/src/app/KlimaorteMap.jsx @@ -4,7 +4,6 @@ import { useContext, useEffect, useState } from "react"; import "react-bootstrap-typeahead/css/Typeahead.css"; import FeatureCollection from "react-cismap/FeatureCollection"; import "react-cismap/topicMaps.css"; -import InfoBoxFotoPreview from "react-cismap/topicmaps/InfoBoxFotoPreview"; import ModeSwitcher from "./ModeSwitcher"; import TopicMapComponent from "react-cismap/topicmaps/TopicMapComponent"; @@ -28,7 +27,10 @@ import { } from "@carma-mapping/components"; import { removeQueryPart } from "react-cismap/tools/routingHelper"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { + InfoBoxFotoPreview, + LightBoxDispatchContext, +} from "@carma-mapping/lightbox"; import { appModes, getMode, getModeUrl } from "./helper/modeParser"; import { getClusterIconCreatorFunction } from "react-cismap/tools/uiHelper"; import { getColorConsideringSeondarySelection } from "./helper/styler"; @@ -298,6 +300,7 @@ function KlimaorteMap() { }} /> } diff --git a/apps/topicmaps/kulturstadtplan/src/app/App.tsx b/apps/topicmaps/kulturstadtplan/src/app/App.tsx index 9d97de617d..89e669c58f 100644 --- a/apps/topicmaps/kulturstadtplan/src/app/App.tsx +++ b/apps/topicmaps/kulturstadtplan/src/app/App.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { MappingConstants } from "react-cismap"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import "bootstrap/dist/css/bootstrap.min.css"; import "leaflet/dist/leaflet.css"; @@ -56,7 +57,9 @@ export function App() { backgroundConfigurations={backgroundConfWithFastOrtho2024} > - + + + ); } diff --git a/apps/topicmaps/kulturstadtplan/src/app/components/Map.tsx b/apps/topicmaps/kulturstadtplan/src/app/components/Map.tsx index 5ec5b2b8b5..d13b7ee628 100644 --- a/apps/topicmaps/kulturstadtplan/src/app/components/Map.tsx +++ b/apps/topicmaps/kulturstadtplan/src/app/components/Map.tsx @@ -110,7 +110,7 @@ const Map = () => { locatorControl={false} fullScreenControl={false} zoomControls={false} - photoLightBox + photoLightBox={false} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} applicationMenuTooltipString={} diff --git a/apps/topicmaps/luftmessstationen/src/app/App.jsx b/apps/topicmaps/luftmessstationen/src/app/App.jsx index ea961d412a..8433ba7b07 100644 --- a/apps/topicmaps/luftmessstationen/src/app/App.jsx +++ b/apps/topicmaps/luftmessstationen/src/app/App.jsx @@ -7,6 +7,7 @@ import "leaflet/dist/leaflet.css"; import "react-bootstrap-typeahead/css/Typeahead.css"; import "react-cismap/topicMaps.css"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import getGTMFeatureStyler from "react-cismap/topicmaps/generic/GTMStyler"; import ContactButton from "react-cismap/ContactButton"; @@ -80,7 +81,9 @@ function App() { backgroundConfigurations={backgroundConfWithFastOrtho2024} > - + + + ); } diff --git a/apps/topicmaps/luftmessstationen/src/app/Luftmessstationskarte.jsx b/apps/topicmaps/luftmessstationen/src/app/Luftmessstationskarte.jsx index 15bced1126..163730a91e 100644 --- a/apps/topicmaps/luftmessstationen/src/app/Luftmessstationskarte.jsx +++ b/apps/topicmaps/luftmessstationen/src/app/Luftmessstationskarte.jsx @@ -115,6 +115,7 @@ function Comp() {
- + + + ); } else { diff --git a/apps/topicmaps/stadtplan/src/app/Stadtplankarte.jsx b/apps/topicmaps/stadtplan/src/app/Stadtplankarte.jsx index 09a348ba40..adf7235282 100644 --- a/apps/topicmaps/stadtplan/src/app/Stadtplankarte.jsx +++ b/apps/topicmaps/stadtplan/src/app/Stadtplankarte.jsx @@ -3,7 +3,7 @@ import { FeatureCollectionContext, FeatureCollectionDispatchContext, } from "react-cismap/contexts/FeatureCollectionContextProvider"; -import { LightBoxContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxContext } from "@carma-mapping/lightbox"; import { TopicMapStylingContext } from "react-cismap/contexts/TopicMapStylingContextProvider"; import FeatureCollection from "react-cismap/FeatureCollection"; import TopicMapComponent from "react-cismap/topicmaps/TopicMapComponent"; @@ -83,6 +83,7 @@ const Stadtplankarte = ({ poiColors }) => { } locatorControl={false} fullScreenControl={false} diff --git a/apps/topicmaps/tz-baumbewirtschaftung/src/app/App.jsx b/apps/topicmaps/tz-baumbewirtschaftung/src/app/App.jsx index 8d6686e60a..17a7226ad8 100644 --- a/apps/topicmaps/tz-baumbewirtschaftung/src/app/App.jsx +++ b/apps/topicmaps/tz-baumbewirtschaftung/src/app/App.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState } from "react"; import localforage from "localforage"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import { defaultLayerConf } from "react-cismap/tools/layerFactory"; import LoginForm from "./components/LoginForm"; import TitleControl from "./components/TitleControl"; @@ -350,21 +351,23 @@ function App() { connectionError={connectionError} /> {auth.jwt && } - { - setAuth({ checked: true, jwt: undefined }); - setLoginInfo({ - color: "#F9D423", - text: "Bitte melden Sie sich erneut an.", - }); - setTimeout(() => setLoginInfo(), 2500); - }} - onConnectionError={(hasError) => setConnectionError(hasError)} - /> + + { + setAuth({ checked: true, jwt: undefined }); + setLoginInfo({ + color: "#F9D423", + text: "Bitte melden Sie sich erneut an.", + }); + setTimeout(() => setLoginInfo(), 2500); + }} + onConnectionError={(hasError) => setConnectionError(hasError)} + /> + diff --git a/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx b/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx index e5484ac356..1e411e3d4f 100644 --- a/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx +++ b/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx @@ -56,7 +56,7 @@ import { maxActionIdInLegacyFC, updateFeatureCollectionWithNewActions, } from "./helper/treeHelper"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import { treeMatches, useKampagne } from "./context/KampagneContext"; type LightboxDispatch = { @@ -759,6 +759,7 @@ const TZBaumbewirtschaftung = ({ } gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} diff --git a/apps/topicmaps/vorhabenkarte/src/app/App.tsx b/apps/topicmaps/vorhabenkarte/src/app/App.tsx index 9d83bcd2c8..7e6349d8bf 100644 --- a/apps/topicmaps/vorhabenkarte/src/app/App.tsx +++ b/apps/topicmaps/vorhabenkarte/src/app/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import { MappingConstants } from "react-cismap"; import "bootstrap/dist/css/bootstrap.min.css"; @@ -41,7 +42,9 @@ export function App() { }} convertItemToFeature={convertItemToFeature} > - + + + ); } diff --git a/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx b/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx index 72c41711b1..d44712588f 100644 --- a/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx +++ b/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx @@ -56,7 +56,7 @@ type LightboxDispatch = { import { FeatureIconOverlay } from "./FeatureIconOverlay"; import { TopicMapDispatchContext } from "react-cismap/contexts/TopicMapContextProvider"; import { isAreaType } from "@carma-commons/resources"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import { useGazData } from "@carma-appframeworks/portals"; import versionData from "../../version.json"; import { useUrlFeatureSelection } from "@carma-appframeworks/portals"; @@ -217,7 +217,7 @@ const Map = () => { locatorControl={false} fullScreenControl={false} zoomControls={false} - photoLightBox + photoLightBox={false} applicationMenuTooltipString={} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} diff --git a/apps/topicmaps/x-and-ride/src/app/App.tsx b/apps/topicmaps/x-and-ride/src/app/App.tsx index 083448f0ad..7dcdfdd66e 100644 --- a/apps/topicmaps/x-and-ride/src/app/App.tsx +++ b/apps/topicmaps/x-and-ride/src/app/App.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { MappingConstants } from "react-cismap"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import "bootstrap/dist/css/bootstrap.min.css"; import "leaflet/dist/leaflet.css"; @@ -92,7 +93,9 @@ export function App() { backgroundConfigurations={backgroundConfWithFastOrtho2024} > - + + + ); } diff --git a/apps/topicmaps/x-and-ride/src/app/components/Map.tsx b/apps/topicmaps/x-and-ride/src/app/components/Map.tsx index c7c4669de6..276aae8585 100644 --- a/apps/topicmaps/x-and-ride/src/app/components/Map.tsx +++ b/apps/topicmaps/x-and-ride/src/app/components/Map.tsx @@ -99,7 +99,7 @@ const Map = () => { locatorControl={false} fullScreenControl={false} zoomControls={false} - photoLightBox + photoLightBox={false} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} applicationMenuTooltipString={} diff --git a/apps/wunda/potenzialflaechen/src/app/App.jsx b/apps/wunda/potenzialflaechen/src/app/App.jsx index 3ab2472503..2aa61e6fd0 100644 --- a/apps/wunda/potenzialflaechen/src/app/App.jsx +++ b/apps/wunda/potenzialflaechen/src/app/App.jsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import "react-bootstrap-typeahead/css/Typeahead.css"; import { MappingConstants } from "react-cismap"; import TopicMapContextProvider from "react-cismap/contexts/TopicMapContextProvider"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import { getInternetExplorerVersion } from "react-cismap/tools/browserHelper"; import { md5ActionFetchDAQ, md5FetchText } from "react-cismap/tools/fetching"; import { getGazDataForTopicIds } from "react-cismap/tools/gazetteerHelper"; @@ -259,13 +260,15 @@ function App() { )} - + + + ); } diff --git a/apps/wunda/potenzialflaechen/src/app/PotenzialflaechenOnlineMap.jsx b/apps/wunda/potenzialflaechen/src/app/PotenzialflaechenOnlineMap.jsx index 2aebe548b3..e566bd4747 100644 --- a/apps/wunda/potenzialflaechen/src/app/PotenzialflaechenOnlineMap.jsx +++ b/apps/wunda/potenzialflaechen/src/app/PotenzialflaechenOnlineMap.jsx @@ -210,6 +210,7 @@ function PotenzialflaechenOnlineMap({ } homeZoom={13} diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index d7731a24ca..38673e0d5e 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -65,6 +65,13 @@ export { GenericInfoBoxFromFeature } from "./lib/components/GenericInfoBoxFromFe export { PieChart } from "./lib/components/PieChart.tsx"; export { ContactMailButton } from "./lib/components/ContactMailButton.tsx"; export { FeatureInfobox } from "./lib/components/FeatureInfobox.tsx"; +export { PanoramaLightBox } from "./lib/components/PanoramaLightBox.tsx"; +export { PanoramaViewer } from "./lib/components/PanoramaViewer.tsx"; +export type { + PanoramaHotspot, + PanoramaViewerProps, +} from "./lib/components/PanoramaViewer.tsx"; +export { PanoramaPreview } from "./lib/components/PanoramaPreview.tsx"; export { InfoBoxHeader } from "./lib/components/InfoBoxHeader.tsx"; // CarmaMap moved to @carma-mapping/core // PreviewLibreMap moved to @carma-mapping/engines/maplibre diff --git a/libraries/appframeworks/portals/src/lib/components/CarmaMapProviderWrapper.tsx b/libraries/appframeworks/portals/src/lib/components/CarmaMapProviderWrapper.tsx index 73fa8ee547..14169f5de4 100644 --- a/libraries/appframeworks/portals/src/lib/components/CarmaMapProviderWrapper.tsx +++ b/libraries/appframeworks/portals/src/lib/components/CarmaMapProviderWrapper.tsx @@ -5,6 +5,7 @@ import { LibreContextProvider, MapSelectionProvider, } from "@carma-mapping/contexts"; +import { CarmaLightBox } from "@carma-mapping/lightbox"; import { TopicMapContextProvider } from "react-cismap/contexts/TopicMapContextProvider"; import { GazDataProvider } from "./GazDataProvider"; @@ -208,7 +209,7 @@ export const CarmaMapProviderWrapper = ({ > - {wrappedChildren} + {wrappedChildren} diff --git a/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx b/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx index d44371180b..e004e84223 100644 --- a/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx @@ -1,5 +1,7 @@ -import InfoBoxFotoPreview from "react-cismap/topicmaps/InfoBoxFotoPreview"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { + InfoBoxFotoPreview, + LightBoxDispatchContext, +} from "@carma-mapping/lightbox"; import { useContext, useState, useCallback, useEffect, useRef } from "react"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; @@ -24,6 +26,8 @@ import { type RouteOption, } from "@carma-mapping/routing"; import { useLibreMapLocateControl } from "@carma-mapping/components"; +import { PanoramaLightBox } from "./PanoramaLightBox"; +import { PanoramaPreview } from "./PanoramaPreview"; interface InfoboxProps { selectedFeature: any; @@ -47,6 +51,15 @@ export const FeatureInfobox = ({ const infoBoxControlObject = selectedFeature?.properties?.info || selectedFeature?.properties; const [openModal, setOpenModal] = useState(false); + const [openPanorama, setOpenPanorama] = useState(false); + + useEffect(() => { + console.log("[PANORAMA] selected feature", { + selectedFeature, + infoBoxControlObject, + panorama: infoBoxControlObject?.panorama, + }); + }, [selectedFeature]); const [routeModalOpen, setRouteModalOpen] = useState(false); const [routeOptions, setRouteOptions] = useState([]); const [routeLoading, setRouteLoading] = useState(false); @@ -209,6 +222,15 @@ export const FeatureInfobox = ({ } noCurrentFeatureContent="" secondaryInfoBoxElements={[ + ...(infoBoxControlObject.panorama + ? [ + setOpenPanorama(true)} + />, + ] + : []), ...(infoBoxControlObject.foto || infoBoxControlObject.fotos ? [ )} + {openPanorama && infoBoxControlObject.panorama && ( + setOpenPanorama(false)} + /> + )} ); }; diff --git a/libraries/appframeworks/portals/src/lib/components/GenericInfoBoxFromFeature.tsx b/libraries/appframeworks/portals/src/lib/components/GenericInfoBoxFromFeature.tsx index 177d51dea1..34d404c7a4 100644 --- a/libraries/appframeworks/portals/src/lib/components/GenericInfoBoxFromFeature.tsx +++ b/libraries/appframeworks/portals/src/lib/components/GenericInfoBoxFromFeature.tsx @@ -10,11 +10,13 @@ import { TopicMapDispatchContext } from "react-cismap/contexts/TopicMapContextPr // @ts-ignore import { ResponsiveTopicMapDispatchContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { + InfoBoxFotoPreview, + LightBoxDispatchContext, +} from "@carma-mapping/lightbox"; import { UIDispatchContext } from "react-cismap/contexts/UIContextProvider"; -import InfoBoxFotoPreview from "react-cismap/topicmaps/InfoBoxFotoPreview"; import { updateUrl } from "@carma-commons/utils"; export const getColorForProperties = (props = { color: "#dddddd" }) => { diff --git a/libraries/appframeworks/portals/src/lib/components/InfoBox.tsx b/libraries/appframeworks/portals/src/lib/components/InfoBox.tsx index 9967df4e29..400abbde00 100644 --- a/libraries/appframeworks/portals/src/lib/components/InfoBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/InfoBox.tsx @@ -6,7 +6,7 @@ import { FeatureCollectionDispatchContext, } from "react-cismap/contexts/FeatureCollectionContextProvider"; import { ResponsiveTopicMapContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; -import { LightBoxDispatchContext } from "react-cismap/contexts/LightBoxContextProvider"; +import { LightBoxDispatchContext } from "@carma-mapping/lightbox"; import Color from "color"; import parseHtml from "html-react-parser"; import Button from "react-bootstrap/Button"; diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css new file mode 100644 index 0000000000..c4f3921d75 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -0,0 +1,179 @@ +/* Street-View-style navigation hotspot: a translucent disc with an upward + chevron, placed in the panorama at the bearing of a neighbouring pano. + Pannellum renders a bare
with this class (cssClass replaces its default + sprite styling), so we size and centre it ourselves via negative margins. + + IMPORTANT: pannellum rewrites this element's inline `transform` every frame to + reposition the hotspot as you pan. So this host element must carry NO + transform/transition of its own — otherwise the hotspot animates toward each + new position (wobble/lag) instead of staying glued to its bearing. The visible + disc therefore lives on the ::before pseudo-element, which pannellum never + touches, so it can be hover-scaled freely. */ +.carma-pano-nav-hotspot { + width: 46px; + height: 46px; + margin-left: -23px; + margin-top: -23px; + cursor: pointer; +} + +.carma-pano-nav-hotspot::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-sizing: border-box; + background: rgba(255, 127, 0, 0.5); + border: 2px solid rgba(255, 255, 255, 0.9); + transition: transform 0.12s ease, background 0.12s ease; +} + +.carma-pano-nav-hotspot::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; /* centre the chevron box on the disc centre */ + /* chevron drawn from two borders; base 45° points it up, and the live + --pano-hotspot-dir (set from the viewer yaw) aims it at the neighbour. */ + border-top: 4px solid #fff; + border-left: 4px solid #fff; + transform-origin: center; + transform: rotate(calc(45deg + var(--pano-hotspot-dir, 0deg))); +} + +.carma-pano-nav-hotspot:hover::before { + transform: scale(1.15); + background: rgba(255, 127, 0, 0.8); +} + +/* Street-View ground marker: a faint ring laid flat on the road, placed at a + neighbouring pano's (yaw, pitch). Deliberately subtle (not a button) — it + brightens only when it is the jump target. Same host rule as the nav hotspot: + pannellum rewrites the host transform every frame, so the visible decal lives + on ::before with its OWN 3D transform; `perspective` on the host gives that + transform depth. The per-cross `--pano-cross-tilt` (lay it on the ground) and + `--pano-cross-scale` (nearer = larger) are set in JS when the element is + created. The .is-hover class is toggled from JS onto the ring nearest the + cursor; :hover covers a direct mouse-over. */ +.carma-pano-cross-hotspot { + width: 44px; + height: 44px; + margin-left: -22px; + margin-top: -22px; + cursor: pointer; + perspective: 220px; +} + +.carma-pano-cross-hotspot::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid rgba(255, 255, 255, 0.5); + background: radial-gradient( + closest-side, + rgba(255, 255, 255, 0.12), + rgba(255, 255, 255, 0) + ); + transform: rotateX(var(--pano-cross-tilt, 70deg)) + scale(var(--pano-cross-scale, 1)); + transition: border-color 0.12s ease, background 0.12s ease; +} + +/* Jump target (nearest to cursor, or direct hover): brighten only — keep the + tilt/scale so it stays pinned to the road. */ +.carma-pano-cross-hotspot:hover::before, +.carma-pano-cross-hotspot.is-hover::before { + border-color: rgba(255, 150, 0, 0.95); + background: radial-gradient( + closest-side, + rgba(255, 150, 0, 0.45), + rgba(255, 150, 0, 0) + ); +} + +/* Street-View cursor arrow: a chevron that follows the mouse on the road, + aimed at the cross it would jump to. Positioned via left/top at the cursor + (centred by negative margins) and oriented + foreshortened via an inline + transform (scaleY squashes it onto the ground, rotate aims it). It never + eats pointer events. Hidden by default; the viewer shows it on the ground. */ +.carma-pano-cursor-arrow { + position: fixed; + width: 138px; + height: 138px; + margin-left: -69px; + margin-top: -69px; + z-index: 100002; + pointer-events: none; + opacity: 0; + transform-origin: center; + transition: opacity 0.08s ease; + will-change: left, top, transform, opacity; +} + +.carma-pano-cursor-arrow svg { + display: block; + width: 100%; + height: 100%; + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.7)); +} + +/* "Go back" hit zone pinned to the bottom centre of the viewer (over the survey + vehicle). The pano behind you is otherwise only reachable with the ArrowDown + key or by panning 180°, so this gives backward navigation a mouse target. The + zone is invisible; the native cursor is hidden here and replaced by the DOM + .carma-pano-back-cursor arrow (an SVG *image* cursor renders blank in Chrome, + so we draw our own). It mirrors the ArrowDown key (onPrevious). */ +.carma-pano-back-btn { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + z-index: 100001; + width: 140px; + height: 84px; + padding: 0; + border: none; + background: transparent; + cursor: none; +} + +/* The downward "back" chevron drawn in place of the native cursor over the zone + — identical look/size to .carma-pano-cursor-arrow, just pointing down. + Positioned via left/top at the pointer and centred on it via negative margins. + Never eats pointer events; faded in/out from JS by toggling opacity. */ +.carma-pano-back-cursor { + position: fixed; + width: 138px; + height: 138px; + margin-left: -69px; + margin-top: -69px; + z-index: 100003; + pointer-events: none; + opacity: 0; + transition: opacity 0.08s ease; + will-change: left, top, opacity; +} + +.carma-pano-back-cursor svg { + display: block; + width: 100%; + height: 100%; + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.7)); +} + +/* Dark placeholder + hide pannellum's load indicator (set inline, hence + !important) so a cached/fast panorama doesn't briefly flash a spinner / 100% + progress bar; the dark overlay covers the short decode time. */ +.carma-panorama-lightbox__viewer .pnlm-container { + background: #2a2a2a; +} + +.carma-panorama-lightbox__viewer .pnlm-lbox, +.carma-panorama-lightbox__viewer .pnlm-load-box { + display: none !important; +} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx new file mode 100644 index 0000000000..f41c71e340 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -0,0 +1,63 @@ +import { createPortal } from "react-dom"; + +import { PanoramaViewer, type PanoramaViewerProps } from "./PanoramaViewer"; + +// Re-export so existing consumers of `@carma-appframeworks/portals` keep +// importing PanoramaHotspot from here. +export type { PanoramaHotspot } from "./PanoramaViewer"; + +interface PanoramaLightBoxProps + extends Omit { + title?: string; + onClose: () => void; +} + +/** + * Standalone fullscreen overlay that renders a 360° panorama with pannellum. + * + * Thin wrapper: it provides the fullscreen chrome (dark backdrop + close + * button) and embeds the reusable {@link PanoramaViewer} for the actual + * rendering and Street-View interaction. The same viewer is also embedded as a + * slide inside the media lightbox, which supplies its own chrome instead. + */ +export const PanoramaLightBox = ({ + onClose, + ...viewerProps +}: PanoramaLightBoxProps) => { + return createPortal( +
+ + +
, + document.body + ); +}; + +export default PanoramaLightBox; diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css new file mode 100644 index 0000000000..56a3ec1937 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css @@ -0,0 +1,67 @@ +.carma-panorama-preview-wrap { + width: 100%; + border-collapse: collapse; +} + +.carma-panorama-preview-wrap td { + padding: 0; + /* right-align the box on desktop, like the photo preview */ + text-align: right; +} + +.carma-panorama-preview { + position: relative; + display: inline-block; + vertical-align: top; + width: 250px; + max-width: 100%; + height: 160px; + /* gap to the infobox header below, matching the photo preview */ + margin-bottom: 5px; + border-radius: 4px; + overflow: hidden; +} + +/* Full width when the infobox goes full width on small screens. */ +@media (max-width: 700px) { + .carma-panorama-preview { + width: 100%; + } +} + +.carma-panorama-preview__viewer { + width: 100%; + height: 100%; +} + +/* Fullscreen control overlaid in the top-right corner. Must be absolutely + positioned; otherwise it drops into normal flow below the full-height viewer + and gets clipped by the container's overflow: hidden. */ +.carma-panorama-preview__expand { + position: absolute; + top: 4px; + right: 4px; + z-index: 2; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, 0.55); + color: white; + font-size: 14px; + line-height: 26px; + cursor: pointer; +} + +/* Loading state: clean dark placeholder. Hide pannellum's load indicator + (it sets display inline, hence !important) so a cached/fast panorama doesn't + briefly flash a spinner / 100% progress bar; the dark placeholder covers the + short decode time. */ +.carma-panorama-preview .pnlm-container { + background: #2a2a2a; +} + +.carma-panorama-preview .pnlm-lbox, +.carma-panorama-preview .pnlm-load-box { + display: none !important; +} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx new file mode 100644 index 0000000000..cde74e1f98 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from "react"; + +import "pannellum/build/pannellum.css"; +import "pannellum"; +import "./PanoramaPreview.css"; +import { resolvePanoramaScene, type PannellumViewer } from "./panoramaScene"; + +interface PanoramaPreviewProps { + /** URL of an equirectangular panorama image (fallback when no multires). */ + src: string; + /** URL of a pannellum multiresolution config.json; preferred over `src`. */ + multiResConfigUrl?: string; + /** Called when the user clicks the expand control (open fullscreen). */ + onExpand?: () => void; + /** Called with the viewer's current yaw (deg) as the user looks around. */ + onYawChange?: (yaw: number) => void; + /** Initial view yaw (deg); rotates the image so it opens facing forward. */ + initialYaw?: number; +} + +/** + * Desktop: fixed thumbnail. Mobile (infobox goes full width): fill the row. + * + * The infobox places each secondary element in a shrink-to-fit, right-aligned + * cell, so a percentage width collapses the cell to ~zero. We must set an + * explicit pixel width. + */ +const getPreviewWidth = () => + typeof window !== "undefined" && window.innerWidth <= 700 + ? window.innerWidth - 16 + : 250; + +const PREVIEW_HEIGHT = 160; + +/** + * Small inline, draggable 360° preview rendered in the infobox. Drag to peek; + * the expand control opens the fullscreen PanoramaLightBox. + */ +export const PanoramaPreview = ({ + src, + multiResConfigUrl, + onExpand, + onYawChange, + initialYaw, +}: PanoramaPreviewProps) => { + const containerRef = useRef(null); + const viewerRef = useRef(null); + const [width, setWidth] = useState(getPreviewWidth); + // Held in refs so changing their identity never re-inits the viewer. + const onYawChangeRef = useRef(onYawChange); + onYawChangeRef.current = onYawChange; + const initialYawRef = useRef(initialYaw); + initialYawRef.current = initialYaw; + + useEffect(() => { + const onResize = () => setWidth(getPreviewWidth()); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // Init the viewer once per panorama. Re-initialising on width changes would + // reload the image; instead we refit the existing viewer below. + useEffect(() => { + const el = containerRef.current; + const pannellum = window.pannellum; + if (!el || !pannellum) return; + + // Scene resolution is async (a multires config is fetched), so guard against + // the effect being cleaned up before the viewer is created. + let cancelled = false; + let raf = 0; + + resolvePanoramaScene(src, multiResConfigUrl).then((scene) => { + if (cancelled) return; + const viewer = pannellum.viewer(el, { + ...scene, + autoLoad: true, + showControls: false, + showFullscreenCtrl: false, + showZoomCtrl: false, + keyboardZoom: false, + mouseZoom: false, + compass: false, + draggable: true, + yaw: initialYawRef.current ?? 0, + }); + viewerRef.current = viewer; + + // Pannellum has no continuous "view changed" event, so poll the yaw on + // each frame and report changes (lets the map's selection arrow follow). + let lastYaw = NaN; + const tick = () => { + // Skip until loaded: before that pannellum reports yaw 0, not the + // configured initial yaw, which would briefly flip the map arrow 180°. + if (!viewer.isLoaded || viewer.isLoaded()) { + const yaw = viewer.getYaw(); + if (yaw !== lastYaw) { + lastYaw = yaw; + onYawChangeRef.current?.(yaw); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + viewerRef.current?.destroy(); + viewerRef.current = null; + }; + }, [src, multiResConfigUrl]); + + // Refit the already-loaded viewer to the new container width (no reload). + useEffect(() => { + viewerRef.current?.resize(); + }, [width]); + + return ( +
+
+ {onExpand && ( + + )} +
+ ); +}; + +export default PanoramaPreview; diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx new file mode 100644 index 0000000000..b41c088724 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx @@ -0,0 +1,608 @@ +import { useEffect, useRef } from "react"; + +import { resolvePanoramaScene, type PannellumViewer } from "./panoramaScene"; + +// PoC: raw pannellum (vanilla JS). The official React wrapper (pannellum-react) +// only declares react@16 as a peer dependency, so we drive the library directly. +import "pannellum/build/pannellum.css"; +import "pannellum"; +import "./PanoramaLightBox.css"; + +declare global { + interface Window { + pannellum?: { + viewer: ( + container: HTMLElement | string, + config: Record + ) => PannellumViewer; + }; + } +} + +/** + * A pannellum navigation hotspot (subset of pannellum's hotspot config). Placed + * at a (pitch, yaw) inside the panorama; clicking it triggers clickHandlerFunc + * (used here to jump to a neighbouring panorama — a Street-View-style tour). + */ +export interface PanoramaHotspot { + id?: string; + pitch: number; + yaw: number; + cssClass?: string; + clickHandlerFunc?: () => void; + /** Horizontal distance to the neighbour (m); drives the ground-decal scale. */ + distanceM?: number; + /** + * Viewer yaw (deg) of the neighbour's own street direction (from its heading), + * pointing toward the neighbour. Aims the cursor arrow along the street, which + * differs from the bearing to the neighbour at a junction. + */ + streetYaw?: number; +} + +export interface PanoramaViewerProps { + /** URL of an equirectangular panorama image (fallback when no multires). */ + src: string; + /** URL of a pannellum multiresolution config.json; preferred over `src`. */ + multiResConfigUrl?: string; + /** Called with the viewer's current yaw (deg) as the user looks around. */ + onYawChange?: (yaw: number) => void; + /** Arrow Up: navigate to the next position (the parent decides which). */ + onNext?: () => void; + /** Arrow Down: navigate to the previous position (the parent decides which). */ + onPrevious?: () => void; + /** Navigation hotspots to the surrounding panoramas. */ + hotspots?: PanoramaHotspot[]; + /** Initial view yaw (deg); rotates the image so it opens facing forward. */ + initialYaw?: number; + /** + * Optional Escape handler. Standalone use (PanoramaLightBox) passes its close + * fn; when embedded in the media lightbox the shell owns Escape, so this is + * omitted to avoid a double close. + */ + onEscape?: () => void; +} + +// Project a world direction (vx,vy,vz; x right, y up, z forward at yaw 0) to a +// pixel in the viewer, given the current view (yaw0, pitch0, hfov) and viewport +// size. pannellum renders a rectilinear (pinhole) view, so this is a standard +// perspective projection; the screen position depends only on direction, so it +// projects both sphere points (a cross) and points-at-infinity (a vanishing +// point). Returns null when the direction is behind the camera. +const projectVec = ( + vx: number, + vy: number, + vz: number, + yaw0Deg: number, + pitch0Deg: number, + hfovDeg: number, + w: number, + h: number +): { x: number; y: number } | null => { + const d2r = Math.PI / 180; + const yaw0 = yaw0Deg * d2r; + const pitch0 = pitch0Deg * d2r; + const F = [ + Math.sin(yaw0) * Math.cos(pitch0), + Math.sin(pitch0), + Math.cos(yaw0) * Math.cos(pitch0), + ]; + const R = [Math.cos(yaw0), 0, -Math.sin(yaw0)]; // horizontal right + const U = [ + F[1] * R[2] - F[2] * R[1], + F[2] * R[0] - F[0] * R[2], + F[0] * R[1] - F[1] * R[0], + ]; // up = F x R + const xc = vx * R[0] + vy * R[1] + vz * R[2]; + const yc = vx * U[0] + vy * U[1] + vz * U[2]; + const zc = vx * F[0] + vy * F[1] + vz * F[2]; + if (zc <= 0.001) return null; // behind the camera + const f = w / 2 / Math.tan((hfovDeg * d2r) / 2); + return { x: w / 2 + (f * xc) / zc, y: h / 2 - (f * yc) / zc }; +}; + +// Unit direction for a (yaw, pitch) in degrees. +const dirFromYawPitch = ( + yawDeg: number, + pitchDeg: number +): [number, number, number] => { + const d2r = Math.PI / 180; + const yaw = yawDeg * d2r; + const pitch = pitchDeg * d2r; + return [ + Math.sin(yaw) * Math.cos(pitch), + Math.sin(pitch), + Math.cos(yaw) * Math.cos(pitch), + ]; +}; + +/** + * Embeddable 360° panorama viewer (pannellum). Fills its parent (100% × 100%) + * and renders no fullscreen chrome of its own — the host (PanoramaLightBox or + * the media lightbox shell) provides the overlay, title and close button. It + * carries the in-viewer interaction layer: the Street-View cursor arrow, the + * navigation hotspots, and the bottom-centre "go back" zone. + */ +export const PanoramaViewer = ({ + src, + multiResConfigUrl, + onYawChange, + onNext, + onPrevious, + hotspots, + initialYaw, + onEscape, +}: PanoramaViewerProps) => { + const containerRef = useRef(null); + // The Street-View-style arrow that follows the cursor on the ground. + const cursorArrowRef = useRef(null); + // A DOM "back" arrow that replaces the native cursor over the go-back zone. + // (An SVG image cursor renders blank in Chrome, so we draw our own and hide + // the real cursor there — same approach as the ground cursor arrow above.) + const backCursorRef = useRef(null); + // Held in refs so changing their identity never re-runs the init effect. + // onYawChange is read live each frame; hotspots / initialYaw are read when a + // scene is (re)built on a hop. + const onYawChangeRef = useRef(onYawChange); + onYawChangeRef.current = onYawChange; + const hotspotsRef = useRef(hotspots); + hotspotsRef.current = hotspots; + const initialYawRef = useRef(initialYaw); + initialYawRef.current = initialYaw; + const onNextRef = useRef(onNext); + onNextRef.current = onNext; + const onPreviousRef = useRef(onPrevious); + onPreviousRef.current = onPrevious; + const onEscapeRef = useRef(onEscape); + onEscapeRef.current = onEscape; + + // The one reused pannellum viewer (created for the first scene, destroyed on + // unmount), the yaw-poll rAF handle, a monotonic scene-id counter, and the + // current scene's hotspot elements — swapped on each hop so the poll re-aims + // whatever is on screen. + const viewerRef = useRef(null); + const rafRef = useRef(0); + const sceneSeqRef = useRef(0); + // When true, the next hop resets the view to the forward (initial) yaw; set + // only when a cross dot is clicked directly. Otherwise a hop keeps the user's + // current view direction (the yaw frame is street-relative, so e.g. a sidewalk + // stays in view from pano to pano). + const resetViewOnNextHopRef = useRef(false); + const hotspotElsRef = useRef< + Array<{ + el: HTMLElement; + yaw: number; + pitch: number; + streetYaw?: number; + navigate?: () => void; + }> + >([]); + + // Create the pannellum viewer once and reuse it across tour hops: a hop adds + // the new pano as a scene and crossfades to it (loadScene) instead of tearing + // down the WebGL context and re-decoding from scratch. The fade also masks the + // decode. The viewer is destroyed only on unmount (effect below). + useEffect(() => { + const el = containerRef.current; + const pannellum = window.pannellum; + if (!el || !pannellum) return; + + const initialYaw = initialYawRef.current ?? 0; + + // Build this scene's hotspots. pannellum calls createTooltipFunc with each + // hotspot element after applying its cssClass, which is our handle: collect + // the elements (and their navigate fn) so the viewer-level mouse handlers + // can find the cross nearest the cursor. We deliberately DON'T pass + // clickHandlerFunc to pannellum — navigation is driven from a viewer-level + // click that jumps to the nearest cross, so a per-element click would + // double-fire. + const hotspotEls: Array<{ + el: HTMLElement; + yaw: number; + pitch: number; + streetYaw?: number; + navigate?: () => void; + }> = []; + const hotSpots = (hotspotsRef.current ?? []).map((h) => { + const { clickHandlerFunc, ...rest } = h; + return { + ...rest, + createTooltipFunc: (div: HTMLElement) => { + // Lay the ring on the road: a flat decal's foreshortening depends only + // on its depression below horizontal (= its pitch), so tilt = 90−|pitch| + // is correct regardless of where the user looks. Scale by distance so + // nearer panos read as larger (the perspective cue pannellum's + // fixed-size hotspots lack). + const tilt = Math.max(25, Math.min(86, 90 - Math.abs(h.pitch))); + const scale = Math.max(0.45, Math.min(1.5, 6 / (h.distanceM ?? 6))); + div.style.setProperty("--pano-cross-tilt", `${tilt}deg`); + div.style.setProperty("--pano-cross-scale", `${scale}`); + hotspotEls.push({ + el: div, + yaw: h.yaw, + pitch: h.pitch, + streetYaw: h.streetYaw, + navigate: clickHandlerFunc, + }); + }, + }; + }); + + // Scene resolution is async (a multires config is fetched); guard against + // the effect being cleaned up (hop / unmount) before it resolves. + let cancelled = false; + + resolvePanoramaScene(src, multiResConfigUrl).then((scene) => { + if (cancelled) return; + const sceneId = `pano-${sceneSeqRef.current++}`; + const sceneConfig = { ...scene, hotSpots, yaw: initialYaw }; + // Swap in this scene's hotspot list so the running poll re-aims it. + hotspotElsRef.current = hotspotEls; + + if (viewerRef.current) { + // Reuse: register the new pano and crossfade to it. Keep the user's + // current view direction across the hop (so they can follow e.g. a + // sidewalk from pano to pano); only a direct click on a cross dot resets + // the view to the forward (initial) yaw and level pitch. loadScene + // no-ops until the first scene has loaded, which it has by the time the + // user can navigate. + const reset = resetViewOnNextHopRef.current; + resetViewOnNextHopRef.current = false; + const hopYaw = reset ? initialYaw : viewerRef.current.getYaw(); + const hopPitch = reset ? 0 : viewerRef.current.getPitch(); + viewerRef.current.addScene(sceneId, sceneConfig); + viewerRef.current.loadScene(sceneId, hopPitch, hopYaw); + return; + } + + // First scene: init in tour mode (default + scenes) so loadScene works. + viewerRef.current = pannellum.viewer(el, { + default: { + firstScene: sceneId, + sceneFadeDuration: 500, + autoLoad: true, + // Keep the controls container so the mobile device-orientation + // (compass/gyroscope) control stays available; only hide zoom + + // fullscreen. + showZoomCtrl: false, + showFullscreenCtrl: false, + // Free the arrow keys (pannellum pans with them by default) so they + // can drive tour navigation instead. Dragging still pans the view. + disableKeyboardCtrl: true, + }, + scenes: { [sceneId]: sceneConfig }, + }); + + // Pannellum has no continuous "view changed" event, so poll the yaw each + // frame and report it (the map arrow follows). The poll runs for the + // viewer's whole life, across scene loads. + let lastYaw = NaN; + const tick = () => { + const viewer = viewerRef.current; + // Skip until loaded: before that pannellum reports yaw 0, not the + // configured initial yaw, which would briefly flip the map arrow 180°. + if (viewer && (!viewer.isLoaded || viewer.isLoaded())) { + const yaw = viewer.getYaw(); + if (yaw !== lastYaw) { + lastYaw = yaw; + onYawChangeRef.current?.(yaw); + } + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + }); + + // No destroy here: a hop only cancels the pending async apply; the viewer + // persists and is reused. Teardown happens on unmount (effect below). + return () => { + cancelled = true; + }; + }, [src, multiResConfigUrl]); + + // Destroy the reused viewer and stop the yaw poll on unmount only. + useEffect(() => { + return () => { + cancelAnimationFrame(rafRef.current); + viewerRef.current?.destroy(); + viewerRef.current = null; + }; + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onEscapeRef.current?.(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + onNextRef.current?.(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + onPreviousRef.current?.(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // Street-View interaction: a click ANYWHERE jumps to the cross nearest the + // cursor (not just a direct hit), and hover always highlights that same + // nearest cross so the preview matches the jump. + // + // Listeners run in the CAPTURE phase: pannellum binds drag handlers on its + // canvas and swallows mousedown there, so a bubble-phase listener would only + // see presses that land on a cross div (above the canvas) — which is exactly + // why a click used to need a dead-on hit. Capturing means we always see the + // press, wherever it lands. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const DRAG_PX = 6; // moved more than this since mousedown => a pan, not a click + const down = { x: 0, y: 0 }; + + // Events on pannellum's own controls (the mobile device-orientation + // toggle) are UI clicks, not navigation clicks — leave them to pannellum. + const isPannellumControl = (e: MouseEvent) => + !!(e.target as HTMLElement | null)?.closest?.(".pnlm-controls-container"); + + type Entry = (typeof hotspotElsRef.current)[number]; + + // Nearest on-screen cross to a client point (or null). Only crosses whose + // centre is inside the viewer rect count, which drops ones behind you that + // pannellum may park at the edges. + const nearestTo = ( + x: number, + y: number + ): { entry: Entry; dist: number } | null => { + const bounds = el.getBoundingClientRect(); + let best: { entry: Entry; dist: number } | null = null; + for (const entry of hotspotElsRef.current) { + const r = entry.el.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + if ( + cx < bounds.left || + cx > bounds.right || + cy < bounds.top || + cy > bounds.bottom + ) { + continue; + } + const dist = Math.hypot(x - cx, y - cy); + if (!best || dist < best.dist) best = { entry, dist }; + } + return best; + }; + + const highlight = (target: HTMLElement | null) => { + for (const entry of hotspotElsRef.current) { + entry.el.classList.toggle("is-hover", entry.el === target); + } + }; + + const hideArrow = () => { + if (cursorArrowRef.current) cursorArrowRef.current.style.opacity = "0"; + }; + + // The cursor arrow follows the mouse but is oriented by the TRAVEL direction + // (current pano -> target node), not the cursor: that direction is the + // target's street, whose on-screen slope is the line from the target cross + // up to its vanishing point on the horizon (same azimuth, pitch 0). So the + // arrow points "down the street toward where you'd jump" wherever the cursor + // sits. It is foreshortened by the cursor's ground pitch, and hidden above + // the horizon (no street there). + const updateArrow = (e: MouseEvent, target: Entry | null) => { + const arrow = cursorArrowRef.current; + if (!arrow) return; + const viewer = viewerRef.current; + if (!target || !viewer) { + arrow.style.opacity = "0"; + return; + } + + let cursorPitch = -30; // assume ground if pannellum can't tell us + if (typeof viewer.mouseEventToCoords === "function") { + try { + const coords = viewer.mouseEventToCoords(e); + if (coords) cursorPitch = coords[0]; + } catch { + // ignore — keep the ground assumption + } + } + if (cursorPitch > -1) { + arrow.style.opacity = "0"; // pointing at sky / buildings, not the road + return; + } + + const bounds = el.getBoundingClientRect(); + const yaw0 = viewer.getYaw(); + const pitch0 = viewer.getPitch(); + const hfov = viewer.getHfov(); + const w = bounds.width; + const h = bounds.height; + // The street is a straight ground line through the target along its own + // heading; in a pinhole view it projects to a straight screen line through + // the target cross and the street's vanishing point. So aim the arrow from + // the cross toward that vanishing direction. + const nearDir = dirFromYawPitch(target.yaw, target.pitch); + const near = projectVec(...nearDir, yaw0, pitch0, hfov, w, h); + const streetYaw = target.streetYaw ?? target.yaw; + const sr = (streetYaw * Math.PI) / 180; + // Horizontal direction of the street (a point at infinity along it). + let vanish = projectVec( + Math.sin(sr), + 0, + Math.cos(sr), + yaw0, + pitch0, + hfov, + w, + h + ); + let flip = false; + if (!vanish) { + // forward vanishing point is behind the camera: use the opposite end of + // the same street line and flip the arrow back to the travel sense. + vanish = projectVec( + -Math.sin(sr), + 0, + -Math.cos(sr), + yaw0, + pitch0, + hfov, + w, + h + ); + flip = true; + } + if (!near || !vanish) { + arrow.style.opacity = "0"; + return; + } + let theta = + (Math.atan2(vanish.y - near.y, vanish.x - near.x) * 180) / Math.PI; + if (flip) theta += 180; + // Flatter near the horizon (small |pitch| = far away), rounder when the + // cursor is low and near. + const k = Math.max( + 0.3, + Math.min(0.85, Math.sin((Math.abs(cursorPitch) * Math.PI) / 180)) + ); + arrow.style.left = `${e.clientX}px`; + arrow.style.top = `${e.clientY}px`; + arrow.style.transform = `scaleY(${k}) rotate(${theta}deg)`; + arrow.style.opacity = "0.4"; + }; + + const onMove = (e: MouseEvent) => { + if (isPannellumControl(e)) { + highlight(null); + hideArrow(); + return; + } + const best = nearestTo(e.clientX, e.clientY); + highlight(best ? best.entry.el : null); + updateArrow(e, best ? best.entry : null); + }; + const onLeave = () => { + highlight(null); + hideArrow(); + }; + const onDown = (e: MouseEvent) => { + down.x = e.clientX; + down.y = e.clientY; + }; + const onClick = (e: MouseEvent) => { + if (isPannellumControl(e)) { + return; + } + // Ignore the click that ends a drag-to-pan. + if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > DRAG_PX) return; + const best = nearestTo(e.clientX, e.clientY); + if (!best) return; + // A direct click on a cross dot resets the next pano to the forward view; + // clicking elsewhere (which still jumps to the nearest dot) keeps the + // current view direction. + const target = e.target as Node | null; + resetViewOnNextHopRef.current = hotspotElsRef.current.some( + (h) => !!target && h.el.contains(target) + ); + best.entry.navigate?.(); + }; + + el.addEventListener("mousemove", onMove, true); + el.addEventListener("mouseleave", onLeave, true); + el.addEventListener("mousedown", onDown, true); + el.addEventListener("click", onClick, true); + return () => { + el.removeEventListener("mousemove", onMove, true); + el.removeEventListener("mouseleave", onLeave, true); + el.removeEventListener("mousedown", onDown, true); + el.removeEventListener("click", onClick, true); + }; + }, []); + + return ( +
+
+ {onPrevious && ( + // "Go back" hit zone pinned to the bottom centre (over the survey + // vehicle, where the road behind you is): the pano behind you is + // otherwise only reachable with the ArrowDown key or by panning 180°. + // The zone itself is invisible; the only feedback is the native cursor + // being replaced by a downward "back" arrow (backCursorRef) while you + // hover it. Clicking mirrors ArrowDown (onPrevious). It sits outside the + // viewer container, so its click never reaches the viewer-level capture + // handlers (no double navigation). + <> +
+ ); +}; + +export default PanoramaViewer; diff --git a/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts new file mode 100644 index 0000000000..4ef95d92ea --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts @@ -0,0 +1,56 @@ +// Shared pannellum viewer types + scene resolution for the panorama viewers +// (PanoramaPreview / PanoramaLightBox). + +export type PannellumViewer = { + destroy: () => void; + resize: () => void; + getYaw: () => number; + getPitch: () => number; + getHfov: () => number; + isLoaded: () => boolean; + /** Register a scene config under an id (tour mode). */ + addScene: (sceneId: string, config: Record) => void; + /** Crossfade to a registered scene; no-ops until the first scene loaded. */ + loadScene: ( + sceneId: string, + pitch?: number, + yaw?: number, + hfov?: number + ) => void; + /** Sphere coords [pitch, yaw] (deg) under a mouse event, or undefined. */ + mouseEventToCoords?: (event: MouseEvent) => [number, number] | undefined; +}; + +/** + * Resolve the pannellum scene config for a panorama. + * + * When a multiresolution config URL is given, fetch pannellum's generated + * `config.json` and attach a `basePath` (the config's own directory) so + * pannellum can resolve the relative tile paths — the generated config ships + * none. A multires panorama paints a low-res cube immediately and refines, + * which is far faster on first view than a single large equirectangular image. + * + * Falls back to a plain equirectangular image when there is no multires config + * or it cannot be loaded, so panos that have not been tiled yet still work. + */ +export const resolvePanoramaScene = async ( + src: string, + multiResConfigUrl?: string +): Promise> => { + if (multiResConfigUrl) { + try { + const response = await fetch(multiResConfigUrl); + if (!response.ok) { + throw new Error(`multires config request failed: ${response.status}`); + } + const config = (await response.json()) as Record; + // basePath must NOT end in a slash: pannellum builds tile urls as + // `basePath + path`, and the config's `path` already starts with "/". + const basePath = multiResConfigUrl.replace(/\/config\.json(?:\?.*)?$/, ""); + return { ...config, basePath }; + } catch { + // fall through to the equirectangular image + } + } + return { type: "equirectangular", panorama: src }; +}; diff --git a/libraries/appframeworks/portals/src/pannellum.d.ts b/libraries/appframeworks/portals/src/pannellum.d.ts new file mode 100644 index 0000000000..c9b7c1027c --- /dev/null +++ b/libraries/appframeworks/portals/src/pannellum.d.ts @@ -0,0 +1,6 @@ +// Minimal type shim for the raw pannellum library (PoC). +// pannellum ships no type declarations. The `window.pannellum` global is +// augmented in PanoramaLightBox.tsx via `declare global`. + +declare module "pannellum"; +declare module "pannellum/build/pannellum.css"; diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 783f6920fa..ac98f729b1 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -15,7 +15,6 @@ import { } from "react"; import { getHashParams } from "@carma-commons/utils"; import { FeatureCollectionContext } from "react-cismap/contexts/FeatureCollectionContextProvider"; -import PhotoLightBox from "react-cismap/topicmaps/PhotoLightbox"; import { TopicMapStylingContext } from "react-cismap/contexts/TopicMapStylingContextProvider"; import "../styles/map.css"; import { @@ -1714,7 +1713,6 @@ export const LibreMap = ({ version: "0.1.0", }} /> - )} diff --git a/libraries/mapping/lightbox/project.json b/libraries/mapping/lightbox/project.json new file mode 100644 index 0000000000..7157609d97 --- /dev/null +++ b/libraries/mapping/lightbox/project.json @@ -0,0 +1,19 @@ +{ + "name": "mapping-lightbox", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libraries/mapping/lightbox/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libraries/mapping/lightbox" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libraries/mapping/lightbox/src/index.ts b/libraries/mapping/lightbox/src/index.ts new file mode 100644 index 0000000000..c0442f6ac2 --- /dev/null +++ b/libraries/mapping/lightbox/src/index.ts @@ -0,0 +1,36 @@ +export { CarmaLightBox } from "./lib/CarmaLightBox"; +export type { CarmaLightBoxProps } from "./lib/CarmaLightBox"; + +export { default as PhotoLightBox } from "./lib/PhotoLightBox"; +export type { PhotoLightBoxProps } from "./lib/PhotoLightBox"; + +export { default as MediaLightBox } from "./lib/MediaLightBox"; +export type { MediaLightBoxProps } from "./lib/MediaLightBox"; + +export { default as InfoBoxFotoPreview } from "./lib/InfoBoxFotoPreview"; +export type { InfoBoxFotoPreviewProps } from "./lib/InfoBoxFotoPreview"; + +export { + default as LightBoxContextProvider, + UIContextProvider, + LightBoxContext, + LightBoxDispatchContext, +} from "./lib/LightBoxContextProvider"; +export type { + LightBoxState, + LightBoxDispatchValue, + LightBoxSetAllPayload, + LightBoxContextProviderProps, + LightBoxSlide, + LightBoxImageSlide, + LightBoxCustomSlide, +} from "./lib/LightBoxContextProvider"; + +export { + triggerLightBoxForFeature, + getLinkOrText, + fotoKraemerUrlManipulation, + fotoKraemerCaptionFactory, + defaultLightBoxCaptionFactory, +} from "./lib/lightboxHelpers"; +export type { TriggerLightBoxForFeatureArgs } from "./lib/lightboxHelpers"; diff --git a/libraries/mapping/lightbox/src/lib/CarmaLightBox.tsx b/libraries/mapping/lightbox/src/lib/CarmaLightBox.tsx new file mode 100644 index 0000000000..d4b000baca --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/CarmaLightBox.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; +import { UIContextProvider } from "./LightBoxContextProvider"; +import PhotoLightBox, { type PhotoLightBoxProps } from "./PhotoLightBox"; + +// Bundles the lightbox context provider and the viewer in a single mount. +// Mount it once where the map shell lives: it provides LightBoxContext / +// LightBoxDispatchContext to everything below it and renders the viewer, which +// stays idle (renders an empty div) until something sets visible = true. + +export interface CarmaLightBoxProps extends PhotoLightBoxProps { + children?: ReactNode; + enabled?: boolean; + appKey?: string; + persistenceSettings?: Record; +} + +export const CarmaLightBox = ({ + children, + enabled, + appKey, + persistenceSettings, + ...viewerProps +}: CarmaLightBoxProps) => ( + + {children} + + +); + +export default CarmaLightBox; diff --git a/libraries/mapping/lightbox/src/lib/InfoBoxFotoPreview.tsx b/libraries/mapping/lightbox/src/lib/InfoBoxFotoPreview.tsx new file mode 100644 index 0000000000..c16b1da441 --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/InfoBoxFotoPreview.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from "react"; +import { triggerLightBoxForFeature } from "./lightboxHelpers"; +import type { LightBoxDispatchValue } from "./LightBoxContextProvider"; + +// Ported from react-cismap src/lib/topicmaps/InfoBoxFotoPreview.js (unchanged +// behaviour). + +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface InfoBoxFotoPreviewProps { + currentFeature?: any; + getPhotoUrl?: (feature: any) => string | undefined; + getPhotoSeriesUrl?: (feature: any) => string | undefined; + getPhotoSeriesArray?: (feature: any) => string[] | undefined; + urlManipulation?: (input: any) => any; + captionFactory?: (linkUrl: string, feature?: any) => ReactNode; + width?: number; + openLightBox?: () => void; + lightBoxDispatchContext?: LightBoxDispatchValue; +} + +// Since this component is simple and static, there's no parent container for it. +const InfoBoxFotoPreview = ({ + currentFeature, + getPhotoUrl = (feature) => feature?.properties?.foto, + getPhotoSeriesUrl = (feature) => feature?.properties?.fotostrecke, + getPhotoSeriesArray = (feature) => feature?.properties?.fotos, + urlManipulation = (input) => input, + captionFactory, + width = 150, + openLightBox, + lightBoxDispatchContext, +}: InfoBoxFotoPreviewProps) => { + if ( + currentFeature === undefined || + urlManipulation(getPhotoUrl(currentFeature)) === undefined || + getPhotoUrl(currentFeature) === "" + ) { + return
; + } else { + return ( + + + + + + +
+ { + if (openLightBox) { + openLightBox(); + } else if (lightBoxDispatchContext) { + triggerLightBoxForFeature({ + currentFeature, + lightBoxDispatchContext, + captionFactory, + getPhotoUrl, + getPhotoSeriesUrl, + getPhotoSeriesArray, + urlManipulation, + }); + } + }} + target="_fotos" + > + Bild + +
+ ); + } +}; + +export default InfoBoxFotoPreview; diff --git a/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx b/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx new file mode 100644 index 0000000000..34def09bfa --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx @@ -0,0 +1,185 @@ +import localforage from "localforage"; +import { createContext, useEffect, type CSSProperties, type ReactNode } from "react"; +import { useImmer, type Updater } from "use-immer"; +import { setFromLocalforage } from "./_helper"; + +// Ported from react-cismap src/lib/contexts/LightBoxContextProvider.js, +// converted to TS. The original image-only behaviour (photourls/captions) is +// intentionally unchanged; `slides` is an additive, richer media model layered +// on top so the lightbox can host mixed content (images + a panorama viewer). + +// A single image slide: just a URL, optionally with its own title/caption. +export interface LightBoxImageSlide { + type: "image"; + src: string; + title?: ReactNode; + caption?: ReactNode; +} + +// A custom slide: the consumer supplies whatever React node fills the centre +// (e.g. a panorama viewer). `render` is called with `active` so the consumer +// can mount the heavy viewer only while its slide is the one on screen. `key` +// keeps React reconciliation stable across slide changes. +// +// NOTE: the lightbox library deliberately knows nothing about pannellum / +// panoramas — the panorama lives in @carma-appframeworks/portals, which already +// depends on this library. Keeping slides generic (a render fn) avoids a +// dependency cycle while still letting the shared lightbox display a panorama. +export interface LightBoxCustomSlide { + type: "custom"; + key: string; + render: (ctx: { active: boolean }) => ReactNode; + title?: ReactNode; + caption?: ReactNode; +} + +export type LightBoxSlide = LightBoxImageSlide | LightBoxCustomSlide; + +export interface LightBoxState { + title: string; + photourls: string[]; + caption: ReactNode; + captions: ReactNode[]; + index: number; + visible: boolean; + reactModalStyle?: CSSProperties; + // Mixed-media slides. When any slide is a custom one, the viewer renders the + // custom MediaLightBox shell instead of react-image-lightbox. + slides?: LightBoxSlide[]; +} + +export interface LightBoxSetAllPayload { + title: string; + photourls: string[]; + caption: ReactNode; + index: number; + visible?: boolean; + slides?: LightBoxSlide[]; +} + +export interface LightBoxDispatchValue { + dispatch: Updater; + setTitle: (x: string) => void; + setPhotoUrls: (x: string[]) => void; + setCaption: (x: ReactNode) => void; + setCaptions: (x: ReactNode[]) => void; + setIndex: (x: number) => void; + setVisible: (x: boolean) => void; + setSlides: (x: LightBoxSlide[] | undefined) => void; + setAll: (all: LightBoxSetAllPayload) => void; +} + +const StateContext = createContext(undefined); +const DispatchContext = createContext( + undefined +); + +const defaultState: LightBoxState = { + title: "", + photourls: [], + caption: "", + captions: [], + index: 0, + visible: false, +}; + +export interface LightBoxContextProviderProps { + children: ReactNode; + enabled?: boolean; + appKey?: string; + persistenceSettings?: Record; +} + +const UIContextProvider = ({ + children, + enabled = true, + appKey, + persistenceSettings, +}: LightBoxContextProviderProps) => { + const [state, dispatch] = useImmer({ ...defaultState }); + const contextKey = "lightbox"; + const set = (prop: K, noTest?: boolean) => { + return (x: LightBoxState[K]) => { + dispatch((draft) => { + if ( + noTest === true || + JSON.stringify(draft[prop]) !== JSON.stringify(x) + ) { + if (persistenceSettings?.[contextKey]?.includes(prop)) { + localforage.setItem("@" + appKey + "." + contextKey + "." + prop, x); + } + draft[prop] = x; + } + }); + }; + }; + useEffect(() => { + if (persistenceSettings && persistenceSettings[contextKey]) { + for (const prop of persistenceSettings[contextKey]) { + const localforagekey = "@" + appKey + "." + contextKey + "." + prop; + const setter = set(prop as keyof LightBoxState, true) as ( + value: unknown + ) => void; + setFromLocalforage(localforagekey, setter); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const setX = { + setTitle: set("title"), + setPhotoUrls: set("photourls"), + setCaption: set("caption"), + setCaptions: set("captions"), + setIndex: set("index"), + setVisible: set("visible"), + // Slides carry render functions, which JSON.stringify drops — so the usual + // deep-equality guard in `set` would wrongly treat two different slide sets + // as equal. Assign directly instead. + setSlides: (x: LightBoxSlide[] | undefined) => { + dispatch((draft) => { + draft.slides = x; + }); + }, + setAll: (all: LightBoxSetAllPayload) => { + dispatch((draft) => { + draft.title = all.title; + draft.photourls = all.photourls; + draft.caption = all.caption; + draft.index = all.index; + draft.visible = all.visible === undefined ? true : all.visible; + draft.slides = all.slides; + }); + }, + }; + + if (enabled === true) { + return ( + + + {children} + + + ); + } else { + return ( + + + {children} + + + ); + } +}; + +export default UIContextProvider; + +export { + StateContext as LightBoxContext, + DispatchContext as LightBoxDispatchContext, + UIContextProvider, +}; diff --git a/libraries/mapping/lightbox/src/lib/MediaLightBox.css b/libraries/mapping/lightbox/src/lib/MediaLightBox.css new file mode 100644 index 0000000000..b46162128b --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/MediaLightBox.css @@ -0,0 +1,163 @@ +/* Custom mixed-media lightbox shell (MediaLightBox.tsx). The chrome is a + deliberate visual match for react-image-lightbox (same backdrop opacity, bar + heights, translucent bars, and the identical close/nav SVG icons) so a + panorama or image shown here is indistinguishable from the photo lightbox + used across the apps. + + The overlay is position:fixed with a high z-index, so it establishes a + stacking context: the toolbar/caption/nav sit above a panorama slide's own + position:fixed cursor arrows because they share this context. */ +.carma-media-lightbox { + position: fixed; + inset: 0; + z-index: 100000; + background-color: rgba(0, 0, 0, 0.85); + color: #fff; +} + +/* The centre fills the whole overlay; the translucent bars float on top, just + like react-image-lightbox overlays its toolbar/caption on the image. A + panorama viewer (which needs an explicitly sized container) gets the full + area; images are contained within it. */ +.carma-media-lightbox__content { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Contain the image clear of the 50px top toolbar + 50px bottom caption (plus + breathing room), so it never slips under the bars or fills edge-to-edge. The + inline transform applies the zoom/pan on top of this fitted base size. */ +.carma-media-lightbox__image { + max-width: calc(100% - 80px); + max-height: calc(100% - 130px); + object-fit: contain; + transform-origin: center center; + user-select: none; + -webkit-user-drag: none; +} + +/* Top toolbar — matches .ril__toolbar (50px, translucent, space-between). */ +.carma-media-lightbox__toolbar { + position: absolute; + top: 0; + left: 0; + right: 0; + /* Above a panorama slide's own position:fixed cursor arrows (z ~100002). */ + z-index: 100010; + height: 50px; + display: flex; + align-items: center; + justify-content: space-between; + background-color: rgba(0, 0, 0, 0.5); +} + +.carma-media-lightbox__title { + flex: 0 1 auto; + min-width: 0; + padding-left: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 50px; + font-size: 120%; +} + +/* Toolbar right cluster: zoom buttons (image slides only) + close. */ +.carma-media-lightbox__toolbar-right { + flex: 0 0 auto; + display: flex; + align-items: center; + margin-right: 20px; +} + +/* Builtin buttons — 40x35, white SVG icon, 0.7 -> 1 on hover, matching + .ril__builtinButton. */ +.carma-media-lightbox__close, +.carma-media-lightbox__zoom { + width: 40px; + height: 35px; + border: none; + cursor: pointer; + opacity: 0.7; + background-color: transparent; + background-repeat: no-repeat; + background-position: center; +} + +.carma-media-lightbox__close:hover, +.carma-media-lightbox__zoom:hover { + opacity: 1; +} + +/* Close — identical to .ril__closeButton. */ +.carma-media-lightbox__close { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj48cGF0aCBkPSJtIDEsMyAxLjI1LC0xLjI1IDcuNSw3LjUgNy41LC03LjUgMS4yNSwxLjI1IC03LjUsNy41IDcuNSw3LjUgLTEuMjUsMS4yNSAtNy41LC03LjUgLTcuNSw3LjUgLTEuMjUsLTEuMjUgNy41LC03LjUgLTcuNSwtNy41IHoiIGZpbGw9IiNGRkYiLz48L3N2Zz4="); +} + +/* Zoom in / out — identical to .ril__zoomInButton / .ril__zoomOutButton. */ +.carma-media-lightbox__zoom--in { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PHBhdGggZD0iTTEyIDV2NiIvPjwvZz48Y2lyY2xlIGN4PSIxMiIgY3k9IjgiIHI9IjciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+"); +} + +.carma-media-lightbox__zoom--out { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PC9nPjxjaXJjbGUgY3g9IjEyIiBjeT0iOCIgcj0iNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4="); +} + +/* Edge nav buttons — match .ril__navButtons (translucent strip, white chevron + SVG, 0.7 -> 1 on hover). */ +.carma-media-lightbox__nav { + position: absolute; + top: 0; + bottom: 0; + z-index: 100010; + width: 20px; + height: 34px; + padding: 40px 30px; + margin: auto; + border: none; + cursor: pointer; + opacity: 0.7; +} + +.carma-media-lightbox__nav:hover { + opacity: 1; +} + +.carma-media-lightbox__nav--prev { + left: 0; + background: rgba(0, 0, 0, 0.2) + url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDE5LDMgLTIsLTIgLTE2LDE2IDE2LDE2IDEsLTEgLTE1LC0xNSAxNSwtMTUgeiIgZmlsbD0iI0ZGRiIvPjwvc3ZnPg==") + no-repeat center; +} + +.carma-media-lightbox__nav--next { + right: 0; + background: rgba(0, 0, 0, 0.2) + url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDEsMyAyLC0yIDE2LDE2IC0xNiwxNiAtMSwtMSAxNSwtMTUgLTE1LC0xNSB6IiBmaWxsPSIjRkZGIi8+PC9zdmc+") + no-repeat center; +} + +/* Bottom caption — matches .ril__caption (translucent bar, left-aligned + content). */ +.carma-media-lightbox__caption { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 100010; + max-height: 150px; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); +} + +.carma-media-lightbox__caption-content { + padding: 10px 20px; + color: #fff; +} + +.carma-media-lightbox .pnlm-controls-container { + top: 60px; +} diff --git a/libraries/mapping/lightbox/src/lib/MediaLightBox.tsx b/libraries/mapping/lightbox/src/lib/MediaLightBox.tsx new file mode 100644 index 0000000000..9bf745ca12 --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/MediaLightBox.tsx @@ -0,0 +1,248 @@ +import { + useCallback, + useContext, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; + +import { + LightBoxContext, + LightBoxDispatchContext, + type LightBoxDispatchValue, + type LightBoxSlide, + type LightBoxState, +} from "./LightBoxContextProvider"; + +import "./MediaLightBox.css"; + +// The custom mixed-media lightbox shell. Unlike react-image-lightbox (which can +// only render an from a URL), this reproduces the familiar top/bottom +// navbar + prev/next chrome and renders EITHER an image OR an arbitrary custom +// node (e.g. a panorama viewer) in the centre, chosen per slide. That lets a +// single lightbox carry images and a panorama and flip between them with the +// same navigation. +// +// It is used by PhotoLightBox whenever the slide set contains a custom slide; +// pure-image sets keep using react-image-lightbox (no regression). + +export interface MediaLightBoxProps { + defaultContextValues?: Partial; +} + +const slideTitle = (slide: LightBoxSlide | undefined): ReactNode => + slide?.title; +const slideCaption = (slide: LightBoxSlide | undefined): ReactNode => + slide?.caption; + +// Image zoom: click steps by ZOOM_STEP, wheel by WHEEL_STEP, both bounded to +// [1, MAX_ZOOM]. 1 = fit-to-screen (no pan). +const ZOOM_STEP = 1.4; +const WHEEL_STEP = 1.1; +const MAX_ZOOM = 6; + +export const MediaLightBox = ({ + defaultContextValues = {}, +}: MediaLightBoxProps) => { + const state = useContext(LightBoxContext) ?? defaultContextValues; + const dispatchContext = + useContext(LightBoxDispatchContext) ?? defaultContextValues; + + const { slides = [], index = 0, visible, title, caption } = state; + const { setVisible, setIndex } = dispatchContext; + + const count = slides.length; + // Wrap the incoming index defensively so a stale/out-of-range value never + // lands on `undefined`. + const safeIndex = count > 0 ? ((index % count) + count) % count : 0; + const activeSlide = slides[safeIndex]; + const isImage = activeSlide?.type === "image"; + + const close = useCallback(() => setVisible?.(false), [setVisible]); + const goPrev = useCallback(() => { + if (count > 1) setIndex?.((safeIndex + count - 1) % count); + }, [count, safeIndex, setIndex]); + const goNext = useCallback(() => { + if (count > 1) setIndex?.((safeIndex + 1) % count); + }, [count, safeIndex, setIndex]); + + // Image zoom + pan state (images only; the panorama has its own pannellum + // zoom). Reset whenever the slide or visibility changes so each image opens + // fit-to-screen. + const contentRef = useRef(null); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + useEffect(() => { + setZoom(1); + setPan({ x: 0, y: 0 }); + }, [safeIndex, visible]); + + const zoomIn = useCallback( + () => setZoom((z) => Math.min(z * ZOOM_STEP, MAX_ZOOM)), + [] + ); + const zoomOut = useCallback(() => { + setZoom((z) => { + const next = Math.max(z / ZOOM_STEP, 1); + if (next === 1) setPan({ x: 0, y: 0 }); + return next; + }); + }, []); + + // Wheel zoom. Attached natively (not via onWheel) so preventDefault works — + // React's synthetic wheel listener is passive and cannot block the page. + useEffect(() => { + const el = contentRef.current; + if (!el || !visible || !isImage) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + setZoom((z) => { + const next = + e.deltaY < 0 + ? Math.min(z * WHEEL_STEP, MAX_ZOOM) + : Math.max(z / WHEEL_STEP, 1); + if (next === 1) setPan({ x: 0, y: 0 }); + return next; + }); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [visible, isImage, safeIndex]); + + // Drag to pan while zoomed in. Captures the pan origin at mousedown and + // tracks via window listeners so the drag continues outside the image. + const onImageMouseDown = useCallback( + (e: ReactMouseEvent) => { + if (zoom <= 1) return; + e.preventDefault(); + const start = { x: e.clientX, y: e.clientY, ox: pan.x, oy: pan.y }; + const onMove = (ev: MouseEvent) => { + setPan({ x: start.ox + (ev.clientX - start.x), y: start.oy + (ev.clientY - start.y) }); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [zoom, pan] + ); + + // Esc closes; Left/Right move between slides. Up/Down are intentionally left + // alone so a panorama slide can use them for its own street-view tour nav. + useEffect(() => { + if (!visible) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + close(); + } else if (e.key === "ArrowLeft") { + goPrev(); + } else if (e.key === "ArrowRight") { + goNext(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [visible, close, goPrev, goNext]); + + if (!visible || count === 0) { + return null; + } + + const headerTitle = slideTitle(activeSlide) ?? title; + const footerCaption = slideCaption(activeSlide) ?? caption; + const showNav = count > 1; + + let center: ReactNode = null; + if (activeSlide?.type === "image") { + center = ( + {typeof 1 ? "grab" : "default", + }} + onLoad={() => window.dispatchEvent(new Event("resize"))} + /> + ); + } else if (activeSlide?.type === "custom") { + // Only the active slide is rendered, so a heavy viewer (panorama) mounts + // once and is torn down when the user navigates away. + center = activeSlide.render({ active: true }); + } + + // Chrome (toolbar / caption / nav) intentionally mirrors react-image-lightbox + // — same icons, bar heights, translucency — so a panorama or image opened in + // this shell looks identical to the photo lightbox used elsewhere. + return createPortal( +
+
+ {center} +
+ +
+
{headerTitle}
+
+ {isImage && ( +
+
+ + {showNav && ( +
, + document.body + ); +}; + +export default MediaLightBox; diff --git a/libraries/mapping/lightbox/src/lib/PhotoLightBox.tsx b/libraries/mapping/lightbox/src/lib/PhotoLightBox.tsx new file mode 100644 index 0000000000..6cfd1a6444 --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/PhotoLightBox.tsx @@ -0,0 +1,98 @@ +import { useContext, type CSSProperties, type ReactNode } from "react"; +import Lightbox from "react-image-lightbox"; +import "react-image-lightbox/style.css"; // This only needs to be imported once in your app +import { + LightBoxContext, + LightBoxDispatchContext, + type LightBoxDispatchValue, + type LightBoxState, +} from "./LightBoxContextProvider"; +import MediaLightBox from "./MediaLightBox"; + +// Ported from react-cismap src/lib/topicmaps/PhotoLightbox.js (unchanged +// behaviour for image-only sets). Renders nothing while visible === false, so +// it is safe to mount idle wherever the map shell lives. +// +// When the slide set contains a custom (non-image) slide — e.g. a panorama — +// it delegates to the custom MediaLightBox shell, which can render arbitrary +// content per slide. Pure-image sets keep using react-image-lightbox so their +// zoom/swipe behaviour is unchanged. + +export interface PhotoLightBoxProps { + reactModalStyleOverride?: CSSProperties; + defaultContextValues?: Partial; +} + +const PhotoLightBox = ({ + reactModalStyleOverride, + defaultContextValues = {}, +}: PhotoLightBoxProps) => { + const state = useContext(LightBoxContext) ?? defaultContextValues; + const dispatchContext = + useContext(LightBoxDispatchContext) ?? defaultContextValues; + const { + title, + photourls = [], + caption, + captions, + index = 0, + visible, + reactModalStyle, + slides, + } = state; + const { setVisible, setIndex } = dispatchContext; + + // Mixed media: hand off to the custom shell. It reads the same context, so no + // props need threading through here. + if ((slides ?? []).some((slide) => slide.type === "custom")) { + return ; + } + + if (visible) { + let nextSrc: string | undefined = photourls[(index + 1) % photourls.length]; + let prevSrc: string | undefined = + photourls[(index + photourls.length - 1) % photourls.length]; + + if (photourls.length === 1) { + nextSrc = undefined; + prevSrc = undefined; + } + + let _caption: ReactNode; + + if (captions && captions.length > 0) { + try { + _caption = captions[index]; + } catch (e) { + _caption = caption; + } + } else { + _caption = caption; + } + + return ( + { + window.dispatchEvent(new Event("resize")); + }} + onCloseRequest={() => setVisible?.(false)} + onMovePrevRequest={() => + setIndex?.((index + photourls.length - 1) % photourls.length) + } + onMoveNextRequest={() => setIndex?.((index + 1) % photourls.length)} + imageTitle={title} + imageCaption={_caption as string} + imagePadding={65} + animationDuration={600} + /> + ); + } else { + return
; + } +}; + +export default PhotoLightBox; diff --git a/libraries/mapping/lightbox/src/lib/_helper.ts b/libraries/mapping/lightbox/src/lib/_helper.ts new file mode 100644 index 0000000000..06d18645a1 --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/_helper.ts @@ -0,0 +1,16 @@ +import localforage from "localforage"; + +// Ported from react-cismap src/lib/contexts/_helper.js (unchanged behaviour). +export const setFromLocalforage = async ( + lfKey: string, + setter: (value: unknown) => void, + fallbackValue?: unknown, + forceFallback?: boolean +): Promise => { + const value = await localforage.getItem(lfKey); + if (value !== undefined && value !== null) { + setter(value); + } else if (fallbackValue !== undefined || forceFallback === true) { + setter(fallbackValue); + } +}; diff --git a/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx b/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx new file mode 100644 index 0000000000..0914e61c86 --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx @@ -0,0 +1,164 @@ +import { faCopyright } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import queryString from "query-string"; +import type { ReactNode } from "react"; +import type { LightBoxDispatchValue } from "./LightBoxContextProvider"; + +// Ported from react-cismap src/lib/tools/lightboxHelpers.js. The only change +// vs. the original is the icon: react-cismap commons/Icon -> FontAwesome +// faCopyright. The Wuppertal specifics (Foto-Kraemer URLs, the cismet photo +// series harvester) are kept intentionally unchanged. + +// react-cismap features are loosely typed; keep them permissive on purpose. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// The default attribution caption shown in the photo lightbox (Stadt +// Wuppertal). Exported so other viewers — e.g. the panorama in the media +// lightbox — can render the identical attribution for a consistent look. +export const defaultLightBoxCaptionFactory = ( + _linkUrl?: string, + _feature?: any +): ReactNode => ( + + Stadt Wuppertal + +); + +export interface TriggerLightBoxForFeatureArgs { + currentFeature: any; + getPhotoUrl: (feature: any) => string | undefined; + getPhotoSeriesUrl: (feature: any) => string | undefined; + getPhotoSeriesArray?: (feature: any) => string[] | undefined; + urlManipulation?: (input: any) => any; + lightBoxDispatchContext: LightBoxDispatchValue; + captionFactory?: (linkUrl: string, feature?: any) => ReactNode; + fallbackLinkUrl?: string; + harvester?: (data: string) => { urls: string[]; selectionWish: number }; +} + +export const triggerLightBoxForFeature = ({ + currentFeature, + getPhotoUrl, + getPhotoSeriesUrl, + getPhotoSeriesArray = () => undefined, + urlManipulation = (input) => input, + lightBoxDispatchContext, + captionFactory = defaultLightBoxCaptionFactory, + fallbackLinkUrl = "http://www.fotokraemer-wuppertal.de/", + harvester = (data) => { + const tmp = document.implementation.createHTMLDocument(); + tmp.body.innerHTML = data; + const urls: string[] = []; + let counter = 0; + const mainfotoname = decodeURIComponent(currentFeature.properties.foto) + .split("/") + .pop() + ?.trim(); + let selectionWish = 0; + for (const el of tmp.getElementsByClassName("bilderrahmen")) { + const query = queryString.parse( + el.getElementsByTagName("a")[0].getAttribute("href") ?? "" + ); + urls.push( + "https://wunda-geoportal-fotos.cismet.de/images/" + query.dateiname_bild + ); + if (mainfotoname === query.dateiname_bild) { + selectionWish = counter; + } + counter += 1; + } + return { urls, selectionWish }; + }, +}: TriggerLightBoxForFeatureArgs) => { + const photoUrl = urlManipulation(getPhotoUrl(currentFeature)); + const photoSeriesUrl = urlManipulation(getPhotoSeriesUrl(currentFeature)); + const photoSeriesPhotoUrls = getPhotoSeriesArray(currentFeature); + + if ( + photoSeriesPhotoUrls !== undefined && + photoSeriesPhotoUrls.length !== undefined && + photoSeriesPhotoUrls.length > 0 + ) { + lightBoxDispatchContext.setAll({ + title: currentFeature.text, + photourls: photoSeriesPhotoUrls, + caption: captionFactory(currentFeature.text, currentFeature), + index: 0, + }); + } else if ( + photoSeriesUrl === undefined || + photoSeriesUrl === null || + photoSeriesUrl.indexOf("&noparse") !== -1 + ) { + let linkUrl; + if (photoSeriesUrl) { + linkUrl = photoSeriesUrl; + } else { + linkUrl = fallbackLinkUrl; + } + lightBoxDispatchContext.setAll({ + title: currentFeature.text, + photourls: [photoUrl], + caption: captionFactory(linkUrl, currentFeature), + index: 0, + }); + } else { + fetch(photoSeriesUrl, { + method: "get", + }) + .then(function (response) { + return response.text(); + }) + .then(function (data) { + const { urls, selectionWish } = harvester(data); + lightBoxDispatchContext.setAll({ + title: currentFeature.text, + photourls: urls, + caption: captionFactory(photoSeriesUrl, currentFeature), + index: selectionWish, + }); + }) + .catch(function (err) { + console.log(err); + }); + } +}; + +export const getLinkOrText = (input?: string | null): ReactNode => { + if (input !== undefined && input !== null) { + if (input.startsWith("https://") || input.startsWith("http://")) { + return ( + + siehe externe Webseite + + ); + } else { + return {input}; + } + } +}; + +export const fotoKraemerUrlManipulation = ( + input?: string +): string | undefined => { + if (input !== undefined || input === "") { + const ret = (input as string).replace( + /https*:\/\/.*fotokraemer-wuppertal\.de/, + "https://wunda-geoportal-fotos.cismet.de/" + ); + // console.log('converted url from ', input); + // console.log('converted url to ', ret); + return ret; + } else { + return undefined; + } +}; + +export const fotoKraemerCaptionFactory = ( + linkUrl: string, + currentFeature?: any +): ReactNode => ( + + Peter Krämer - Fotografie + +); diff --git a/libraries/mapping/lightbox/tsconfig.json b/libraries/mapping/lightbox/tsconfig.json new file mode 100644 index 0000000000..3c458b010f --- /dev/null +++ b/libraries/mapping/lightbox/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.legacy.base.json", + "compilerOptions": {}, + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libraries/mapping/lightbox/tsconfig.lib.json b/libraries/mapping/lightbox/tsconfig.lib.json new file mode 100644 index 0000000000..0903525016 --- /dev/null +++ b/libraries/mapping/lightbox/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libraries/mapping/lightbox/vite.config.ts b/libraries/mapping/lightbox/vite.config.ts new file mode 100644 index 0000000000..27f3a4557d --- /dev/null +++ b/libraries/mapping/lightbox/vite.config.ts @@ -0,0 +1,35 @@ +/// +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import * as path from "path"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/libraries/mapping/lightbox", + + plugins: [ + nxViteTsPaths(), + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.lib.json"), + }), + ], + + build: { + outDir: "../../../dist/libraries/mapping/lightbox", + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + entry: "src/index.ts", + name: "carma-mapping-lightbox", + fileName: "index", + formats: ["es", "cjs"], + }, + rollupOptions: { + external: [], + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 410571b8c1..db20ae2758 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "markdown-it": "^14.1.0", "match-sorter": "^6.3.4", "md5": "^2.3.0", + "pannellum": "^2.5.7", "postcss": "8.4.40", "prismjs": "^1.29.0", "proj4": "^2.19.4", @@ -6404,6 +6405,21 @@ "node": ">=8" } }, + "node_modules/@cismet-dev/react-cismap-envirometrics-maps/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@cismet-dev/react-cismap-envirometrics-maps/node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -13264,6 +13280,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -13303,6 +13320,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13323,6 +13341,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13343,6 +13362,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13363,6 +13383,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13383,6 +13404,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13403,6 +13425,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13423,6 +13446,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13443,6 +13467,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13463,6 +13488,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13483,6 +13509,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13503,6 +13530,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13523,6 +13551,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13543,6 +13572,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13560,6 +13590,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -14665,6 +14696,18 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/background/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/background/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14717,6 +14760,18 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/controls/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/controls/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14832,6 +14887,18 @@ "node": ">=12" } }, + "node_modules/@reactflow/core/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/core/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14945,6 +15012,18 @@ "node": ">=12" } }, + "node_modules/@reactflow/minimap/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/minimap/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15021,6 +15100,18 @@ "node": ">=12" } }, + "node_modules/@reactflow/node-resizer/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/node-resizer/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15073,6 +15164,18 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/node-toolbar/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@reactflow/node-toolbar/node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -15677,6 +15780,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15690,6 +15794,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15703,6 +15808,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15716,6 +15822,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15729,6 +15836,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15742,6 +15850,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15755,6 +15864,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15768,6 +15878,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15781,6 +15892,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -17610,7 +17722,7 @@ "version": "1.10.12", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.12.tgz", "integrity": "sha512-+iUL0PYpPm6N9AdV1wvafakvCqFegQus1aoEDxgFsv3/uNVNIyRaupf/v/Zkp5hbep2EzhtoJR0aiJIzDbXWHg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -17652,6 +17764,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17668,6 +17781,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17684,6 +17798,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -17700,6 +17815,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17716,6 +17832,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17732,6 +17849,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17748,6 +17866,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17764,6 +17883,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17780,6 +17900,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17796,6 +17917,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -17809,14 +17931,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -17844,7 +17966,7 @@ "version": "0.1.17", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -35035,6 +35157,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "license": "Apache-2.0", "optional": true, "bin": { @@ -48055,6 +48178,12 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/pannellum": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/pannellum/-/pannellum-2.5.7.tgz", + "integrity": "sha512-AHMRYdLPxetwhBg4lO5EgoZ55C/e+wrJF2WaFQIQA18HSVcvLvsdXEjDL7WpD8MPIFwNnH9qoUDsUqP8cF4pgA==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index 9f9acf8fd1..4fd8c37565 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "markdown-it": "^14.1.0", "match-sorter": "^6.3.4", "md5": "^2.3.0", + "pannellum": "^2.5.7", "postcss": "8.4.40", "prismjs": "^1.29.0", "proj4": "^2.19.4",