Fix mobile
This commit is contained in:
@@ -424,7 +424,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
}
|
||||
|
||||
renderedLines.push(
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||
{spans}
|
||||
</div>
|
||||
)
|
||||
@@ -459,7 +459,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||
{spans}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const X_CHANGE_THRESHOLD = 0.3
|
||||
|
||||
const updateDimensions = () => {
|
||||
const updateDimensions = (force = false) => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
|
||||
@@ -84,30 +84,59 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 1
|
||||
setDimensions(prev => {
|
||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||
return prev
|
||||
if (!force) {
|
||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||
return prev
|
||||
}
|
||||
}
|
||||
return { width, height, scaleFactor }
|
||||
})
|
||||
}
|
||||
|
||||
// Use rAF for fullscreen toggle so CSS layout settles before measuring
|
||||
requestAnimationFrame(updateDimensions)
|
||||
// Force update on fullscreen/orientation change so animation always restarts
|
||||
requestAnimationFrame(() => updateDimensions(true))
|
||||
|
||||
const onOrientationChange = () => {
|
||||
requestAnimationFrame(() => updateDimensions(true))
|
||||
}
|
||||
window.addEventListener('orientationchange', onOrientationChange)
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(updateDimensions, 2000)
|
||||
debounceTimer = setTimeout(() => updateDimensions(), 2000)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('orientationchange', onOrientationChange)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
}, [containerHeight, isFullscreen])
|
||||
|
||||
const toggleFullscreen = useCallback(() => setIsFullscreen(prev => !prev), [])
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const entering = !isFullscreen
|
||||
setIsFullscreen(entering)
|
||||
|
||||
if (entering) {
|
||||
// On portrait touch devices, request native fullscreen + lock landscape
|
||||
const isPortrait = window.matchMedia('(orientation: portrait)').matches
|
||||
const isTouch = window.matchMedia('(pointer: coarse)').matches
|
||||
if (isPortrait && isTouch && containerRef.current?.requestFullscreen) {
|
||||
const so = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
|
||||
containerRef.current.requestFullscreen()
|
||||
.then(() => so.lock?.('landscape'))
|
||||
.catch(() => {})
|
||||
}
|
||||
} else {
|
||||
const so = screen.orientation as ScreenOrientation & { unlock?: () => void }
|
||||
try { so.unlock?.() } catch { /* not supported */ }
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
// ESC key to exit fullscreen
|
||||
useEffect(() => {
|
||||
@@ -126,6 +155,17 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isFullscreen])
|
||||
|
||||
// Sync state when native fullscreen is exited via browser controls
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!document.fullscreenElement && isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handler)
|
||||
return () => document.removeEventListener('fullscreenchange', handler)
|
||||
}, [isFullscreen])
|
||||
|
||||
// Focus trap when fullscreen
|
||||
useFocusTrap(containerRef, isFullscreen)
|
||||
|
||||
@@ -134,6 +174,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
|
||||
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
|
||||
|
||||
// pinnedNodeIdRef is declared later but only accessed at call-time (not during render), so empty dep arrays are correct
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
const resolveGraphFallback = useCallback(
|
||||
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
|
||||
[],
|
||||
@@ -148,6 +190,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
if (pId && pType && pType !== 'skill') return pId
|
||||
return null
|
||||
}, [])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
// Shared refs for hooks
|
||||
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
@@ -229,7 +231,129 @@ function ProjectItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsCarousel() {
|
||||
// --- Embla slide-by-slide carousel for screens < 1024px ---
|
||||
|
||||
function EmblaProjectsCarousel() {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [wrapperWidth, setWrapperWidth] = useState(0)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
const w = el.clientWidth
|
||||
if (w > 0) setWrapperWidth(w)
|
||||
}
|
||||
update()
|
||||
const obs = new ResizeObserver(update)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
const slidesPerView = wrapperWidth < 480 ? 1 : 2
|
||||
const slideWidth = slidesPerView === 1 ? '100%' : 'calc(50% - 6px)'
|
||||
const cardMinHeight = wrapperWidth < 480 ? 148 : wrapperWidth < 640 ? 168 : 182
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(
|
||||
{ loop: true, align: 'start' },
|
||||
[Autoplay({ delay: 4000, stopOnInteraction: false, stopOnMouseEnter: true })],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi || typeof window === 'undefined') return
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const sync = () => {
|
||||
const autoplay = emblaApi.plugins()?.autoplay
|
||||
if (!autoplay) return
|
||||
if (mq.matches) autoplay.stop()
|
||||
else autoplay.play()
|
||||
}
|
||||
sync()
|
||||
mq.addEventListener('change', sync)
|
||||
return () => mq.removeEventListener('change', sync)
|
||||
}, [emblaApi])
|
||||
|
||||
useEffect(() => {
|
||||
emblaApi?.reInit()
|
||||
}, [emblaApi, slidesPerView])
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
}, [emblaApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
const updateSnaps = () => {
|
||||
setScrollSnaps(emblaApi.scrollSnapList())
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
}
|
||||
updateSnaps()
|
||||
emblaApi.on('select', onSelect)
|
||||
emblaApi.on('reInit', updateSnaps)
|
||||
return () => {
|
||||
emblaApi.off('select', onSelect)
|
||||
emblaApi.off('reInit', updateSnaps)
|
||||
}
|
||||
}, [emblaApi, onSelect])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<div ref={emblaRef} style={{ overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
{investigations.map((project) => (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{scrollSnaps.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
style={{
|
||||
width: index === selectedIndex ? '16px' : '6px',
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
background:
|
||||
index === selectedIndex
|
||||
? 'var(--accent-primary, #00897B)'
|
||||
: 'var(--border-light, #d1d5db)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Continuous scroll carousel for screens >= 1024px ---
|
||||
|
||||
function ContinuousScrollCarousel() {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -245,95 +369,64 @@ export function ProjectsCarousel() {
|
||||
|
||||
useEffect(() => {
|
||||
const viewportEl = viewportRef.current
|
||||
if (!viewportEl || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewportEl || typeof window === 'undefined') return
|
||||
const updateWidth = () => {
|
||||
const nextWidth = viewportEl.clientWidth
|
||||
if (nextWidth > 0) {
|
||||
setViewportWidth(nextWidth)
|
||||
}
|
||||
if (nextWidth > 0) setViewportWidth(nextWidth)
|
||||
}
|
||||
updateWidth()
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(() => updateWidth())
|
||||
observer.observe(viewportEl)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => window.removeEventListener('resize', updateWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches)
|
||||
|
||||
syncMotionPreference()
|
||||
mediaQuery.addEventListener('change', syncMotionPreference)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', syncMotionPreference)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const trackEl = trackRef.current
|
||||
const firstSetEl = firstSetRef.current
|
||||
if (!trackEl || !firstSetEl || prefersReducedMotion) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!trackEl || !firstSetEl || prefersReducedMotion) return
|
||||
let animationFrameId = 0
|
||||
let lastTime = 0
|
||||
const speedPxPerSecond = viewportWidth < 768 ? 18 : 24
|
||||
|
||||
const speedPxPerSecond = 24
|
||||
const tick = (timestamp: number) => {
|
||||
if (!lastTime) {
|
||||
lastTime = timestamp
|
||||
}
|
||||
if (!lastTime) lastTime = timestamp
|
||||
const deltaSeconds = (timestamp - lastTime) / 1000
|
||||
lastTime = timestamp
|
||||
|
||||
if (!isPausedRef.current) {
|
||||
const setWidth = firstSetEl.offsetWidth
|
||||
if (setWidth > 0) {
|
||||
offsetRef.current += speedPxPerSecond * deltaSeconds
|
||||
if (offsetRef.current >= setWidth) {
|
||||
offsetRef.current -= setWidth
|
||||
}
|
||||
if (offsetRef.current >= setWidth) offsetRef.current -= setWidth
|
||||
trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)`
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick)
|
||||
return () => window.cancelAnimationFrame(animationFrameId)
|
||||
}, [prefersReducedMotion, viewportWidth])
|
||||
|
||||
const cardsPerView = useMemo(() => {
|
||||
if (viewportWidth < 480) return 1
|
||||
if (viewportWidth < 768) return 2
|
||||
return 4
|
||||
}, [viewportWidth])
|
||||
|
||||
const slideWidth = useMemo(() => {
|
||||
const cardsPerView = 4
|
||||
const gap = 12
|
||||
const totalGap = (cardsPerView - 1) * gap
|
||||
const computedWidth = (viewportWidth - totalGap) / cardsPerView
|
||||
return `${Math.max(computedWidth, 0)}px`
|
||||
}, [cardsPerView, viewportWidth])
|
||||
}, [viewportWidth])
|
||||
|
||||
const cardMinHeight = useMemo(() => {
|
||||
if (viewportWidth < 480) return 148
|
||||
if (viewportWidth < 640) return 168
|
||||
if (viewportWidth < 1024) return 182
|
||||
if (viewportWidth < 1440) return 196
|
||||
return 214
|
||||
}, [viewportWidth])
|
||||
@@ -343,49 +436,70 @@ export function ProjectsCarousel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setPaused(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setPaused(false)
|
||||
}
|
||||
ref={trackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 'max-content',
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 'max-content',
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
}}
|
||||
>
|
||||
{[0, 1].map((setIndex) => (
|
||||
<div
|
||||
key={setIndex}
|
||||
ref={setIndex === 0 ? firstSetRef : undefined}
|
||||
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
|
||||
>
|
||||
{investigations.map((project) => (
|
||||
<ProjectItem
|
||||
key={`${setIndex}-${project.id}`}
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{[0, 1].map((setIndex) => (
|
||||
<div
|
||||
key={setIndex}
|
||||
ref={setIndex === 0 ? firstSetRef : undefined}
|
||||
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
|
||||
>
|
||||
{investigations.map((project) => (
|
||||
<ProjectItem
|
||||
key={`${setIndex}-${project.id}`}
|
||||
project={project}
|
||||
slideWidth={slideWidth}
|
||||
cardMinHeight={cardMinHeight}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main export ---
|
||||
|
||||
export function ProjectsCarousel() {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(() =>
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(max-width: 1023px)').matches
|
||||
: false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)')
|
||||
const handler = () => setIsSmallScreen(mq.matches)
|
||||
setIsSmallScreen(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '28px' }}>
|
||||
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
||||
{isSmallScreen ? <EmblaProjectsCarousel /> : <ContinuousScrollCarousel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function AccessibilityProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAccessibility() {
|
||||
const context = useContext(AccessibilityContext)
|
||||
if (!context) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDetailPanel(): DetailPanelContextValue {
|
||||
const context = useContext(DetailPanelContext)
|
||||
if (!context) {
|
||||
|
||||
@@ -621,7 +621,7 @@ export function useForceSimulation(
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [dimensions, options])
|
||||
}, [dimensions, options, svgRef])
|
||||
|
||||
return {
|
||||
simulationRef,
|
||||
|
||||
@@ -408,7 +408,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
||||
}, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, hideAll, revealStep, scheduleTimeout])
|
||||
}, [deps.simulationRef, hideAll, revealStep, scheduleTimeout])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (prefersReducedMotion) return
|
||||
|
||||
+1
-1
@@ -441,7 +441,7 @@ html {
|
||||
/* Tablet+: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.pathway-columns {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3.5fr);
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 3.5fr);
|
||||
align-items: start;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user