diff --git a/package-lock.json b/package-lock.json
index 527c7c4..de24b7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
"isomorphic-dompurify": "^2.16.0",
"next": "^14.2.21",
"next-mdx-remote": "^5.0.0",
+ "pannellum": "^2.5.7",
"react": "^18.3.1",
"react-apexcharts": "^1.4.1",
"react-dom": "^18.3.1",
@@ -7668,6 +7669,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "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/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
diff --git a/package.json b/package.json
index 6267d52..7a8ea6d 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"isomorphic-dompurify": "^2.16.0",
"next": "^14.2.21",
"next-mdx-remote": "^5.0.0",
+ "pannellum": "^2.5.7",
"react": "^18.3.1",
"react-apexcharts": "^1.4.1",
"react-dom": "^18.3.1",
diff --git a/public/posts/AWvB_Expedition106/360photo_2AUV_AWvBdinghy.jpg b/public/posts/AWvB_Expedition106/360photo_2AUV_AWvBdinghy.jpg
new file mode 100644
index 0000000..f2d3d47
Binary files /dev/null and b/public/posts/AWvB_Expedition106/360photo_2AUV_AWvBdinghy.jpg differ
diff --git a/src/components/mdx/index.js b/src/components/mdx/index.js
index d4cba26..6244bc3 100644
--- a/src/components/mdx/index.js
+++ b/src/components/mdx/index.js
@@ -6,4 +6,5 @@ export { Hr } from '@/components/mdx/hr'
export { Image } from '@/components/mdx/image'
export { Link } from '@/components/mdx/link'
export { mapping } from '@/components/mdx/mdx-jsx-mapping'
+export { Panorama } from '@/components/mdx/panorama'
export { Quote } from '@/components/mdx/quote'
diff --git a/src/components/mdx/mdx-jsx-mapping.js b/src/components/mdx/mdx-jsx-mapping.js
index 7a94223..8e33616 100644
--- a/src/components/mdx/mdx-jsx-mapping.js
+++ b/src/components/mdx/mdx-jsx-mapping.js
@@ -3,6 +3,7 @@ import { Heading } from '@/components/mdx/heading'
import { Hr } from '@/components/mdx/hr'
import { Image } from '@/components/mdx/image'
import { Link } from '@/components/mdx/link'
+import { Panorama } from '@/components/mdx/panorama'
import { Quote } from '@/components/mdx/quote'
import { Box, Text } from '@chakra-ui/react'
@@ -23,4 +24,5 @@ export const mapping = {
li: (props) => ,
blockquote: (props) =>
,
img: (props) => ,
+ Panorama: (props) => ,
}
diff --git a/src/components/mdx/panorama.js b/src/components/mdx/panorama.js
new file mode 100644
index 0000000..4e92556
--- /dev/null
+++ b/src/components/mdx/panorama.js
@@ -0,0 +1,210 @@
+import { Box, Text } from '@chakra-ui/react'
+import { useEffect, useRef, useState } from 'react'
+import { Image } from '@/components/mdx/image'
+
+const DEFAULT_HINT = 'Drag to look around. On mobile, drag or move your device.'
+const FALLBACK_MAX_TEXTURE_SIZE = 2048
+
+const getViewerFactory = (pannellumModule) => {
+ if (typeof window !== 'undefined' && window.pannellum) {
+ if (typeof window.pannellum.viewer === 'function') {
+ return window.pannellum.viewer
+ }
+ }
+
+ if (pannellumModule && typeof pannellumModule.viewer === 'function') {
+ return pannellumModule.viewer
+ }
+
+ if (
+ pannellumModule &&
+ pannellumModule.default &&
+ typeof pannellumModule.default.viewer === 'function'
+ ) {
+ return pannellumModule.default.viewer
+ }
+
+ if (typeof pannellumModule === 'function') {
+ return pannellumModule
+ }
+
+ if (pannellumModule && typeof pannellumModule.default === 'function') {
+ return pannellumModule.default
+ }
+
+ return null
+}
+
+const getMaxPanoramaWidth = () => {
+ if (typeof document === 'undefined') return FALLBACK_MAX_TEXTURE_SIZE * 2
+
+ try {
+ const canvas = document.createElement('canvas')
+ const gl =
+ canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
+
+ if (!gl) return FALLBACK_MAX_TEXTURE_SIZE * 2
+ const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE)
+ return Math.max(FALLBACK_MAX_TEXTURE_SIZE * 2, maxTextureSize * 2)
+ } catch {
+ return FALLBACK_MAX_TEXTURE_SIZE * 2
+ }
+}
+
+const loadImage = (src) =>
+ new Promise((resolve, reject) => {
+ const img = new window.Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => resolve(img)
+ img.onerror = () => reject(new Error('Panorama image failed to load'))
+ img.src = src
+ })
+
+const buildResizedPanorama = async (src, maxWidth) => {
+ const image = await loadImage(src)
+ if (image.naturalWidth <= maxWidth) return src
+
+ const scale = maxWidth / image.naturalWidth
+ const targetWidth = Math.round(image.naturalWidth * scale)
+ const targetHeight = Math.round(image.naturalHeight * scale)
+
+ const canvas = document.createElement('canvas')
+ canvas.width = targetWidth
+ canvas.height = targetHeight
+
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return src
+
+ ctx.drawImage(image, 0, 0, targetWidth, targetHeight)
+ return canvas.toDataURL('image/jpeg', 0.9)
+}
+
+export const Panorama = ({
+ src,
+ alt,
+ height = '420px',
+ hint = DEFAULT_HINT,
+ ...imageProps
+}) => {
+ const containerRef = useRef(null)
+ const viewerRef = useRef(null)
+ const [viewerFailed, setViewerFailed] = useState(false)
+ const [viewerLoaded, setViewerLoaded] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ setViewerFailed(false)
+ setViewerLoaded(false)
+
+ const initViewer = (viewerFactory, panoramaSource) => {
+ viewerRef.current = viewerFactory(containerRef.current, {
+ type: 'equirectangular',
+ panorama: panoramaSource,
+ autoLoad: true,
+ showControls: true,
+ showFullscreenCtrl: true,
+ mouseZoom: true,
+ compass: false,
+ hfov: 110,
+ })
+
+ if (typeof viewerRef.current.on === 'function') {
+ viewerRef.current.on('load', () => {
+ if (!cancelled) setViewerLoaded(true)
+ })
+
+ viewerRef.current.on('error', (error) => {
+ if (cancelled) return
+ setViewerFailed(true)
+ })
+ }
+ }
+
+ const setupViewer = async () => {
+ if (!containerRef.current || !src || typeof window === 'undefined') {
+ return
+ }
+
+ try {
+ const pannellumModule = await import('pannellum')
+ if (cancelled) return
+
+ const viewerFactory = getViewerFactory(pannellumModule)
+ if (!viewerFactory) {
+ throw new Error('Unable to resolve Pannellum viewer factory')
+ }
+
+ const maxPanoramaWidth = getMaxPanoramaWidth()
+
+ try {
+ const safeSource = await buildResizedPanorama(src, maxPanoramaWidth)
+ if (cancelled) return
+ try {
+ initViewer(viewerFactory, safeSource)
+ } catch {
+ if (cancelled) return
+ initViewer(viewerFactory, src)
+ }
+ } catch {
+ if (cancelled) return
+ try {
+ initViewer(viewerFactory, src)
+ } catch {
+ setViewerFailed(true)
+ }
+ }
+ } catch {
+ if (!cancelled) {
+ setViewerFailed(true)
+ }
+ }
+ }
+
+ setupViewer()
+
+ return () => {
+ cancelled = true
+ if (
+ viewerRef.current &&
+ typeof viewerRef.current.destroy === 'function'
+ ) {
+ viewerRef.current.destroy()
+ }
+ viewerRef.current = null
+ }
+ }, [src])
+
+ return (
+
+ {!viewerFailed && (
+
+ )}
+
+ {viewerFailed && (
+
+ )}
+
+
+ {viewerLoaded || viewerFailed ? hint : 'Loading 360 view...'}
+
+
+ )
+}
diff --git a/src/pages/_app.js b/src/pages/_app.js
index 09bc05e..c496848 100644
--- a/src/pages/_app.js
+++ b/src/pages/_app.js
@@ -4,6 +4,7 @@ import { ChakraProvider } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import Script from 'next/script'
import { useEffect } from 'react'
+import 'pannellum/build/pannellum.css'
function MyApp({ Component, pageProps }) {
const router = useRouter()
diff --git a/src/posts/AWvB_Expedition106/index.md b/src/posts/AWvB_Expedition106/index.md
new file mode 100644
index 0000000..91db7b9
--- /dev/null
+++ b/src/posts/AWvB_Expedition106/index.md
@@ -0,0 +1,21 @@
+---
+title: 'Live from the Anna Weber-van Bosse vessel!'
+date: '2026-06-29'
+authors:
+ - name: Gonçalo Albergaria
+ github: GoncaloAlbergaria
+summary: 'VirtualShip at AWvB NIOZ 106 AUV training+Deep Sea Prey expedition'
+---
+
+Our 360° camera has been capturing the several NIOZ AUV trial missions in the Gulf of Genoa!
+VirtualShip has joined the newly designed NIOZ research vessel, Anna Weber-van Bosse, in a northern Mediterranean trialing of its Autonomous Underwater Vehicle (AUV) – part of a concerted Marine Robotics programme spearheaded at NIOZ from 2025 onwards.
+
+Emma and Gonçalo boarded the AWvB in the Port of Genoa on Thursday, 25 June, and will be accompanying all things AUV, CTD, hopper video frame deployments and recoveries to further develop VirtualShip’s VR material. We are also lucky to be onboard while [Dr. Fleur Visser](https://www.nioz.nl/en/about-nioz/staff/fleur-visser)’s [Deep Sea Prey](https://www.nioz.nl/en/news-and-blogs/news/vidi-grant-for-studying-how-whales-hunt-in-deep-sea) research is undertaken! It is allowing us to learn about cetacean feeding behaviour, as well as video monitoring applications.
+
+It is engrossing to join this dual expedition and translate the AWvB’s bustling days into VirtualShip’s audience!
+
+
diff --git a/src/posts/VS@AWvB_Expedition106/index.md b/src/posts/VS@AWvB_Expedition106/index.md
new file mode 100644
index 0000000..6436f75
--- /dev/null
+++ b/src/posts/VS@AWvB_Expedition106/index.md
@@ -0,0 +1,15 @@
+---
+title: 'Live from the Anna Weber-van Bosse vessel!'
+date: '2025-06-29'
+authors:
+ - name: Gonçalo Albergaria
+ github: GoncaloAlbergaria
+summary: 'VirtualShip at AWvB NIOZ 106 AUV training+Deep Sea Prey expedition'
+---
+
+Our 360° camera has been capturing the several NIOZ AUV trial missions in the Gulf of Genoa!
+VirtualShip has joined the newly designed NIOZ research vessel, Anna Weber-van Bosse, in a northern Mediterranean trialing of its Autonomous Underwater Vehicle (AUV) – part of a concerted Marine Robotics programme spearheaded at NIOZ from 2025 onwards.
+Emma and Gonçalo boarded the AWvB in the Port of Genoa on Thursday, 25 June, and will be accompanying all things AUV, CTD, hopper video frame deployments and recoveries to further develop VirtualShip’s VR material. We are also lucky to be onboard while dr. Fleur Visser’s Deep Sea Prey research is undertaken! It is allowing us to learn about cetacean feeding behaviour, as well as video monitoring applications.
+It is engrossing to join this dual expedition and translate the AWvB’s bustling days into VirtualShip’s audience!
+
+#![Photo with AUV from AWvB workboat]