From b044bc884dcb0ad2b1141077e1772b6f547466f3 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 14:34:35 +0200 Subject: [PATCH 01/61] =?UTF-8?q?feat(geoportal):=20add=20Pannellum=20360?= =?UTF-8?q?=C2=B0=20panorama=20viewer=20PoC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrate a panorama "photobox" driven by a `panorama` field emitted from the vector-style infoBoxMapping, independent of the react-cismap photo lightbox (no react-cismap changes). - PanoramaPreview: inline draggable 360° preview in the infobox (250x160), with an expand control - PanoramaLightBox: fullscreen pannellum viewer portaled to document.body so it renders above the map; zoom and fullscreen controls hidden, mobile device-orientation (compass) control kept; close via close button / Esc - wire the panorama branch into geoportal FeatureInfoBox and the shared portals FeatureInfobox - add pannellum 2.5.7 dependency and a minimal type shim Note: includes temporary [PANORAMA] debug logging to verify the field handover. --- .../feature-info/FeatureInfoBox.tsx | 35 ++++- libraries/appframeworks/portals/src/index.ts | 2 + .../src/lib/components/FeatureInfobox.tsx | 27 ++++ .../src/lib/components/PanoramaLightBox.tsx | 102 +++++++++++++ .../src/lib/components/PanoramaPreview.tsx | 92 ++++++++++++ .../appframeworks/portals/src/pannellum.d.ts | 6 + package-lock.json | 137 +++++++++++++++++- package.json | 1 + 8 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx create mode 100644 libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx create mode 100644 libraries/appframeworks/portals/src/pannellum.d.ts diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 82085e9a16..2cb73e6b1d 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -39,6 +39,8 @@ import { InfoBoxHeader, utils, getActionLinksForFeature, + PanoramaLightBox, + PanoramaPreview, } from "@carma-appframeworks/portals"; import { parseColor } from "../../helper/color"; import { useFeatureFlags } from "@carma-providers/feature-flag"; @@ -64,6 +66,7 @@ const FeatureInfoBox = ({ additionalSecondaryInfoBoxElements = [], }: InfoBoxProps) => { const [open, setOpen] = useState(false); + const [openPanorama, setOpenPanorama] = useState(false); const [shouldRenderLoadingInfobox, setShouldRenderLoadingInfobox] = useState(false); const [headerColor, setHeaderColor] = useState(""); @@ -244,6 +247,14 @@ const FeatureInfoBox = ({ updateHeaderAndColor(); }, [selectedFeature]); + useEffect(() => { + console.log("[PANORAMA] selected feature", { + selectedFeature, + panorama: selectedFeature?.properties?.panorama, + sourceProps: selectedFeature?.properties?.sourceProps, + }); + }, [selectedFeature]); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; @@ -293,18 +304,33 @@ const FeatureInfoBox = ({ return <>; } + const panoramaElements = selectedFeature.properties.panorama + ? [ + setOpenPanorama(true)} + />, + ] + : []; + const visibleSecondaryInfoBoxElements = selectedFeature.properties.foto || selectedFeature.properties.fotos ? [ ...additionalSecondaryInfoBoxElements, ...featureHeaders, + ...panoramaElements, , ] - : [...additionalSecondaryInfoBoxElements, ...featureHeaders]; + : [ + ...additionalSecondaryInfoBoxElements, + ...featureHeaders, + ...panoramaElements, + ]; return ( <> @@ -363,6 +389,13 @@ const FeatureInfoBox = ({ skipTeilzwilling={true} /> )} + {openPanorama && selectedFeature.properties.panorama && ( + setOpenPanorama(false)} + /> + )} ); }; diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index d7731a24ca..3920c9c566 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -65,6 +65,8 @@ 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 { 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/FeatureInfobox.tsx b/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx index d44371180b..4890ea4cf6 100644 --- a/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/FeatureInfobox.tsx @@ -24,6 +24,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 +49,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 +220,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/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx new file mode 100644 index 0000000000..3d3237431f --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +// 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"; + +declare global { + interface Window { + pannellum?: { + viewer: ( + container: HTMLElement | string, + config: Record + ) => { destroy: () => void }; + }; + } +} + +interface PanoramaLightBoxProps { + /** URL of an equirectangular panorama image. */ + src: string; + title?: string; + onClose: () => void; +} + +/** + * Fullscreen overlay that renders a 360° panorama with pannellum. + * + * This is a standalone PoC viewer (NOT wired into the react-cismap photo + * lightbox). It exists to demonstrate the panorama "photobox" driven by a + * `panorama` field coming out of the vector-style infoBoxMapping. + */ +export const PanoramaLightBox = ({ + src, + onClose, +}: PanoramaLightBoxProps) => { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + const pannellum = window.pannellum; + if (!el || !pannellum) return; + + const viewer = pannellum.viewer(el, { + type: "equirectangular", + panorama: src, + autoLoad: true, + // Keep the controls container so the mobile device-orientation + // (compass/gyroscope) control stays available; only hide zoom + fullscreen. + showZoomCtrl: false, + showFullscreenCtrl: false, + }); + + return () => viewer.destroy(); + }, [src]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return createPortal( +
+ +
+
, + document.body + ); +}; + +export default PanoramaLightBox; 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..8af5e4ce5b --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; + +import "pannellum/build/pannellum.css"; +import "pannellum"; + +interface PanoramaPreviewProps { + /** URL of an equirectangular panorama image. */ + src: string; + width?: number; + height?: number; + /** Called when the user clicks the expand control (open fullscreen). */ + onExpand?: () => void; +} + +/** + * Small inline, draggable 360° preview rendered in the infobox, mirroring the + * flat-photo thumbnail. Drag to peek around; the expand control opens the + * fullscreen PanoramaLightBox. PoC: raw pannellum. + */ +export const PanoramaPreview = ({ + src, + width = 250, + height = 160, + onExpand, +}: PanoramaPreviewProps) => { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + const pannellum = window.pannellum; + if (!el || !pannellum) return; + + const viewer = pannellum.viewer(el, { + type: "equirectangular", + panorama: src, + autoLoad: true, + showControls: false, + showFullscreenCtrl: false, + showZoomCtrl: false, + keyboardZoom: false, + mouseZoom: false, + compass: false, + draggable: true, + }); + + return () => viewer.destroy(); + }, [src]); + + return ( +
+
+ {onExpand && ( + + )} +
+ ); +}; + +export default PanoramaPreview; 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/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", From 84e9fc4338accd7331a0dd8fff0bc2fe811e3738 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 15:09:11 +0200 Subject: [PATCH 02/61] refactor(geoportal): responsive panorama preview and viewer polish PanoramaPreview: - full width on small screens / fixed thumbnail on desktop, using an explicit pixel width (the infobox places each secondary element in a shrink-to-fit cell, so a percentage width collapses to zero) - init pannellum once and call viewer.resize() on width change to refit without reloading the image - clean dark loading placeholder and an enlarged loading spinner - 5px gap to the infobox header, matching the photo preview PanoramaLightBox: - render via a portal to document.body so it appears above the map - hide zoom and fullscreen controls, keep the mobile device-orientation control --- .../src/lib/components/PanoramaLightBox.tsx | 2 +- .../src/lib/components/PanoramaPreview.css | 45 ++++++++++ .../src/lib/components/PanoramaPreview.tsx | 83 ++++++++++--------- 3 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 3d3237431f..609c0ca175 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -12,7 +12,7 @@ declare global { viewer: ( container: HTMLElement | string, config: Record - ) => { destroy: () => void }; + ) => { destroy: () => void; resize: () => void }; }; } } 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..8c9cdf3fa3 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css @@ -0,0 +1,45 @@ +.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%; +} + +/* Loading state: clean placeholder instead of pannellum's faint grid, and a + larger spinner (its default is a fixed ~20px box that looks tiny here). */ +.carma-panorama-preview .pnlm-container { + background: #2a2a2a; +} + +.carma-panorama-preview .pnlm-lbox { + transform: scale(2.5); +} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx index 8af5e4ce5b..7f75257135 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -1,30 +1,50 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import "pannellum/build/pannellum.css"; import "pannellum"; +import "./PanoramaPreview.css"; interface PanoramaPreviewProps { /** URL of an equirectangular panorama image. */ src: string; - width?: number; - height?: number; /** Called when the user clicks the expand control (open fullscreen). */ onExpand?: () => void; } /** - * Small inline, draggable 360° preview rendered in the infobox, mirroring the - * flat-photo thumbnail. Drag to peek around; the expand control opens the - * fullscreen PanoramaLightBox. PoC: raw pannellum. + * 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. */ -export const PanoramaPreview = ({ - src, - width = 250, - height = 160, - onExpand, -}: PanoramaPreviewProps) => { +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, onExpand }: PanoramaPreviewProps) => { const containerRef = useRef(null); + const viewerRef = useRef<{ + destroy: () => void; + resize: () => void; + } | null>(null); + const [width, setWidth] = useState(getPreviewWidth); + 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; @@ -42,22 +62,25 @@ export const PanoramaPreview = ({ compass: false, draggable: true, }); + viewerRef.current = viewer; - return () => viewer.destroy(); + return () => { + viewer.destroy(); + viewerRef.current = null; + }; }, [src]); + // Refit the already-loaded viewer to the new container width (no reload). + useEffect(() => { + viewerRef.current?.resize(); + }, [width]); + return (
-
+
{onExpand && ( From 0b590cf88a9cf50cdc48229a4b1f544511718ade Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 15:14:34 +0200 Subject: [PATCH 03/61] fix(geoportal): restore panorama preview fullscreen button The responsive-design refactor extracted the preview's inline styles into PanoramaPreview.css but dropped the expand button rule. Without absolute positioning the button fell into normal flow below the full-height viewer and was clipped by the container's overflow: hidden, so the fullscreen control was invisible. Re-add the .carma-panorama-preview__expand rule. --- .../src/lib/components/PanoramaPreview.css | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css index 8c9cdf3fa3..eab2fc3513 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css @@ -34,6 +34,25 @@ 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 placeholder instead of pannellum's faint grid, and a larger spinner (its default is a fixed ~20px box that looks tiny here). */ .carma-panorama-preview .pnlm-container { From 9599b0fc1591ad4f6174872542132570e3b93890 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 16:00:08 +0200 Subject: [PATCH 04/61] feat(geoportal): rotate selected panorama arrow to follow viewer direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map's selection-arrow now tracks the live look-direction inside the 360° panorama viewer (Street-View style), instead of only showing the photo's fixed capture heading. - PanoramaPreview / PanoramaLightBox emit an onYawChange(yaw) callback, driven by a requestAnimationFrame loop polling viewer.getYaw() (pannellum has no continuous view-change event). The callback is held in a ref so it never re-inits the viewer / reloads the image. - FeatureInfoBox resolves the selected layer's maplibre map via getMaplibreMaps (keyed by selectedFeature.id) and sets selection-arrow's icon-rotate to heading + yaw. Since that layer only renders the selected feature, the layer-wide constant rotates just the visible arrow; the data-driven ['get','heading'] is restored on deselect/unmount. - Only one viewer drives at a time: the inline preview yields to the fullscreen lightbox while it is open. - Tunable PANORAMA_YAW_SIGN / PANORAMA_YAW_OFFSET constants for calibration. --- .../feature-info/FeatureInfoBox.tsx | 77 ++++++++++++++++++- .../src/lib/components/PanoramaLightBox.tsx | 31 +++++++- .../src/lib/components/PanoramaPreview.tsx | 27 ++++++- 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 2cb73e6b1d..263fec2b16 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -1,4 +1,12 @@ -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"; @@ -22,7 +30,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"; @@ -48,6 +56,18 @@ 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; + interface InfoBoxProps { pos?: [number, number]; onZoomToFeature?: (feature: FeatureInfo) => void; @@ -80,6 +100,7 @@ const FeatureInfoBox = ({ const layers = useSelector(getLayers); const numOfLayers = layers.length; const infoText = useSelector(getInfoText); + const maplibreMaps = useSelector(getMaplibreMaps); const lightBoxDispatchContext = useContext(LightBoxDispatchContext); const { routedMapRef } = useContext(TopicMapContext); @@ -255,6 +276,54 @@ const FeatureInfoBox = ({ }); }, [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] + ); + const panoramaHeading = Number( + selectedFeature?.properties?.sourceProps?.heading + ); + + // Rotate the selected arrow to follow the panorama view direction. + const handlePanoramaYaw = useCallback( + (yaw: number) => { + if ( + !selectedLayerMap || + typeof selectedLayerMap.getLayer !== "function" || + !selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) || + Number.isNaN(panoramaHeading) + ) { + return; + } + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + panoramaHeading + yaw * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET + ); + }, + [selectedLayerMap, panoramaHeading] + ); + + // Restore the data-driven heading when the selection (and thus its map) + // changes or the box unmounts; we overrode icon-rotate to a constant above. + useEffect(() => { + if (!selectedLayerMap) return; + return () => { + if ( + typeof selectedLayerMap.getLayer === "function" && + selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) + ) { + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + ["get", "heading"] + ); + } + }; + }, [selectedLayerMap]); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; @@ -310,6 +379,9 @@ const FeatureInfoBox = ({ key="infobox-panorama-preview" src={selectedFeature.properties.panorama} onExpand={() => setOpenPanorama(true)} + // Only the active viewer drives the map arrow; yield to the + // fullscreen lightbox while it is open. + onYawChange={openPanorama ? undefined : handlePanoramaYaw} />, ] : []; @@ -394,6 +466,7 @@ const FeatureInfoBox = ({ src={selectedFeature.properties.panorama} title={selectedFeature.properties.title} onClose={() => setOpenPanorama(false)} + onYawChange={handlePanoramaYaw} /> )} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 609c0ca175..e8bab372ad 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -12,7 +12,11 @@ declare global { viewer: ( container: HTMLElement | string, config: Record - ) => { destroy: () => void; resize: () => void }; + ) => { + destroy: () => void; + resize: () => void; + getYaw: () => number; + }; }; } } @@ -22,6 +26,8 @@ interface PanoramaLightBoxProps { src: string; title?: string; onClose: () => void; + /** Called with the viewer's current yaw (deg) as the user looks around. */ + onYawChange?: (yaw: number) => void; } /** @@ -34,8 +40,12 @@ interface PanoramaLightBoxProps { export const PanoramaLightBox = ({ src, onClose, + onYawChange, }: PanoramaLightBoxProps) => { const containerRef = useRef(null); + // Held in a ref so changing the callback identity never re-inits the viewer. + const onYawChangeRef = useRef(onYawChange); + onYawChangeRef.current = onYawChange; useEffect(() => { const el = containerRef.current; @@ -52,7 +62,24 @@ export const PanoramaLightBox = ({ showFullscreenCtrl: false, }); - return () => viewer.destroy(); + // Pannellum has no continuous "view changed" event, so poll the yaw on each + // frame and report changes (lets the map's selection arrow follow the view). + let raf = 0; + let lastYaw = NaN; + const tick = () => { + const yaw = viewer.getYaw(); + if (yaw !== lastYaw) { + lastYaw = yaw; + onYawChangeRef.current?.(yaw); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + viewer.destroy(); + }; }, [src]); useEffect(() => { diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx index 7f75257135..42267caf61 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -9,6 +9,8 @@ interface PanoramaPreviewProps { src: 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; } /** @@ -29,13 +31,21 @@ 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, onExpand }: PanoramaPreviewProps) => { +export const PanoramaPreview = ({ + src, + onExpand, + onYawChange, +}: PanoramaPreviewProps) => { const containerRef = useRef(null); const viewerRef = useRef<{ destroy: () => void; resize: () => void; + getYaw: () => number; } | null>(null); const [width, setWidth] = useState(getPreviewWidth); + // Held in a ref so changing the callback identity never re-inits the viewer. + const onYawChangeRef = useRef(onYawChange); + onYawChangeRef.current = onYawChange; useEffect(() => { const onResize = () => setWidth(getPreviewWidth()); @@ -64,7 +74,22 @@ export const PanoramaPreview = ({ src, onExpand }: PanoramaPreviewProps) => { }); 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 the view). + let raf = 0; + let lastYaw = NaN; + const tick = () => { + const yaw = viewer.getYaw(); + if (yaw !== lastYaw) { + lastYaw = yaw; + onYawChangeRef.current?.(yaw); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); viewer.destroy(); viewerRef.current = null; }; From 66a263e9b222be84464352517e28b576d3bfefc9 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 16:00:33 +0200 Subject: [PATCH 05/61] chore(geoportal): log native maplibre features on selection (debug) Temporary [PANORAMA] debug log of the raw native maplibre features (e.hits) in onSelectionChanged, before carma maps them into the redux selectedFeature, plus a window.__panoramaHits handle for interactive inspection. Strip before merge. --- .../GeoportalMap/hooks/useCreateCismapLayer.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From 9ff0e1d6257a62b7617691ba1868d6c0ffdfeb0f Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 17:23:21 +0200 Subject: [PATCH 06/61] feat(geoportal): panorama tour navigation hotspots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Street-View-style navigation between panoramas plus the viewer-orientation handling needed to make it usable: - PanoramaLightBox: accept hotspots (new PanoramaHotspot type) and render them as clickable arrow-discs (PanoramaLightBox.css); clicking one hops to a neighbouring pano. - FeatureInfoBox: find surrounding panos at runtime via querySourceFeatures using each feature's proj_x/proj_y/proj_z, place a hotspot per neighbour at its bearing with pitch refined from horizontal distance + altitude difference, and declutter by distance + angular separation. Navigation rebuilds the neighbour's carma feature via createFeature and selects it, so viewer, infobox and map arrow all follow the tour. - Keep the map selection highlight a pure function of the redux selection (covers programmatic hops) and snap the arrow to the new heading on selection to avoid a stale-direction jump. - Open the viewer facing forward (initialYaw 180; the image centre faces the back of the survey car) and gate yaw reporting on isLoaded() so the arrow does not briefly flip 180° before pannellum applies the initial yaw. --- .../feature-info/FeatureInfoBox.tsx | 242 +++++++++++++++++- libraries/appframeworks/portals/src/index.ts | 1 + .../src/lib/components/PanoramaLightBox.css | 34 +++ .../src/lib/components/PanoramaLightBox.tsx | 43 +++- .../src/lib/components/PanoramaPreview.tsx | 21 +- 5 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 263fec2b16..9c194882c7 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -49,7 +49,9 @@ import { getActionLinksForFeature, PanoramaLightBox, 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"; @@ -67,6 +69,62 @@ const PANORAMA_SELECTION_ARROW_LAYER = "selection-arrow"; // 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; + +// Tour navigation hotspots: pick surrounding panos from the vector source and +// render a clickable arrow at each one's bearing. +const PANORAMA_NEIGHBOR_RADIUS_M = 12; +const PANORAMA_MAX_HOTSPOTS = 6; +// Drop a candidate whose bearing is within this of an already-kept one (keeps +// the nearer); avoids a cluster of overlapping arrows down a straight street. +const PANORAMA_MIN_HOTSPOT_SEPARATION_DEG = 22; +// 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; + +// 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)); + +// The single vector source + source-layer of a panorama style (e.g. the +// oelberg_panorama style has exactly one vector source). +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 ?? {}; + const sourceId = Object.keys(sources).find( + (id) => sources[id]?.type === "vector" + ); + if (!sourceId) return null; + const sourceLayer = style?.layers?.find( + (l) => l.source === sourceId && l["source-layer"] + )?.["source-layer"]; + if (!sourceLayer) return null; + return { sourceId, sourceLayer }; + } catch { + return null; + } +}; interface InfoBoxProps { pos?: [number, number]; @@ -306,8 +364,9 @@ const FeatureInfoBox = ({ [selectedLayerMap, panoramaHeading] ); - // Restore the data-driven heading when the selection (and thus its map) - // changes or the box unmounts; we overrode icon-rotate to a constant above. + // 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 () => { @@ -321,9 +380,185 @@ const FeatureInfoBox = ({ ["get", "heading"] ); } + 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 { + 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 orientation right away. + // Otherwise it keeps the previous selection's overridden icon-rotate until + // the new viewer's first yaw tick arrives (a brief wrong-direction jump, + // ~90° at a T-junction). + const heading = Number(selectedFeature?.properties?.sourceProps?.heading); + if (!Number.isNaN(heading)) { + selectedLayerMap.setLayoutProperty( + PANORAMA_SELECTION_ARROW_LAYER, + "icon-rotate", + 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; + // 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] + ); + + // Build navigation hotspots from the panos surrounding the selected one. + const panoramaHotspots = useMemo(() => { + const props = selectedFeature?.properties?.sourceProps; + if ( + !selectedLayerMap || + !props || + typeof selectedLayerMap.querySourceFeatures !== "function" + ) { + return []; + } + const cx = Number(props.proj_x); + const cy = Number(props.proj_y); + const cz = Number(props.proj_z); + const cHeading = Number(props.heading); + const cFid = props.fid; + if (Number.isNaN(cx) || Number.isNaN(cy) || Number.isNaN(cHeading)) { + return []; + } + const src = resolvePanoSource(selectedLayerMap); + if (!src) return []; + + let all: Array<{ properties?: Record }> = []; + try { + all = selectedLayerMap.querySourceFeatures(src.sourceId, { + sourceLayer: src.sourceLayer, + }); + } catch { + return []; + } + + const seen = new Set(); + const candidates: Array<{ + fid: number; + dist: number; + dz: number; + bearing: number; + }> = []; + 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 + candidates.push({ + fid: p.fid, + dist, + dz: Number.isNaN(dz) ? 0 : dz, + bearing, + }); + } + + candidates.sort((a, b) => a.dist - b.dist); + const kept: typeof candidates = []; + for (const c of candidates) { + if (kept.length >= PANORAMA_MAX_HOTSPOTS) break; + if ( + kept.some( + (k) => + angularDiffDeg(k.bearing, c.bearing) < + PANORAMA_MIN_HOTSPOT_SEPARATION_DEG + ) + ) { + continue; + } + kept.push(c); + } + + return kept.map((c) => { + // Inverse of the arrow calibration: absolute bearing = heading + yaw. + 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 { + id: `pano-nav-${c.fid}`, + pitch, + yaw, + cssClass: "carma-pano-nav-hotspot", + clickHandlerFunc: () => handlePanoramaNavigate(c.fid), + }; + }); + }, [selectedFeature, selectedLayerMap, handlePanoramaNavigate]); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; @@ -382,6 +617,7 @@ const FeatureInfoBox = ({ // Only the active viewer drives the map arrow; yield to the // fullscreen lightbox while it is open. onYawChange={openPanorama ? undefined : handlePanoramaYaw} + initialYaw={PANORAMA_INITIAL_YAW} />, ] : []; @@ -467,6 +703,8 @@ const FeatureInfoBox = ({ title={selectedFeature.properties.title} onClose={() => setOpenPanorama(false)} onYawChange={handlePanoramaYaw} + hotspots={panoramaHotspots} + initialYaw={PANORAMA_INITIAL_YAW} /> )} diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index 3920c9c566..c214038e2c 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -66,6 +66,7 @@ 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 type { PanoramaHotspot } from "./lib/components/PanoramaLightBox.tsx"; export { PanoramaPreview } from "./lib/components/PanoramaPreview.tsx"; export { InfoBoxHeader } from "./lib/components/InfoBoxHeader.tsx"; // CarmaMap moved to @carma-mapping/core 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..c70ee377ac --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -0,0 +1,34 @@ +/* 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. */ +.carma-pano-nav-hotspot { + width: 46px; + height: 46px; + margin-left: -23px; + margin-top: -23px; + border-radius: 50%; + box-sizing: border-box; + background: rgba(255, 127, 0, 0.5); + border: 2px solid rgba(255, 255, 255, 0.9); + cursor: pointer; + 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; + /* up-pointing chevron drawn from two borders, rotated 45° */ + border-top: 4px solid #fff; + border-left: 4px solid #fff; + transform: translate(-50%, -30%) rotate(45deg); +} + +.carma-pano-nav-hotspot:hover { + transform: scale(1.15); + background: rgba(255, 127, 0, 0.8); +} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index e8bab372ad..6ed7b5d5da 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; // 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 { @@ -16,11 +17,25 @@ declare global { destroy: () => void; resize: () => void; getYaw: () => number; + isLoaded: () => boolean; }; }; } } +/** + * 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; +} + interface PanoramaLightBoxProps { /** URL of an equirectangular panorama image. */ src: string; @@ -28,6 +43,10 @@ interface PanoramaLightBoxProps { onClose: () => void; /** Called with the viewer's current yaw (deg) as the user looks around. */ onYawChange?: (yaw: number) => void; + /** Navigation hotspots to the surrounding panoramas. */ + hotspots?: PanoramaHotspot[]; + /** Initial view yaw (deg); rotates the image so it opens facing forward. */ + initialYaw?: number; } /** @@ -41,11 +60,19 @@ export const PanoramaLightBox = ({ src, onClose, onYawChange, + hotspots, + initialYaw, }: PanoramaLightBoxProps) => { const containerRef = useRef(null); - // Held in a ref so changing the callback identity never re-inits the viewer. + // Held in refs so changing their identity never re-inits the viewer. The + // viewer re-inits on src change (a tour hop), which is exactly when fresh + // hotspots / initial yaw apply, so reading the ref at init time is enough. const onYawChangeRef = useRef(onYawChange); onYawChangeRef.current = onYawChange; + const hotspotsRef = useRef(hotspots); + hotspotsRef.current = hotspots; + const initialYawRef = useRef(initialYaw); + initialYawRef.current = initialYaw; useEffect(() => { const el = containerRef.current; @@ -60,6 +87,8 @@ export const PanoramaLightBox = ({ // (compass/gyroscope) control stays available; only hide zoom + fullscreen. showZoomCtrl: false, showFullscreenCtrl: false, + hotSpots: hotspotsRef.current ?? [], + yaw: initialYawRef.current ?? 0, }); // Pannellum has no continuous "view changed" event, so poll the yaw on each @@ -67,10 +96,14 @@ export const PanoramaLightBox = ({ let raf = 0; let lastYaw = NaN; const tick = () => { - const yaw = viewer.getYaw(); - if (yaw !== lastYaw) { - lastYaw = yaw; - onYawChangeRef.current?.(yaw); + // 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); }; diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx index 42267caf61..b9310e379d 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -11,6 +11,8 @@ interface PanoramaPreviewProps { 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; } /** @@ -35,17 +37,21 @@ export const PanoramaPreview = ({ src, onExpand, onYawChange, + initialYaw, }: PanoramaPreviewProps) => { const containerRef = useRef(null); const viewerRef = useRef<{ destroy: () => void; resize: () => void; getYaw: () => number; + isLoaded: () => boolean; } | null>(null); const [width, setWidth] = useState(getPreviewWidth); - // Held in a ref so changing the callback identity never re-inits the viewer. + // 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()); @@ -71,6 +77,7 @@ export const PanoramaPreview = ({ mouseZoom: false, compass: false, draggable: true, + yaw: initialYawRef.current ?? 0, }); viewerRef.current = viewer; @@ -79,10 +86,14 @@ export const PanoramaPreview = ({ let raf = 0; let lastYaw = NaN; const tick = () => { - const yaw = viewer.getYaw(); - if (yaw !== lastYaw) { - lastYaw = yaw; - onYawChangeRef.current?.(yaw); + // 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); }; From b55f971ecf53c91077a9602b426e6e9a45481a4f Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Mon, 22 Jun 2026 17:27:24 +0200 Subject: [PATCH 07/61] fix(geoportal): make panorama arrow rotation data-driven to kill flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selection-arrow's icon-rotate was overridden with a precomputed scalar (selectedHeading + yaw). On selection change the cismap path highlights the new feature one frame before the snap effect runs, so for that frame the arrow used the previous feature's heading — a full heading error (~90° at a T-junction). Write icon-rotate as an expression on the feature's own heading (['+', ['get','heading'], yaw]) in the live follow, the on-select snap and the reset. The visible (selected) arrow is then always rotated by its own heading, so it is correct the instant the selection moves; only the small yaw delta can lag, and that is zero when facing forward. --- .../feature-info/FeatureInfoBox.tsx | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 9c194882c7..4071c20f67 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -340,28 +340,27 @@ const FeatureInfoBox = ({ () => maplibreMaps?.find((entry) => entry.id === selectedFeature?.id)?.map, [maplibreMaps, selectedFeature?.id] ); - const panoramaHeading = Number( - selectedFeature?.properties?.sourceProps?.heading - ); - // Rotate the selected arrow to follow the panorama view direction. + // 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) => { if ( !selectedLayerMap || typeof selectedLayerMap.getLayer !== "function" || - !selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) || - Number.isNaN(panoramaHeading) + !selectedLayerMap.getLayer(PANORAMA_SELECTION_ARROW_LAYER) ) { return; } selectedLayerMap.setLayoutProperty( PANORAMA_SELECTION_ARROW_LAYER, "icon-rotate", - panoramaHeading + yaw * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET + ["+", ["get", "heading"], yaw * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET] ); }, - [selectedLayerMap, panoramaHeading] + [selectedLayerMap] ); // Restore the data-driven heading and clear the highlight when the selection @@ -377,7 +376,11 @@ const FeatureInfoBox = ({ selectedLayerMap.setLayoutProperty( PANORAMA_SELECTION_ARROW_LAYER, "icon-rotate", - ["get", "heading"] + [ + "+", + ["get", "heading"], + PANORAMA_INITIAL_YAW * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET, + ] ); } const src = resolvePanoSource(selectedLayerMap); @@ -418,18 +421,18 @@ const FeatureInfoBox = ({ { source: src.sourceId, sourceLayer: src.sourceLayer, id: fid }, { selected: true } ); - // Snap the arrow to the new feature's initial orientation right away. - // Otherwise it keeps the previous selection's overridden icon-rotate until - // the new viewer's first yaw tick arrives (a brief wrong-direction jump, - // ~90° at a T-junction). - const heading = Number(selectedFeature?.properties?.sourceProps?.heading); - if (!Number.isNaN(heading)) { - selectedLayerMap.setLayoutProperty( - PANORAMA_SELECTION_ARROW_LAYER, - "icon-rotate", - heading + PANORAMA_INITIAL_YAW * PANORAMA_YAW_SIGN + PANORAMA_YAW_OFFSET - ); - } + // 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 } From c822372ba5b7de7706dde128f1e8049716bc39f1 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 11:39:09 +0200 Subject: [PATCH 08/61] fix(portals): hide pannellum load indicator on cached panoramas Pannellum shows a spinner and a load-progress box while a panorama loads. For a cached or fast-decoding image this only flashes briefly, which reads as a glitch. Hide the load indicator (pannellum sets its display inline, so !important is required) and keep a dark #2a2a2a placeholder to cover the short decode time, in both the inline preview and the fullscreen lightbox. Tag the lightbox viewer container with a carma-panorama-lightbox__viewer class so the CSS can target it. --- .../portals/src/lib/components/PanoramaLightBox.css | 12 ++++++++++++ .../portals/src/lib/components/PanoramaLightBox.tsx | 6 +++++- .../portals/src/lib/components/PanoramaPreview.css | 11 +++++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index c70ee377ac..14b531b00f 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -32,3 +32,15 @@ transform: scale(1.15); background: rgba(255, 127, 0, 0.8); } + +/* 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 index 6ed7b5d5da..63c9aa0e9f 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -153,7 +153,11 @@ export const PanoramaLightBox = ({ > × -
+
, document.body ); diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css index eab2fc3513..56a3ec1937 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.css @@ -53,12 +53,15 @@ cursor: pointer; } -/* Loading state: clean placeholder instead of pannellum's faint grid, and a - larger spinner (its default is a fixed ~20px box that looks tiny here). */ +/* 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 { - transform: scale(2.5); +.carma-panorama-preview .pnlm-lbox, +.carma-panorama-preview .pnlm-load-box { + display: none !important; } From 22c3e40757187d28ef1b28846c8ed4c023268f97 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 11:39:20 +0200 Subject: [PATCH 09/61] feat(geoportal): support geojson panorama source and preload neighbours Make the panorama tour work against a fully client-side geojson style (e.g. oelberg_panorama/styleG.json) in addition to the vector-tile style: - resolvePanoSource now falls back to a geojson source (which has no source-layer) when no vector source is present, so hotspots, the selection highlight and tour navigation resolve correctly for both style types. Feature ids are used as-is: vector tiles bake id=fid and the geojson dataset carries id=fid natively, so the style must not set generateId (which would override the data's ids). - Preload the reachable neighbours' panorama images on selection (Image + decode, held in a ref so the in-flight downloads are not GC'd) so a tour hop is near-instant. The hotspot memo now also surfaces the kept neighbours' file names; each neighbour URL is derived from the current panorama URL by swapping the file name. - Guard the selection-arrow cleanup against an already-removed layer: getLayer/setLayoutProperty throw once the layer's map is torn down, which crashed when removing a layer while a panorama was selected. --- .../feature-info/FeatureInfoBox.tsx | 126 +++++++++++++----- 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 4071c20f67..1da00064c1 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -99,11 +99,13 @@ const normalizeDeg = (deg: number): number => { const angularDiffDeg = (a: number, b: number): number => Math.abs(normalizeDeg(a - b)); -// The single vector source + source-layer of a panorama style (e.g. the -// oelberg_panorama style has exactly one vector source). +// 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 => { +): { sourceId: string; sourceLayer?: string } | null => { try { const style = map?.getStyle?.() as | { @@ -112,15 +114,22 @@ const resolvePanoSource = ( } | undefined; const sources = style?.sources ?? {}; - const sourceId = Object.keys(sources).find( + // Prefer a vector source with its source-layer. + const vectorId = Object.keys(sources).find( (id) => sources[id]?.type === "vector" ); - if (!sourceId) return null; - const sourceLayer = style?.layers?.find( - (l) => l.source === sourceId && l["source-layer"] - )?.["source-layer"]; - if (!sourceLayer) return null; - return { sourceId, sourceLayer }; + 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; } @@ -369,19 +378,26 @@ const FeatureInfoBox = ({ useEffect(() => { if (!selectedLayerMap) return; return () => { - 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, - ] - ); + // 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) { @@ -413,6 +429,9 @@ const FeatureInfoBox = ({ 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, @@ -466,15 +485,20 @@ const FeatureInfoBox = ({ [selectedLayerMap, selectedFeature, layers, dispatch] ); - // Build navigation hotspots from the panos surrounding the selected one. - const panoramaHotspots = useMemo(() => { + // 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 []; + return empty; } const cx = Number(props.proj_x); const cy = Number(props.proj_y); @@ -482,10 +506,10 @@ const FeatureInfoBox = ({ const cHeading = Number(props.heading); const cFid = props.fid; if (Number.isNaN(cx) || Number.isNaN(cy) || Number.isNaN(cHeading)) { - return []; + return empty; } const src = resolvePanoSource(selectedLayerMap); - if (!src) return []; + if (!src) return empty; let all: Array<{ properties?: Record }> = []; try { @@ -493,7 +517,7 @@ const FeatureInfoBox = ({ sourceLayer: src.sourceLayer, }); } catch { - return []; + return empty; } const seen = new Set(); @@ -502,6 +526,7 @@ const FeatureInfoBox = ({ dist: number; dz: number; bearing: number; + fileName?: string; }> = []; for (const f of all) { const p = f?.properties; @@ -516,11 +541,13 @@ const FeatureInfoBox = ({ 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; candidates.push({ fid: p.fid, dist, dz: Number.isNaN(dz) ? 0 : dz, bearing, + fileName: typeof rawFileName === "string" ? rawFileName : undefined, }); } @@ -540,7 +567,7 @@ const FeatureInfoBox = ({ kept.push(c); } - return kept.map((c) => { + const hotspots = kept.map((c) => { // Inverse of the arrow calibration: absolute bearing = heading + yaw. const yaw = normalizeDeg( (c.bearing - cHeading - PANORAMA_YAW_OFFSET) / PANORAMA_YAW_SIGN @@ -560,8 +587,47 @@ const FeatureInfoBox = ({ clickHandlerFunc: () => handlePanoramaNavigate(c.fid), }; }); + + const neighbourFileNames = kept + .map((c) => c.fileName) + .filter((n): n is string => !!n); + + 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]); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; From 35964fabbe234863a8af2bcce0679354b440cda4 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 11:47:34 +0200 Subject: [PATCH 10/61] fix(portals): stop panorama nav hotspots wobbling while panning Pannellum rewrites each hotspot host element's inline transform every frame to reposition it as the user pans. The hotspot carried its own transition: transform, so it animated toward each new position instead of snapping there, making the arrows swim/lag and "settle" when panning stopped. Move the disc and its hover-scale onto a ::before pseudo-element (which pannellum never touches) and leave the host element transform-free, so it stays locked to its bearing while hover still animates smoothly. --- .../src/lib/components/PanoramaLightBox.css | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index 14b531b00f..a4034e8acc 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -1,17 +1,30 @@ /* 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. */ + 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); - cursor: pointer; transition: transform 0.12s ease, background 0.12s ease; } @@ -28,7 +41,7 @@ transform: translate(-50%, -30%) rotate(45deg); } -.carma-pano-nav-hotspot:hover { +.carma-pano-nav-hotspot:hover::before { transform: scale(1.15); background: rgba(255, 127, 0, 0.8); } From 29e2cd29dbe8af99fa14ae47c0229a1ccfec2859 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 12:02:35 +0200 Subject: [PATCH 11/61] feat(portals): aim panorama nav arrows at their neighbour as you pan Each navigation hotspot now points toward the panorama it leads to, Street-View style, instead of always pointing up. pannellum's createTooltipFunc gives a handle to each rendered hotspot element; the per-frame yaw poll sets a --pano-hotspot-dir CSS variable on each to the on-screen angle from view-centre to that neighbour (its panorama yaw minus the current view yaw), and the chevron is rotated by it. A neighbour you face points up; one off to the side points that way. --- .../src/lib/components/PanoramaLightBox.css | 7 +++- .../src/lib/components/PanoramaLightBox.tsx | 38 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index a4034e8acc..b40295600b 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -35,10 +35,13 @@ left: 50%; width: 16px; height: 16px; - /* up-pointing chevron drawn from two borders, rotated 45° */ + 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: translate(-50%, -30%) rotate(45deg); + transform-origin: center; + transform: rotate(calc(45deg + var(--pano-hotspot-dir, 0deg))); } .carma-pano-nav-hotspot:hover::before { diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 63c9aa0e9f..e459869d52 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -49,6 +49,14 @@ interface PanoramaLightBoxProps { initialYaw?: number; } +// 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; +}; + /** * Fullscreen overlay that renders a 360° panorama with pannellum. * @@ -79,6 +87,24 @@ export const PanoramaLightBox = ({ const pannellum = window.pannellum; if (!el || !pannellum) return; + const initialYaw = initialYawRef.current ?? 0; + + // Each rendered hotspot's div plus the panorama yaw of its neighbour. The + // yaw poll below rotates each arrow to keep pointing at its neighbour as the + // user looks around (Street-View style). pannellum calls createTooltipFunc + // with the hotspot element after applying its cssClass, which is our handle. + const hotspotEls: Array<{ el: HTMLElement; yaw: number }> = []; + const hotSpots = (hotspotsRef.current ?? []).map((h) => ({ + ...h, + createTooltipFunc: (div: HTMLElement) => { + div.style.setProperty( + "--pano-hotspot-dir", + `${normalizeDeg(h.yaw - initialYaw)}deg` + ); + hotspotEls.push({ el: div, yaw: h.yaw }); + }, + })); + const viewer = pannellum.viewer(el, { type: "equirectangular", panorama: src, @@ -87,8 +113,8 @@ export const PanoramaLightBox = ({ // (compass/gyroscope) control stays available; only hide zoom + fullscreen. showZoomCtrl: false, showFullscreenCtrl: false, - hotSpots: hotspotsRef.current ?? [], - yaw: initialYawRef.current ?? 0, + hotSpots, + yaw: initialYaw, }); // Pannellum has no continuous "view changed" event, so poll the yaw on each @@ -103,6 +129,14 @@ export const PanoramaLightBox = ({ if (yaw !== lastYaw) { lastYaw = yaw; onYawChangeRef.current?.(yaw); + // Re-aim each hotspot arrow: the on-screen angle from view-centre to + // the neighbour is its (panorama yaw − current view yaw). + for (const hs of hotspotEls) { + hs.el.style.setProperty( + "--pano-hotspot-dir", + `${normalizeDeg(hs.yaw - yaw)}deg` + ); + } } } raf = requestAnimationFrame(tick); From c759415f2190c34aca0e686d1399a7c62539bcc1 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 15:20:45 +0200 Subject: [PATCH 12/61] feat(portals): support multiresolution panoramas in the viewers Load a pannellum multiresolution panorama (tiled cube faces) when the selected feature carries a panoramaMultiResConfig URL, falling back to the equirectangular jpg otherwise. Multires paints a low-res cube immediately and refines, so first view is far faster than pulling a single large equirectangular image. - new resolvePanoramaScene() fetches the generated config.json and gives it a basePath (its own directory) so pannellum can resolve the relative tile paths, which the generated config omits; on any failure (e.g. a pano not yet tiled -> 404) it falls back to the equirectangular image. - PanoramaPreview and PanoramaLightBox take a multiResConfigUrl prop and resolve the scene asynchronously (guarded against teardown mid-fetch). - FeatureInfoBox passes properties.panoramaMultiResConfig to both. --- .../feature-info/FeatureInfoBox.tsx | 2 + .../src/lib/components/PanoramaLightBox.tsx | 87 +++++++++++-------- .../src/lib/components/PanoramaPreview.tsx | 82 +++++++++-------- .../src/lib/components/panoramaScene.ts | 43 +++++++++ 4 files changed, 140 insertions(+), 74 deletions(-) create mode 100644 libraries/appframeworks/portals/src/lib/components/panoramaScene.ts diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 1da00064c1..032954fd3b 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -682,6 +682,7 @@ const FeatureInfoBox = ({ setOpenPanorama(true)} // Only the active viewer drives the map arrow; yield to the // fullscreen lightbox while it is open. @@ -769,6 +770,7 @@ const FeatureInfoBox = ({ {openPanorama && selectedFeature.properties.panorama && ( setOpenPanorama(false)} onYawChange={handlePanoramaYaw} diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index e459869d52..c75c8beb97 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; +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"; @@ -37,8 +39,10 @@ export interface PanoramaHotspot { } interface PanoramaLightBoxProps { - /** URL of an equirectangular panorama image. */ + /** URL of an equirectangular panorama image (fallback when no multires). */ src: string; + /** URL of a pannellum multiresolution config.json; preferred over `src`. */ + multiResConfigUrl?: string; title?: string; onClose: () => void; /** Called with the viewer's current yaw (deg) as the user looks around. */ @@ -66,6 +70,7 @@ const normalizeDeg = (deg: number): number => { */ export const PanoramaLightBox = ({ src, + multiResConfigUrl, onClose, onYawChange, hotspots, @@ -105,49 +110,59 @@ export const PanoramaLightBox = ({ }, })); - const viewer = pannellum.viewer(el, { - type: "equirectangular", - panorama: src, - autoLoad: true, - // Keep the controls container so the mobile device-orientation - // (compass/gyroscope) control stays available; only hide zoom + fullscreen. - showZoomCtrl: false, - showFullscreenCtrl: false, - hotSpots, - yaw: initialYaw, - }); - - // Pannellum has no continuous "view changed" event, so poll the yaw on each - // frame and report changes (lets the map's selection arrow follow the view). + // 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 viewer: PannellumViewer | undefined; let raf = 0; - 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); - // Re-aim each hotspot arrow: the on-screen angle from view-centre to - // the neighbour is its (panorama yaw − current view yaw). - for (const hs of hotspotEls) { - hs.el.style.setProperty( - "--pano-hotspot-dir", - `${normalizeDeg(hs.yaw - yaw)}deg` - ); + + resolvePanoramaScene(src, multiResConfigUrl).then((scene) => { + if (cancelled) return; + const v = pannellum.viewer(el, { + ...scene, + autoLoad: true, + // Keep the controls container so the mobile device-orientation + // (compass/gyroscope) control stays available; only hide zoom + + // fullscreen. + showZoomCtrl: false, + showFullscreenCtrl: false, + hotSpots, + yaw: initialYaw, + }); + viewer = v; + + // 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 (!v.isLoaded || v.isLoaded()) { + const yaw = v.getYaw(); + if (yaw !== lastYaw) { + lastYaw = yaw; + onYawChangeRef.current?.(yaw); + // Re-aim each hotspot arrow: the on-screen angle from view-centre to + // the neighbour is its (panorama yaw − current view yaw). + for (const hs of hotspotEls) { + hs.el.style.setProperty( + "--pano-hotspot-dir", + `${normalizeDeg(hs.yaw - yaw)}deg` + ); + } } } - } + raf = requestAnimationFrame(tick); + }; raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); + }); return () => { + cancelled = true; cancelAnimationFrame(raf); - viewer.destroy(); + viewer?.destroy(); }; - }, [src]); + }, [src, multiResConfigUrl]); useEffect(() => { const onKey = (e: KeyboardEvent) => { diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx index b9310e379d..cde74e1f98 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaPreview.tsx @@ -3,10 +3,13 @@ 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. */ + /** 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. */ @@ -35,17 +38,13 @@ const PREVIEW_HEIGHT = 160; */ export const PanoramaPreview = ({ src, + multiResConfigUrl, onExpand, onYawChange, initialYaw, }: PanoramaPreviewProps) => { const containerRef = useRef(null); - const viewerRef = useRef<{ - destroy: () => void; - resize: () => void; - getYaw: () => number; - isLoaded: () => boolean; - } | null>(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); @@ -66,45 +65,52 @@ export const PanoramaPreview = ({ const pannellum = window.pannellum; if (!el || !pannellum) return; - const viewer = pannellum.viewer(el, { - type: "equirectangular", - panorama: src, - 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 the view). + // 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; - 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); + + 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); - }; - raf = requestAnimationFrame(tick); + }); return () => { + cancelled = true; cancelAnimationFrame(raf); - viewer.destroy(); + viewerRef.current?.destroy(); viewerRef.current = null; }; - }, [src]); + }, [src, multiResConfigUrl]); // Refit the already-loaded viewer to the new container width (no reload). useEffect(() => { 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..8fe2dfb204 --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts @@ -0,0 +1,43 @@ +// Shared pannellum viewer types + scene resolution for the panorama viewers +// (PanoramaPreview / PanoramaLightBox). + +export type PannellumViewer = { + destroy: () => void; + resize: () => void; + getYaw: () => number; + isLoaded: () => boolean; +}; + +/** + * 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 }; +}; From a1e63e87293df2dd35397bea648c7b6da590ce98 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 15:52:31 +0200 Subject: [PATCH 13/61] perf(portals): reuse the panorama viewer across tour hops via loadScene The lightbox used to destroy and recreate the whole pannellum WebGL viewer on every tour hop, paying context teardown/setup plus a full decode each time. Init the viewer once in tour mode (default + scenes) and, on a hop, addScene + loadScene to crossfade to the new pano, reusing the WebGL context; the fade also masks the decode. - The viewer is created for the first scene and destroyed only on unmount (separate []-effect); the src/multiResConfigUrl effect now either creates it (first scene) or addScene+loadScene (subsequent). - The yaw poll runs for the viewer's whole life across scene loads; the current scene's hotspot elements are swapped into a ref each hop so arrow re-aiming tracks whatever is on screen. - Scenes are still built by resolvePanoramaScene, so multires and the jpg fallback work per scene. - PannellumViewer gains addScene/loadScene; window.pannellum uses it. --- .../src/lib/components/PanoramaLightBox.tsx | 110 +++++++++++------- .../src/lib/components/panoramaScene.ts | 9 ++ 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index c75c8beb97..f6eb572153 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -15,12 +15,7 @@ declare global { viewer: ( container: HTMLElement | string, config: Record - ) => { - destroy: () => void; - resize: () => void; - getYaw: () => number; - isLoaded: () => boolean; - }; + ) => PannellumViewer; }; } } @@ -77,9 +72,9 @@ export const PanoramaLightBox = ({ initialYaw, }: PanoramaLightBoxProps) => { const containerRef = useRef(null); - // Held in refs so changing their identity never re-inits the viewer. The - // viewer re-inits on src change (a tour hop), which is exactly when fresh - // hotspots / initial yaw apply, so reading the ref at init time is enough. + // 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); @@ -87,6 +82,19 @@ export const PanoramaLightBox = ({ const initialYawRef = useRef(initialYaw); initialYawRef.current = initialYaw; + // 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); + const hotspotElsRef = useRef>([]); + + // 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; @@ -94,10 +102,9 @@ export const PanoramaLightBox = ({ const initialYaw = initialYawRef.current ?? 0; - // Each rendered hotspot's div plus the panorama yaw of its neighbour. The - // yaw poll below rotates each arrow to keep pointing at its neighbour as the - // user looks around (Street-View style). pannellum calls createTooltipFunc - // with the hotspot element after applying its cssClass, which is our handle. + // Build this scene's hotspots. pannellum calls createTooltipFunc with each + // hotspot element after applying its cssClass, which is our handle: stamp + // the initial aim and collect the elements so the yaw poll can re-aim them. const hotspotEls: Array<{ el: HTMLElement; yaw: number }> = []; const hotSpots = (hotspotsRef.current ?? []).map((h) => ({ ...h, @@ -110,41 +117,57 @@ export const PanoramaLightBox = ({ }, })); - // Scene resolution is async (a multires config is fetched), so guard against - // the effect being cleaned up before the viewer is created. + // Scene resolution is async (a multires config is fetched); guard against + // the effect being cleaned up (hop / unmount) before it resolves. let cancelled = false; - let viewer: PannellumViewer | undefined; - let raf = 0; resolvePanoramaScene(src, multiResConfigUrl).then((scene) => { if (cancelled) return; - const v = pannellum.viewer(el, { - ...scene, - autoLoad: true, - // Keep the controls container so the mobile device-orientation - // (compass/gyroscope) control stays available; only hide zoom + - // fullscreen. - showZoomCtrl: false, - showFullscreenCtrl: false, - hotSpots, - yaw: initialYaw, + 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 (open facing + // forward, level). loadScene no-ops until the first scene has loaded, + // which it has by the time the user can click a hotspot. + viewerRef.current.addScene(sceneId, sceneConfig); + viewerRef.current.loadScene(sceneId, 0, initialYaw); + 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, + }, + scenes: { [sceneId]: sceneConfig }, }); - viewer = v; - // Pannellum has no continuous "view changed" event, so poll the yaw on - // each frame and report changes (lets the map's selection arrow follow). + // Pannellum has no continuous "view changed" event, so poll the yaw each + // frame: report it (the map arrow follows) and re-aim the current scene's + // hotspots. 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 (!v.isLoaded || v.isLoaded()) { - const yaw = v.getYaw(); + if (viewer && (!viewer.isLoaded || viewer.isLoaded())) { + const yaw = viewer.getYaw(); if (yaw !== lastYaw) { lastYaw = yaw; onYawChangeRef.current?.(yaw); - // Re-aim each hotspot arrow: the on-screen angle from view-centre to - // the neighbour is its (panorama yaw − current view yaw). - for (const hs of hotspotEls) { + // On-screen angle from view-centre to each neighbour is its + // (panorama yaw − current view yaw). + for (const hs of hotspotElsRef.current) { hs.el.style.setProperty( "--pano-hotspot-dir", `${normalizeDeg(hs.yaw - yaw)}deg` @@ -152,18 +175,27 @@ export const PanoramaLightBox = ({ } } } - raf = requestAnimationFrame(tick); + rafRef.current = requestAnimationFrame(tick); }; - raf = 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; - cancelAnimationFrame(raf); - viewer?.destroy(); }; }, [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") onClose(); diff --git a/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts index 8fe2dfb204..a93d5c14c6 100644 --- a/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts +++ b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts @@ -6,6 +6,15 @@ export type PannellumViewer = { resize: () => void; getYaw: () => 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; }; /** From c599cf917944b66910b956323a65baaed7d90dec Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 16:05:20 +0200 Subject: [PATCH 14/61] feat(geoportal): arrow-key tour navigation in the panorama lightbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free pannellum's arrow keys in the fullscreen viewer (disableKeyboardCtrl) and bind Up/Down to a tour hop: Up jumps to the neighbour nearest the current view direction, Down to the one behind it. - PanoramaLightBox takes onNext/onPrevious and fires them on ArrowUp/ ArrowDown (preventing page scroll); dragging still pans. - FeatureInfoBox captures the live view yaw from the viewer poll and picks the neighbour whose bearing is nearest the view (Up) or its opposite (Down), reusing the existing hotspot hop (loadScene crossfade). Only fires within a 90° hemisphere of the target, so Up never jumps to a pano clearly behind you. --- .../feature-info/FeatureInfoBox.tsx | 42 +++++++++++++++++++ .../src/lib/components/PanoramaLightBox.tsx | 23 +++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 032954fd3b..6941ad0802 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -88,6 +88,10 @@ const PANORAMA_MIN_HOTSPOT_SEPARATION_DEG = 22; 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 => { @@ -350,12 +354,17 @@ const FeatureInfoBox = ({ [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" || @@ -628,6 +637,37 @@ const FeatureInfoBox = ({ 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] + ); + if (loadingFeatureInfo && shouldRenderLoadingInfobox) return ; @@ -774,6 +814,8 @@ const FeatureInfoBox = ({ title={selectedFeature.properties.title} onClose={() => setOpenPanorama(false)} onYawChange={handlePanoramaYaw} + onNext={handlePanoramaNext} + onPrevious={handlePanoramaPrevious} hotspots={panoramaHotspots} initialYaw={PANORAMA_INITIAL_YAW} /> diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index f6eb572153..1782d56ba1 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -42,6 +42,10 @@ interface PanoramaLightBoxProps { onClose: () => void; /** 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. */ @@ -68,6 +72,8 @@ export const PanoramaLightBox = ({ multiResConfigUrl, onClose, onYawChange, + onNext, + onPrevious, hotspots, initialYaw, }: PanoramaLightBoxProps) => { @@ -81,6 +87,10 @@ export const PanoramaLightBox = ({ hotspotsRef.current = hotspots; const initialYawRef = useRef(initialYaw); initialYawRef.current = initialYaw; + const onNextRef = useRef(onNext); + onNextRef.current = onNext; + const onPreviousRef = useRef(onPrevious); + onPreviousRef.current = onPrevious; // 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 @@ -148,6 +158,9 @@ export const PanoramaLightBox = ({ // 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 }, }); @@ -198,7 +211,15 @@ export const PanoramaLightBox = ({ useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + onClose(); + } 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); From b7f6135b866a422f7306dcba3c760d276a22bfb8 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Tue, 23 Jun 2026 18:26:09 +0200 Subject: [PATCH 15/61] feat(geoportal): street-view ground crosses for panorama navigation - discover neighbours from the baked `nb` tile property (viewport-independent), falling back to querySourceFeatures where the tiles do not carry it yet - render neighbours as faint, ground-pinned rings that recede down the street (per-cross ground tilt from pitch + distance scale), replacing the chevrons - click anywhere jumps to the nearest cross; hover highlights that same cross; capture-phase listeners so the click beats pannellum's canvas drag handler - keep arrow-key tour navigation; dev [PANORAMA] neighbour-source log remains for now (strip before merge) --- .../feature-info/FeatureInfoBox.tsx | 213 ++++++++++++------ .../src/lib/components/PanoramaLightBox.css | 47 ++++ .../src/lib/components/PanoramaLightBox.tsx | 145 +++++++++--- 3 files changed, 302 insertions(+), 103 deletions(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 6941ad0802..c23cca04f5 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -76,13 +76,16 @@ const PANORAMA_YAW_OFFSET = 0; // correctly and stays in sync with what is on screen. const PANORAMA_INITIAL_YAW = 180; -// Tour navigation hotspots: pick surrounding panos from the vector source and -// render a clickable arrow at each one's bearing. -const PANORAMA_NEIGHBOR_RADIUS_M = 12; -const PANORAMA_MAX_HOTSPOTS = 6; -// Drop a candidate whose bearing is within this of an already-kept one (keeps -// the nearer); avoids a cluster of overlapping arrows down a straight street. -const PANORAMA_MIN_HOTSPOT_SEPARATION_DEG = 22; +// 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; @@ -103,6 +106,31 @@ const normalizeDeg = (deg: number): number => { 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)]. +type BakedNeighbour = [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 / @@ -509,75 +537,91 @@ const FeatureInfoBox = ({ ) { return empty; } - const cx = Number(props.proj_x); - const cy = Number(props.proj_y); - const cz = Number(props.proj_z); const cHeading = Number(props.heading); const cFid = props.fid; - if (Number.isNaN(cx) || Number.isNaN(cy) || Number.isNaN(cHeading)) { - return empty; - } - const src = resolvePanoSource(selectedLayerMap); - if (!src) return empty; + if (!Number.isFinite(cHeading)) return empty; - let all: Array<{ properties?: Record }> = []; - try { - all = selectedLayerMap.querySourceFeatures(src.sourceId, { - sourceLayer: src.sourceLayer, - }); - } catch { - return empty; - } - - const seen = new Set(); - const candidates: Array<{ + type Candidate = { fid: number; dist: number; dz: number; bearing: number; fileName?: string; - }> = []; - 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; - candidates.push({ - fid: p.fid, - dist, - dz: Number.isNaN(dz) ? 0 : dz, - bearing, - fileName: typeof rawFileName === "string" ? rawFileName : undefined, - }); - } + }; + 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]); + 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), + }); + } + } 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; - candidates.sort((a, b) => a.dist - b.dist); - const kept: typeof candidates = []; - for (const c of candidates) { - if (kept.length >= PANORAMA_MAX_HOTSPOTS) break; - if ( - kept.some( - (k) => - angularDiffDeg(k.bearing, c.bearing) < - PANORAMA_MIN_HOTSPOT_SEPARATION_DEG - ) - ) { - continue; + 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; + candidates.push({ + fid: p.fid, + dist, + dz: Number.isNaN(dz) ? 0 : dz, + bearing, + fileName: typeof rawFileName === "string" ? rawFileName : undefined, + }); } - kept.push(c); } - const hotspots = kept.map((c) => { - // Inverse of the arrow calibration: absolute bearing = heading + yaw. + // 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 ); @@ -588,19 +632,46 @@ const FeatureInfoBox = ({ (Math.atan2(c.dz - PANORAMA_CAMERA_HEIGHT_M, c.dist) * 180) / Math.PI ) ); - return { - id: `pano-nav-${c.fid}`, - pitch, - yaw, - cssClass: "carma-pano-nav-hotspot", - clickHandlerFunc: () => handlePanoramaNavigate(c.fid), - }; + 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); + } + + const hotspots = kept.map((c) => ({ + id: `pano-nav-${c.fid}`, + pitch: c.pitch, + yaw: c.yaw, + 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 and how many targets + // survive declutter. Strip before merge. + console.log("[PANORAMA] neighbours", { + source: baked ? "baked-nb" : "fallback-querySourceFeatures", + found: candidates.length, + shown: kept.length, + }); + return { hotspots, neighbourFileNames }; }, [selectedFeature, selectedLayerMap, handlePanoramaNavigate]); diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index b40295600b..988e70345b 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -49,6 +49,53 @@ 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) + ); +} + /* 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. */ diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 1782d56ba1..a43e4c0578 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -31,6 +31,8 @@ export interface PanoramaHotspot { yaw: number; cssClass?: string; clickHandlerFunc?: () => void; + /** Horizontal distance to the neighbour (m); drives the ground-decal scale. */ + distanceM?: number; } interface PanoramaLightBoxProps { @@ -52,14 +54,6 @@ interface PanoramaLightBoxProps { initialYaw?: number; } -// 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; -}; - /** * Fullscreen overlay that renders a 360° panorama with pannellum. * @@ -99,7 +93,9 @@ export const PanoramaLightBox = ({ const viewerRef = useRef(null); const rafRef = useRef(0); const sceneSeqRef = useRef(0); - const hotspotElsRef = useRef>([]); + const hotspotElsRef = useRef< + Array<{ el: HTMLElement; yaw: 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 @@ -113,19 +109,35 @@ export const PanoramaLightBox = ({ 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: stamp - // the initial aim and collect the elements so the yaw poll can re-aim them. - const hotspotEls: Array<{ el: HTMLElement; yaw: number }> = []; - const hotSpots = (hotspotsRef.current ?? []).map((h) => ({ - ...h, - createTooltipFunc: (div: HTMLElement) => { - div.style.setProperty( - "--pano-hotspot-dir", - `${normalizeDeg(h.yaw - initialYaw)}deg` - ); - hotspotEls.push({ el: div, yaw: h.yaw }); - }, - })); + // 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; + 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, navigate: clickHandlerFunc }); + }, + }; + }); // Scene resolution is async (a multires config is fetched); guard against // the effect being cleaned up (hop / unmount) before it resolves. @@ -166,8 +178,8 @@ export const PanoramaLightBox = ({ }); // Pannellum has no continuous "view changed" event, so poll the yaw each - // frame: report it (the map arrow follows) and re-aim the current scene's - // hotspots. The poll runs for the viewer's whole life, across scene loads. + // 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; @@ -178,14 +190,6 @@ export const PanoramaLightBox = ({ if (yaw !== lastYaw) { lastYaw = yaw; onYawChangeRef.current?.(yaw); - // On-screen angle from view-centre to each neighbour is its - // (panorama yaw − current view yaw). - for (const hs of hotspotElsRef.current) { - hs.el.style.setProperty( - "--pano-hotspot-dir", - `${normalizeDeg(hs.yaw - yaw)}deg` - ); - } } } rafRef.current = requestAnimationFrame(tick); @@ -225,6 +229,83 @@ export const PanoramaLightBox = ({ return () => window.removeEventListener("keydown", onKey); }, [onClose]); + // 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 }; + + // 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) => { + const bounds = el.getBoundingClientRect(); + let best: { el: HTMLElement; navigate?: () => void; 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 = { el: entry.el, navigate: entry.navigate, dist }; + } + } + return best; + }; + + const highlight = (target: HTMLElement | null) => { + for (const entry of hotspotElsRef.current) { + entry.el.classList.toggle("is-hover", entry.el === target); + } + }; + + const onMove = (e: MouseEvent) => { + const best = nearestTo(e.clientX, e.clientY); + highlight(best ? best.el : null); + }; + const onLeave = () => highlight(null); + const onDown = (e: MouseEvent) => { + down.x = e.clientX; + down.y = e.clientY; + }; + const onClick = (e: MouseEvent) => { + // 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); + best?.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 createPortal(
Date: Wed, 24 Jun 2026 11:02:25 +0200 Subject: [PATCH 16/61] feat(geoportal): cursor arrow that points along the nearest pano's heading - add a ground-pinned cursor arrow to the panorama lightbox: it follows the mouse and aims along the nearest cross's street (its heading), using a pinhole world->screen projection of the current view so it reads as lying on the road - read each neighbour's heading from the baked `nb` (5th value) or, when the deployed tiles predate that field, from the live map source, so the aim works before a redeploy; resolve the heading's 180deg ambiguity toward the target - add getPitch/getHfov/mouseEventToCoords to the pannellum viewer type Dev [PANORAMA] logs still present; to be stripped before merge. --- .../feature-info/FeatureInfoBox.tsx | 88 ++++++-- .../src/lib/components/PanoramaLightBox.css | 26 +++ .../src/lib/components/PanoramaLightBox.tsx | 192 +++++++++++++++++- .../src/lib/components/panoramaScene.ts | 4 + 4 files changed, 287 insertions(+), 23 deletions(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index c23cca04f5..2550dda8ef 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -115,8 +115,10 @@ const fidToFileName = (fid: number): string => { return `pano_${s.slice(0, 6)}_${s.slice(6)}`; }; -// A single baked neighbour: [fid, bearing(deg, 0=N CW), distance(m), dz(m)]. -type BakedNeighbour = [number, number, number, number]; +// 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 @@ -547,6 +549,8 @@ const FeatureInfoBox = ({ dz: number; bearing: number; fileName?: string; + /** The neighbour's own heading (deg, 0=N CW): its street orientation. */ + headingDeg?: number; }; const candidates: Candidate[] = []; @@ -564,6 +568,7 @@ const FeatureInfoBox = ({ 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; @@ -573,6 +578,7 @@ const FeatureInfoBox = ({ dz: Number.isFinite(dz) ? dz : 0, bearing: Number.isFinite(bearing) ? bearing : 0, fileName: fidToFileName(fid), + headingDeg: Number.isFinite(headingDeg) ? headingDeg : undefined, }); } } else { @@ -607,12 +613,14 @@ const FeatureInfoBox = ({ 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, }); } } @@ -651,25 +659,81 @@ const FeatureInfoBox = ({ if (!clashes) kept.push(c); } - const hotspots = kept.map((c) => ({ - id: `pano-nav-${c.fid}`, - pitch: c.pitch, - yaw: c.yaw, - distanceM: c.dist, - cssClass: "carma-pano-cross-hotspot", - clickHandlerFunc: () => handlePanoramaNavigate(c.fid), - })); + // 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 and how many targets - // survive declutter. Strip before merge. + // [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 }; diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index 988e70345b..9f7c3c8f98 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -96,6 +96,32 @@ ); } +/* 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: 46px; + height: 46px; + margin-left: -23px; + margin-top: -23px; + 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)); +} + /* 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. */ diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index a43e4c0578..8ed13b5f0f 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -33,6 +33,12 @@ export interface PanoramaHotspot { 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; } interface PanoramaLightBoxProps { @@ -54,6 +60,59 @@ interface PanoramaLightBoxProps { initialYaw?: number; } +// 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), + ]; +}; + /** * Fullscreen overlay that renders a 360° panorama with pannellum. * @@ -72,6 +131,8 @@ export const PanoramaLightBox = ({ initialYaw, }: PanoramaLightBoxProps) => { const containerRef = useRef(null); + // The Street-View-style arrow that follows the cursor on the ground. + const cursorArrowRef = 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. @@ -94,7 +155,13 @@ export const PanoramaLightBox = ({ const rafRef = useRef(0); const sceneSeqRef = useRef(0); const hotspotElsRef = useRef< - Array<{ el: HTMLElement; yaw: number; navigate?: () => void }> + 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 @@ -118,6 +185,8 @@ export const PanoramaLightBox = ({ const hotspotEls: Array<{ el: HTMLElement; yaw: number; + pitch: number; + streetYaw?: number; navigate?: () => void; }> = []; const hotSpots = (hotspotsRef.current ?? []).map((h) => { @@ -134,7 +203,13 @@ export const PanoramaLightBox = ({ 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, navigate: clickHandlerFunc }); + hotspotEls.push({ + el: div, + yaw: h.yaw, + pitch: h.pitch, + streetYaw: h.streetYaw, + navigate: clickHandlerFunc, + }); }, }; }); @@ -245,13 +320,14 @@ export const PanoramaLightBox = ({ const DRAG_PX = 6; // moved more than this since mousedown => a pan, not a click const down = { x: 0, y: 0 }; + 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) => { + const nearestTo = (x: number, y: number): { entry: Entry; dist: number } | null => { const bounds = el.getBoundingClientRect(); - let best: { el: HTMLElement; navigate?: () => void; dist: number } | null = - null; + 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; @@ -265,9 +341,7 @@ export const PanoramaLightBox = ({ continue; } const dist = Math.hypot(x - cx, y - cy); - if (!best || dist < best.dist) { - best = { el: entry.el, navigate: entry.navigate, dist }; - } + if (!best || dist < best.dist) best = { entry, dist }; } return best; }; @@ -278,11 +352,91 @@ export const PanoramaLightBox = ({ } }; + 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 = "1"; + }; + const onMove = (e: MouseEvent) => { const best = nearestTo(e.clientX, e.clientY); - highlight(best ? best.el : null); + highlight(best ? best.entry.el : null); + updateArrow(e, best ? best.entry : null); + }; + const onLeave = () => { + highlight(null); + hideArrow(); }; - const onLeave = () => highlight(null); const onDown = (e: MouseEvent) => { down.x = e.clientX; down.y = e.clientY; @@ -291,7 +445,7 @@ export const PanoramaLightBox = ({ // 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); - best?.navigate?.(); + best?.entry.navigate?.(); }; el.addEventListener("mousemove", onMove, true); @@ -341,6 +495,22 @@ export const PanoramaLightBox = ({ className="carma-panorama-lightbox__viewer" style={{ width: "100%", height: "100%" }} /> +
, document.body ); diff --git a/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts index a93d5c14c6..4ef95d92ea 100644 --- a/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts +++ b/libraries/appframeworks/portals/src/lib/components/panoramaScene.ts @@ -5,6 +5,8 @@ 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; @@ -15,6 +17,8 @@ export type PannellumViewer = { yaw?: number, hfov?: number ) => void; + /** Sphere coords [pitch, yaw] (deg) under a mouse event, or undefined. */ + mouseEventToCoords?: (event: MouseEvent) => [number, number] | undefined; }; /** From f15121d6af4aae2b28bc659d3e4a541eb4b110be Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Wed, 24 Jun 2026 17:18:08 +0200 Subject: [PATCH 17/61] feat(geoportal): recenter the map on each panorama tour hop When navigating between panoramas in the fullscreen viewer, pan the Leaflet base map to the pano being jumped to (keeping the current zoom), so closing fullscreen leaves the just-visited point centered in the map. --- .../components/feature-info/FeatureInfoBox.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 2550dda8ef..e065a91130 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -517,11 +517,24 @@ const FeatureInfoBox = ({ 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] + [selectedLayerMap, selectedFeature, layers, dispatch, routedMapRef] ); // Build navigation hotspots from the panos surrounding the selected one, and From 6cb8f0911b383b488701e125b73a1f0a091bfdb9 Mon Sep 17 00:00:00 2001 From: Thorsten Hell Date: Thu, 25 Jun 2026 19:31:20 +0200 Subject: [PATCH 18/61] feat(portals): add a back-navigation cursor zone to the panorama viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panorama behind the current view was only reachable with the ArrowDown key or by panning 180°. Add an invisible hit zone pinned to the bottom centre of the lightbox (over the survey vehicle) that, on click, calls onPrevious (same as ArrowDown). The zone is not drawn; over it the native cursor is replaced by a downward chevron drawn as a DOM element that follows the pointer, matching the ground cursor arrow's look (an SVG image cursor renders blank in Chrome). --- .../src/lib/components/PanoramaLightBox.css | 44 +++++++++++++++ .../src/lib/components/PanoramaLightBox.tsx | 56 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css index 9f7c3c8f98..12bf1126dd 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.css @@ -122,6 +122,50 @@ 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: 46px; + height: 46px; + margin-left: -23px; + margin-top: -23px; + 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. */ diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 8ed13b5f0f..023bcfa376 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -133,6 +133,10 @@ export const PanoramaLightBox = ({ 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. @@ -495,6 +499,58 @@ export const PanoramaLightBox = ({ className="carma-panorama-lightbox__viewer" style={{ width: "100%", height: "100%" }} /> + {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). + <> +
} locatorControl={false} fullScreenControl={false} From bb1e516a5afb7ac8724251e66bd64a029c60728d Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 1 Jul 2026 15:08:15 +0200 Subject: [PATCH 55/61] baumbewirtschaftung: disable cismap photo lightbox --- apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx b/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx index 2a0fd692c0..1e411e3d4f 100644 --- a/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx +++ b/apps/topicmaps/tz-baumbewirtschaftung/src/app/Map.tsx @@ -759,6 +759,7 @@ const TZBaumbewirtschaftung = ({
} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} From 6f56909c32f6707ed6c4accf416a0076987e6dc5 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 1 Jul 2026 15:08:22 +0200 Subject: [PATCH 56/61] vorhabenkarte: disable cismap photo lightbox --- apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx b/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx index d7e131bce9..d44712588f 100644 --- a/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx +++ b/apps/topicmaps/vorhabenkarte/src/app/components/Map.tsx @@ -217,7 +217,7 @@ const Map = () => { locatorControl={false} fullScreenControl={false} zoomControls={false} - photoLightBox + photoLightBox={false} applicationMenuTooltipString={} gazetteerSearchControl={true} gazetteerSearchComponent={EmptySearchComponent} From a6200af848ef0045b2ad31b75185af1e3a5a4bb0 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 1 Jul 2026 15:08:29 +0200 Subject: [PATCH 57/61] x-and-ride: disable cismap photo lightbox --- apps/topicmaps/x-and-ride/src/app/components/Map.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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={} From 33fd66717d860885d201bcb2a48c4fa5394cbea0 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 1 Jul 2026 15:08:39 +0200 Subject: [PATCH 58/61] potenzialflaechen: disable cismap photo lightbox --- .../potenzialflaechen/src/app/PotenzialflaechenOnlineMap.jsx | 1 + 1 file changed, 1 insertion(+) 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} From 827fc2df015381fe7758623b88df9e3f6b920c53 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 1 Jul 2026 20:22:26 +0200 Subject: [PATCH 59/61] add panorama viewer to lightbox --- .../feature-info/FeatureInfoBox.tsx | 121 +++- libraries/appframeworks/portals/src/index.ts | 6 +- .../src/lib/components/PanoramaLightBox.tsx | 558 +--------------- .../src/lib/components/PanoramaViewer.tsx | 595 ++++++++++++++++++ libraries/mapping/lightbox/src/index.ts | 7 + .../src/lib/LightBoxContextProvider.tsx | 45 +- .../lightbox/src/lib/MediaLightBox.css | 159 +++++ .../lightbox/src/lib/MediaLightBox.tsx | 248 ++++++++ .../lightbox/src/lib/PhotoLightBox.tsx | 17 +- .../lightbox/src/lib/lightboxHelpers.tsx | 18 +- 10 files changed, 1202 insertions(+), 572 deletions(-) create mode 100644 libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx create mode 100644 libraries/mapping/lightbox/src/lib/MediaLightBox.css create mode 100644 libraries/mapping/lightbox/src/lib/MediaLightBox.tsx diff --git a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx index 0367121f18..e97ee69bde 100644 --- a/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx +++ b/apps/geoportal/src/app/components/feature-info/FeatureInfoBox.tsx @@ -11,7 +11,12 @@ import { useDispatch, useSelector } from "react-redux"; import { InfoBoxFotoPreview, + LightBoxContext, LightBoxDispatchContext, + defaultLightBoxCaptionFactory, + type LightBoxCustomSlide, + type LightBoxImageSlide, + type LightBoxSlide, } from "@carma-mapping/lightbox"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; @@ -49,7 +54,7 @@ import { InfoBoxHeader, utils, getActionLinksForFeature, - PanoramaLightBox, + PanoramaViewer, PanoramaPreview, type PanoramaHotspot, } from "@carma-appframeworks/portals"; @@ -189,7 +194,6 @@ const FeatureInfoBox = ({ additionalSecondaryInfoBoxElements = [], }: InfoBoxProps) => { const [open, setOpen] = useState(false); - const [openPanorama, setOpenPanorama] = useState(false); const [shouldRenderLoadingInfobox, setShouldRenderLoadingInfobox] = useState(false); const [headerColor, setHeaderColor] = useState(""); @@ -205,6 +209,7 @@ const FeatureInfoBox = ({ const infoText = useSelector(getInfoText); const maplibreMaps = useSelector(getMaplibreMaps); const lightBoxDispatchContext = useContext(LightBoxDispatchContext); + const lightBoxState = useContext(LightBoxContext); const { routedMapRef } = useContext(TopicMapContext); @@ -818,6 +823,99 @@ const FeatureInfoBox = ({ [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 ; @@ -873,10 +971,10 @@ const FeatureInfoBox = ({ key="infobox-panorama-preview" src={selectedFeature.properties.panorama} multiResConfigUrl={selectedFeature.properties.panoramaMultiResConfig} - onExpand={() => setOpenPanorama(true)} + onExpand={openPanoramaLightBox} // Only the active viewer drives the map arrow; yield to the - // fullscreen lightbox while it is open. - onYawChange={openPanorama ? undefined : handlePanoramaYaw} + // lightbox viewer while it is open. + onYawChange={lightboxVisible ? undefined : handlePanoramaYaw} initialYaw={PANORAMA_INITIAL_YAW} />, ] @@ -957,19 +1055,6 @@ const FeatureInfoBox = ({ skipTeilzwilling={true} /> )} - {openPanorama && selectedFeature.properties.panorama && ( - setOpenPanorama(false)} - onYawChange={handlePanoramaYaw} - onNext={handlePanoramaNext} - onPrevious={handlePanoramaPrevious} - hotspots={panoramaHotspots} - initialYaw={PANORAMA_INITIAL_YAW} - /> - )} ); }; diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index c214038e2c..38673e0d5e 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -66,7 +66,11 @@ 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 type { PanoramaHotspot } 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 diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx index 10ea406109..f41c71e340 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaLightBox.tsx @@ -1,489 +1,29 @@ -import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; -import { resolvePanoramaScene, type PannellumViewer } from "./panoramaScene"; +import { PanoramaViewer, type PanoramaViewerProps } from "./PanoramaViewer"; -// 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"; +// Re-export so existing consumers of `@carma-appframeworks/portals` keep +// importing PanoramaHotspot from here. +export type { PanoramaHotspot } from "./PanoramaViewer"; -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; -} - -interface PanoramaLightBoxProps { - /** URL of an equirectangular panorama image (fallback when no multires). */ - src: string; - /** URL of a pannellum multiresolution config.json; preferred over `src`. */ - multiResConfigUrl?: string; +interface PanoramaLightBoxProps + extends Omit { title?: string; onClose: () => void; - /** 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; } -// 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), - ]; -}; - /** - * Fullscreen overlay that renders a 360° panorama with pannellum. + * Standalone fullscreen overlay that renders a 360° panorama with pannellum. * - * This is a standalone PoC viewer (NOT wired into the react-cismap photo - * lightbox). It exists to demonstrate the panorama "photobox" driven by a - * `panorama` field coming out of the vector-style infoBoxMapping. + * 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 = ({ - src, - multiResConfigUrl, onClose, - onYawChange, - onNext, - onPrevious, - hotspots, - initialYaw, + ...viewerProps }: PanoramaLightBoxProps) => { - 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; - - // 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") { - onClose(); - } 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); - }, [onClose]); - - // 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 }; - - 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) => { - 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) => { - // 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 createPortal(
× -
- {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). - <> -
, document.body ); 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..59691f41fc --- /dev/null +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx @@ -0,0 +1,595 @@ +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 }; + + 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) => { + 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) => { + // 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/mapping/lightbox/src/index.ts b/libraries/mapping/lightbox/src/index.ts index 2a116d6e1c..c0442f6ac2 100644 --- a/libraries/mapping/lightbox/src/index.ts +++ b/libraries/mapping/lightbox/src/index.ts @@ -4,6 +4,9 @@ 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"; @@ -18,6 +21,9 @@ export type { LightBoxDispatchValue, LightBoxSetAllPayload, LightBoxContextProviderProps, + LightBoxSlide, + LightBoxImageSlide, + LightBoxCustomSlide, } from "./lib/LightBoxContextProvider"; export { @@ -25,5 +31,6 @@ export { getLinkOrText, fotoKraemerUrlManipulation, fotoKraemerCaptionFactory, + defaultLightBoxCaptionFactory, } from "./lib/lightboxHelpers"; export type { TriggerLightBoxForFeatureArgs } from "./lib/lightboxHelpers"; diff --git a/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx b/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx index cad4c1fbfa..34def09bfa 100644 --- a/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx +++ b/libraries/mapping/lightbox/src/lib/LightBoxContextProvider.tsx @@ -4,7 +4,36 @@ import { useImmer, type Updater } from "use-immer"; import { setFromLocalforage } from "./_helper"; // Ported from react-cismap src/lib/contexts/LightBoxContextProvider.js, -// converted to TS. Runtime behaviour is intentionally unchanged. +// 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; @@ -14,6 +43,9 @@ export interface LightBoxState { 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 { @@ -22,6 +54,7 @@ export interface LightBoxSetAllPayload { caption: ReactNode; index: number; visible?: boolean; + slides?: LightBoxSlide[]; } export interface LightBoxDispatchValue { @@ -32,6 +65,7 @@ export interface LightBoxDispatchValue { setCaptions: (x: ReactNode[]) => void; setIndex: (x: number) => void; setVisible: (x: boolean) => void; + setSlides: (x: LightBoxSlide[] | undefined) => void; setAll: (all: LightBoxSetAllPayload) => void; } @@ -98,6 +132,14 @@ const UIContextProvider = ({ 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; @@ -105,6 +147,7 @@ const UIContextProvider = ({ draft.caption = all.caption; draft.index = all.index; draft.visible = all.visible === undefined ? true : all.visible; + draft.slides = all.slides; }); }, }; diff --git a/libraries/mapping/lightbox/src/lib/MediaLightBox.css b/libraries/mapping/lightbox/src/lib/MediaLightBox.css new file mode 100644 index 0000000000..d67e33185a --- /dev/null +++ b/libraries/mapping/lightbox/src/lib/MediaLightBox.css @@ -0,0 +1,159 @@ +/* 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; +} 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 index d738196b6d..6cfd1a6444 100644 --- a/libraries/mapping/lightbox/src/lib/PhotoLightBox.tsx +++ b/libraries/mapping/lightbox/src/lib/PhotoLightBox.tsx @@ -7,10 +7,16 @@ import { type LightBoxDispatchValue, type LightBoxState, } from "./LightBoxContextProvider"; +import MediaLightBox from "./MediaLightBox"; // Ported from react-cismap src/lib/topicmaps/PhotoLightbox.js (unchanged -// behaviour). Renders nothing while visible === false, so it is safe to mount -// idle wherever the map shell lives. +// 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; @@ -32,9 +38,16 @@ const PhotoLightBox = ({ 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 = diff --git a/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx b/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx index 02310ace42..0914e61c86 100644 --- a/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx +++ b/libraries/mapping/lightbox/src/lib/lightboxHelpers.tsx @@ -12,6 +12,18 @@ import type { LightBoxDispatchValue } from "./LightBoxContextProvider"; // 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; @@ -31,11 +43,7 @@ export const triggerLightBoxForFeature = ({ getPhotoSeriesArray = () => undefined, urlManipulation = (input) => input, lightBoxDispatchContext, - captionFactory = (linkUrl) => ( - - Stadt Wuppertal - - ), + captionFactory = defaultLightBoxCaptionFactory, fallbackLinkUrl = "http://www.fotokraemer-wuppertal.de/", harvester = (data) => { const tmp = document.implementation.createHTMLDocument(); From 5527901cd793ca7225fdc85f850c9e62fbb5f059 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 22 Jul 2026 10:38:24 +0200 Subject: [PATCH 60/61] move orientation button underneath lightbox navbar --- .../mapping/lightbox/src/lib/MediaLightBox.css | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libraries/mapping/lightbox/src/lib/MediaLightBox.css b/libraries/mapping/lightbox/src/lib/MediaLightBox.css index d67e33185a..b46162128b 100644 --- a/libraries/mapping/lightbox/src/lib/MediaLightBox.css +++ b/libraries/mapping/lightbox/src/lib/MediaLightBox.css @@ -94,16 +94,16 @@ /* Close — identical to .ril__closeButton. */ .carma-media-lightbox__close { - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj48cGF0aCBkPSJtIDEsMyAxLjI1LC0xLjI1IDcuNSw3LjUgNy41LC03LjUgMS4yNSwxLjI1IC03LjUsNy41IDcuNSw3LjUgLTEuMjUsMS4yNSAtNy41LC03LjUgLTcuNSw3LjUgLTEuMjUsLTEuMjUgNy41LC03LjUgLTcuNSwtNy41IHoiIGZpbGw9IiNGRkYiLz48L3N2Zz4='); + 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+'); + 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='); + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PC9nPjxjaXJjbGUgY3g9IjEyIiBjeT0iOCIgcj0iNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4="); } /* Edge nav buttons — match .ril__navButtons (translucent strip, white chevron @@ -129,14 +129,14 @@ .carma-media-lightbox__nav--prev { left: 0; background: rgba(0, 0, 0, 0.2) - url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDE5LDMgLTIsLTIgLTE2LDE2IDE2LDE2IDEsLTEgLTE1LC0xNSAxNSwtMTUgeiIgZmlsbD0iI0ZGRiIvPjwvc3ZnPg==') + 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+') + url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDEsMyAyLC0yIDE2LDE2IC0xNiwxNiAtMSwtMSAxNSwtMTUgLTE1LC0xNSB6IiBmaWxsPSIjRkZGIi8+PC9zdmc+") no-repeat center; } @@ -157,3 +157,7 @@ padding: 10px 20px; color: #fff; } + +.carma-media-lightbox .pnlm-controls-container { + top: 60px; +} From e4ff911913f50952ebe9c0341e145a096c05a185 Mon Sep 17 00:00:00 2001 From: David Glogaza Date: Wed, 22 Jul 2026 10:38:59 +0200 Subject: [PATCH 61/61] make pannellum controls clickable --- .../portals/src/lib/components/PanoramaViewer.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx b/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx index 59691f41fc..b41c088724 100644 --- a/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx +++ b/libraries/appframeworks/portals/src/lib/components/PanoramaViewer.tsx @@ -341,6 +341,11 @@ export const PanoramaViewer = ({ 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 @@ -471,6 +476,11 @@ export const PanoramaViewer = ({ }; 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); @@ -484,6 +494,9 @@ export const PanoramaViewer = ({ 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);