"use client"; import { useEffect, useRef } from "react"; import { useMap } from "./map"; import { useSettings } from "@/hooks/use-settings"; import type { City } from "@/lib/cities"; const IDLE_TIMEOUT_MS = 5_000; const DEFAULT_ZOOM = 9.2; const DEFAULT_PITCH = 49; const DEFAULT_BEARING = 27.4; export function CameraController({ city }: { city: City }) { const { map, isLoaded } = useMap(); const { settings } = useSettings(); const prevCityRef = useRef(null); const idleTimerRef = useRef | null>(null); const orbitFrameRef = useRef(null); const isInteractingRef = useRef(false); useEffect(() => { if (!map || !isLoaded || !city) return; if (city.id === prevCityRef.current) return; prevCityRef.current = city.id; map.flyTo({ center: city.coordinates, zoom: DEFAULT_ZOOM, pitch: DEFAULT_PITCH, bearing: DEFAULT_BEARING, duration: 2800, essential: true, }); }, [map, isLoaded, city]); useEffect(() => { if (!map || !isLoaded || !city) return; const onNorthUp = () => { map.easeTo({ bearing: 0, duration: 650, essential: true, }); }; const onResetView = (event: Event) => { const customEvent = event as CustomEvent<{ center?: [number, number] }>; const center = customEvent.detail?.center ?? city.coordinates; map.flyTo({ center, zoom: DEFAULT_ZOOM, pitch: DEFAULT_PITCH, bearing: DEFAULT_BEARING, duration: 1200, essential: true, }); }; window.addEventListener("aeris:north-up", onNorthUp); window.addEventListener("aeris:reset-view", onResetView); return () => { window.removeEventListener("aeris:north-up", onNorthUp); window.removeEventListener("aeris:reset-view", onResetView); }; }, [map, isLoaded, city]); useEffect(() => { if (!map || !isLoaded || !city || !settings.autoOrbit) { if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); return; } const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; if (prefersReducedMotion) return; const directionMultiplier = settings.orbitDirection === "clockwise" ? 1 : -1; const speed = settings.orbitSpeed * directionMultiplier; function startOrbit() { if (!map || isInteractingRef.current) return; function tick() { if (!map || isInteractingRef.current) return; const bearing = map.getBearing() + speed; map.setBearing(bearing % 360); orbitFrameRef.current = requestAnimationFrame(tick); } orbitFrameRef.current = requestAnimationFrame(tick); } function stopOrbit() { if (orbitFrameRef.current) { cancelAnimationFrame(orbitFrameRef.current); orbitFrameRef.current = null; } } function resetIdleTimer() { isInteractingRef.current = true; stopOrbit(); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); idleTimerRef.current = setTimeout(() => { isInteractingRef.current = false; startOrbit(); }, IDLE_TIMEOUT_MS); } const events = ["mousedown", "wheel", "touchstart"] as const; const container = map.getContainer(); events.forEach((e) => container.addEventListener(e, resetIdleTimer, { passive: true }), ); const onMoveStart = () => { if (isInteractingRef.current) stopOrbit(); }; map.on("movestart", onMoveStart); idleTimerRef.current = setTimeout(() => { isInteractingRef.current = false; startOrbit(); }, IDLE_TIMEOUT_MS); return () => { stopOrbit(); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); events.forEach((e) => container.removeEventListener(e, resetIdleTimer)); map.off("movestart", onMoveStart); }; }, [ map, isLoaded, city, settings.autoOrbit, settings.orbitSpeed, settings.orbitDirection, ]); return null; }