Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/components/mdx/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 2 additions & 0 deletions src/components/mdx/mdx-jsx-mapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -23,4 +24,5 @@ export const mapping = {
li: (props) => <Box as='li' pb={1} {...props} />,
blockquote: (props) => <Quote {...props} />,
img: (props) => <Image {...props} alt={props.alt} />,
Panorama: (props) => <Panorama {...props} />,
}
210 changes: 210 additions & 0 deletions src/components/mdx/panorama.js
Original file line number Diff line number Diff line change
@@ -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 (
<Box my={6}>
{!viewerFailed && (
<Box
ref={containerRef}
role='img'
aria-label={alt}
borderRadius='md'
overflow='hidden'
height={height}
width='100%'
cursor='grab'
touchAction='none'
userSelect='none'
style={{ WebkitUserSelect: 'none' }}
/>
)}

{viewerFailed && (
<Image
src={src}
alt={alt}
borderRadius='md'
width='100%'
{...imageProps}
/>
)}

<Text mt={2} fontSize='sm' color='gray.500'>
{viewerLoaded || viewerFailed ? hint : 'Loading 360 view...'}
</Text>
</Box>
)
}
1 change: 1 addition & 0 deletions src/pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions src/posts/AWvB_Expedition106/index.md
Original file line number Diff line number Diff line change
@@ -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!

<Panorama
src='/posts/AWvB_Expedition106/360photo_2AUV_AWvBdinghy.jpg'
alt='AWvB vessel in the Port of Genoa'
height='520px'
/>
15 changes: 15 additions & 0 deletions src/posts/VS@AWvB_Expedition106/index.md
Original file line number Diff line number Diff line change
@@ -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]
Loading