Fix mobile

This commit is contained in:
2026-02-17 23:14:05 +00:00
parent d51efb535d
commit 836305e2a3
11 changed files with 306 additions and 95 deletions
+2 -2
View File
@@ -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)
+194 -80
View File
@@ -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>
)
}
+1
View File
@@ -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) {
+1
View File
@@ -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) {
+1 -1
View File
@@ -621,7 +621,7 @@ export function useForceSimulation(
return () => {
simulation.stop()
}
}, [dimensions, options])
}, [dimensions, options, svgRef])
return {
simulationRef,
+1 -1
View File
@@ -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
View File
@@ -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;
}