diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx index 949fbc9..a9a2ffc 100644 --- a/src/components/AllSkyMap.tsx +++ b/src/components/AllSkyMap.tsx @@ -1,14 +1,11 @@ -import { - CSSProperties, - useCallback, - useEffect, - useRef, - useState, - useMemo, -} from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import SourceFluxFilter from './SourceFluxFilter'; import { MIN_MAX_FLUX_VALUES } from '../configs/constants'; -import { FREQUENCY_COLORS, SO_FALLBACK_COLOR } from '../configs/socolors'; +import { + FREQUENCY_COLORS, + SO_FALLBACK_COLOR, + frequencyKey, +} from '../configs/socolors'; export interface SkySource { sourceId: string; @@ -25,7 +22,7 @@ interface AllSkyMapProps { bands: Set; title?: string; subtitle?: string; - height?: CSSProperties['height']; + height?: number; setClickedSourceId: (id: string) => void; } @@ -33,17 +30,25 @@ interface HoveredSource { name: string; ra: number; dec: number; - y: number; /** Which side of the marker the tooltip is anchored to, and how far from it; * lets the tooltip flip to the marker's left near the right edge instead of * overflowing the (overflow: hidden) all-sky-wrapper. */ horizontal: { side: 'left' | 'right'; offset: number }; + /** Same idea as horizontal, but for the bottom edge: flips the tooltip to sit above the marker + * instead of below it near the bottom edge. There's no analogous top-edge check because the + * tooltip's default ('top') anchoring already starts below the cursor, so it can't overflow + * upward regardless of how close to the top edge the marker is. */ + vertical: { side: 'top' | 'bottom'; offset: number }; } // Rough upper bound on the tooltip's rendered width (name + RA/Dec lines), used to decide // whether anchoring it to the marker's right edge would run it past the container's edge. const TOOLTIP_WIDTH_ESTIMATE = 180; +// Rough upper bound on the tooltip's rendered height (name + RA/Dec lines), used to decide +// whether anchoring it below the marker would run it past the container's bottom edge. +const TOOLTIP_HEIGHT_ESTIMATE = 70; + // Creates a shape function for Aladin's catalogs used to update the marker color const getShapeFunction = (appliedBand: string) => @@ -53,7 +58,9 @@ const getShapeFunction = canvasCtx.closePath(); // Sets AllSkyMap marker colors to the filter's applied freq band, if selected // and defined in FREQUENCY_COLORS - canvasCtx.fillStyle = FREQUENCY_COLORS[appliedBand] ?? SO_FALLBACK_COLOR; + const freq = Number(appliedBand.split('_')[1]); + canvasCtx.fillStyle = + FREQUENCY_COLORS[frequencyKey(freq)] ?? SO_FALLBACK_COLOR; canvasCtx.globalAlpha = 0.8; canvasCtx.fill(); }; @@ -90,6 +97,14 @@ export default function AllSkyMap({ const [hoveredSource, setHoveredSource] = useState( null ); + // Aladin's fullscreen mode (with the default realFullscreen: false) doesn't use the browser's + // real Fullscreen API; instead, it makes the container div position:fixed and covers the whole + // viewport via a CSS class. That means xyMouseCoords (already relative to the container's own + // top-left) become viewport-relative too, but our tooltip's own position:absolute is anchored + // to all-sky-wrapper - a box that no longer corresponds to where the map is actually rendered + // once the container escapes it via position:fixed. Tracking this lets the tooltip switch to + // position:fixed itself (see the render below) so it keeps tracking the cursor in both modes. + const [isFullscreen, setIsFullscreen] = useState(false); // Initialize the Aladin viewer once; it's never torn down for the lifetime of this // component (see App.tsx, which keeps Main mounted across navigation). @@ -107,13 +122,18 @@ export default function AllSkyMap({ window.A.init .then(() => { if (cancelled || !window.A) return; + // calculate a FOV based on user's viewport width to enforce full [360,180] on load + const fov = + window.innerWidth < height + ? 360 / (window.innerWidth / height) + : 360 * (window.innerWidth / height); const aladin = window.A.aladin(el, { - fov: 360, + fov, cooFrame: 'equatorial', projection: 'MOL', }); - // fov as an init option is unreliable on this build; set it explicitly. - aladin.setFov(360); + // for good measure, in case aladin ignores the init structure + aladin.setFov(fov); aladinInstanceRef.current = aladin; aladin.on('objectClicked', (object) => { @@ -131,21 +151,30 @@ export default function AllSkyMap({ typeof object.dec === 'number' ) { const containerWidth = containerRef.current?.clientWidth ?? 0; + const containerHeight = containerRef.current?.clientHeight ?? 0; const wouldOverflowRight = xyMouseCoords.x + TOOLTIP_WIDTH_ESTIMATE + 12 > containerWidth; + const wouldOverflowBottom = + xyMouseCoords.y + TOOLTIP_HEIGHT_ESTIMATE + 12 > containerHeight; setHoveredSource({ name, ra: object.ra, dec: object.dec, - y: xyMouseCoords.y, horizontal: wouldOverflowRight ? { side: 'right', offset: containerWidth - xyMouseCoords.x } : { side: 'left', offset: xyMouseCoords.x }, + vertical: wouldOverflowBottom + ? { side: 'bottom', offset: containerHeight - xyMouseCoords.y } + : { side: 'top', offset: xyMouseCoords.y }, }); } }); aladin.on('objectHoveredStop', () => setHoveredSource(null)); + aladin.on('fullScreenToggled', (isInFullscreen) => { + setIsFullscreen(isInFullscreen); + }); + setIsDataReady(true); }) .catch(() => { @@ -264,9 +293,15 @@ export default function AllSkyMap({
{hoveredSource.name}
diff --git a/src/components/Lightcurve.tsx b/src/components/Lightcurve.tsx index 3f0fd97..d98ef1b 100644 --- a/src/components/Lightcurve.tsx +++ b/src/components/Lightcurve.tsx @@ -166,7 +166,7 @@ function makeLegendProxyTrace( thickness: 1.0, width: 1.0, }, - type: 'scatter', + type: 'scattergl', mode: 'markers', marker: { size: 5, @@ -276,7 +276,7 @@ export function Lightcurve({ thickness: 1.0, width: 1.0, }, - type: 'scatter', + type: 'scattergl', mode: 'markers', marker: { size: 5, @@ -328,7 +328,7 @@ export function Lightcurve({ thickness: 1.0, width: 1.0, }, - type: 'scatter', + type: 'scattergl', mode: 'markers', marker: { size: 5, @@ -554,9 +554,17 @@ export function Lightcurve({ /** Creates the Plotly plot and attaches our handlers to the plot */ useEffect(() => { const stablePlotlyReference = plotlyRef.current; - if (stablePlotlyReference) { - setIsDataReady(false); + if (!stablePlotlyReference) return; + setIsDataReady(false); + + // Plotly.newPlot is a synchronous, potentially expensive call for a lightcurve with many + // traces/points; since React flushes every effect for a commit in one synchronous + // pass, running it directly here would block any other component's effects in that same + // commit (e.g. Main.tsx's dialog-open transition-kickoff effect) until it finishes. Deferring + // it to the next animation frame lets those effects run first, so this Lightcurve mounting + // with already-cached (near-instant) data doesn't stall an in-progress UI transition. + const raf = requestAnimationFrame(() => { void Plotly.newPlot( stablePlotlyReference, plotData, @@ -574,12 +582,11 @@ export function Lightcurve({ ); void stablePlotlyReference.on('plotly_click', handleMarkerClick); - } + }); return () => { - if (stablePlotlyReference) { - Plotly.purge(stablePlotlyReference); - } + cancelAnimationFrame(raf); + Plotly.purge(stablePlotlyReference); }; }, [ plotData, diff --git a/src/components/Main.tsx b/src/components/Main.tsx index fd01f39..e885043 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -8,21 +8,53 @@ import { useQuery } from '../hooks/useQuery'; import { Lightcurve } from './Lightcurve'; import { DEFAULT_HOMEPAGE_PLOT_LAYOUT } from '../configs/constants'; import { useNavigate } from 'react-router'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { lightcurveApi } from '../api/client'; import AllSkyMap, { SkySource } from './AllSkyMap'; import { LinkOutIcon } from './icons/LinkOutIcon'; import { CloseIcon } from './icons/CloseIcon'; +/** Duration of the dialog's toast-like enter/exit transition; must match the CSS transition + * duration on .home-light-curve in index.css; used as a fallback in case 'transitionend' never + * fires (e.g. the element is removed mid-transition). */ +const DIALOG_ANIM_DURATION_MS = 220; + +type DialogPhase = 'closed' | 'entering' | 'open' | 'exiting'; + +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + /** Renders the "home" page of the web app */ export function Main() { const [selectionStrategy, setSelectionStrategy] = useState('instrument'); - const dialogRef = useRef(null); + // The animated inner wrapper (not the outer positioned container) - see dialogPhase below. + const dialogContentRef = useRef(null); const navigate = useNavigate(); const [selectedSourceId, setSelectedSourceId] = useState(null); + // Drives the dialog's toast-like enter/exit transition: + // - 'closed': dialog is not shown. + // - 'entering': dialog was just shown; about to transition from hidden -> visible. + // - 'open': settled, fully visible. + // - 'exiting': transitioning from visible -> hidden; on completion either actually closes the + // dialog, or (if a new marker was clicked while already open) swaps to the new source and + // re-enters, so a rapid marker-to-marker click always plays a full close-then-reopen cycle + // instead of an abrupt in-place content swap. + const [dialogPhase, setDialogPhase] = useState('closed'); + // Set only when a new marker is clicked while the dialog is already open/animating; read once + // the exit transition finishes (see the 'exiting' effect below). + const pendingSourceIdRef = useRef(null); + const { data: allSources, error: initialLoadError } = useQuery< { sources: SourceResponse[] } | undefined >({ @@ -96,21 +128,118 @@ export function Main() { return { bands, skySources }; }, [sources]); - // Opens the dialog synchronously and unconditionally, so re-clicking the same already-selected - // marker after closing the dialog reopens it too (selectedSourceId alone wouldn't change in - // that case, so an effect keyed on it - or on the fetched lightcurveData - would never re-fire). + // Mirrors dialogPhase for handleClickedSource below, so that callback can have a permanently + // stable identity (see its comment) while still reading the latest phase when invoked. + const dialogPhaseRef = useRef(dialogPhase); + dialogPhaseRef.current = dialogPhase; + + // If the dialog is closed, opens it directly and plays the enter transition. If it's already + // open (or mid-transition), queues the new source and plays the exit transition instead - see + // the 'exiting' phase effect below for what happens once that finishes. + // + // "Open"/"closed" are entirely represented by dialogPhase + CSS (the hidden class below) plus + // the inert attribute; there's no underlying element to show()/close() natively; this used to + // be a native , but that turned out to cost ~80-100ms of synchronous browser work per + // show()/close() call on this page, which silently ate a big chunk of the animation, + // so it was replaced with a plain, always-mounted div. + // + // Also deliberately has no dependencies (reads dialogPhase via a ref instead) so its identity + // never changes: it's passed to AllSkyMap as setClickedSourceId, which isn't memoized, so a + // changing reference here would re-render the whole all-sky map on every dialog phase + // transition. const handleClickedSource = useCallback((id: string) => { - setSelectedSourceId(id); - dialogRef.current?.show(); + if (dialogPhaseRef.current === 'closed') { + setSelectedSourceId(id); + setDialogPhase(prefersReducedMotion() ? 'open' : 'entering'); + return; + } + + pendingSourceIdRef.current = id; + setDialogPhase('exiting'); }, []); - // Closes the dialog on Escape. Attached exactly once for the component's lifetime - dialogRef - // is a stable ref object (its `.current` is read fresh on every invocation), so there's - // nothing here that ever needs the effect to re-run. + // Plays the exit transition and, once it finishes, actually closes the dialog. Used by the + // Escape handler and the close button; clicking a new marker instead goes through + // handleClickedSource above, which reuses this same 'exiting' phase for its close-then-reopen + // cycle (see the 'exiting' effect below). + const handleCloseDialog = useCallback(() => { + if (dialogPhase === 'closed' || dialogPhase === 'exiting') return; + pendingSourceIdRef.current = null; + setDialogPhase('exiting'); + }, [dialogPhase]); + + // handleCloseDialog isn't stable across renders (it depends on dialogPhase), so the + // once-attached keydown listener below reads it through a ref rather than depending on it + // directly; same pattern as setClickedSourceIdRef in AllSkyMap. + const handleCloseDialogRef = useRef(handleCloseDialog); + handleCloseDialogRef.current = handleCloseDialog; + + // Kicks off the enter transition. useLayoutEffect runs synchronously as part of the commit, + // before the browser paints, avoiding any scheduling delay that may eat into the transition; + // reading el.offsetHeight forces the browser to compute layout with the "hidden" class still + // applied, giving the CSS transition a real prior frame to animate from before immediately + // flipping to the resting state. + useLayoutEffect(() => { + if (dialogPhase !== 'entering') return; + if (prefersReducedMotion()) { + setDialogPhase('open'); + return; + } + + const el = dialogContentRef.current; + if (el) void el.offsetHeight; + setDialogPhase('open'); + }, [dialogPhase]); + + // Waits for the exit transition to finish, then either reopens for a queued source (a marker + // was clicked while the dialog was already open) or actually closes the dialog. Also a + // useLayoutEffect (see above) so attaching the transitionend listener/fallback timeout isn't + // itself delayed. + useLayoutEffect(() => { + if (dialogPhase !== 'exiting') return; + + let done = false; + const finishExit = () => { + if (done) return; + done = true; + + const nextId = pendingSourceIdRef.current; + pendingSourceIdRef.current = null; + if (nextId !== null) { + setSelectedSourceId(nextId); + setDialogPhase('entering'); + } else { + setDialogPhase('closed'); + } + }; + + const el = dialogContentRef.current; + if (!el || prefersReducedMotion()) { + finishExit(); + return; + } + + const handleTransitionEnd = (e: TransitionEvent) => { + if (e.target === el) finishExit(); + }; + el.addEventListener('transitionend', handleTransitionEnd); + // Fallback in case transitionend never fires for some reason; keeps the dialog from getting + // stuck mid-exit. + const timeout = window.setTimeout(finishExit, DIALOG_ANIM_DURATION_MS); + + return () => { + el.removeEventListener('transitionend', handleTransitionEnd); + window.clearTimeout(timeout); + }; + }, [dialogPhase]); + + // Closes the dialog on Escape. Attached exactly once for the component's lifetime; reads + // dialogPhaseRef/handleCloseDialogRef fresh on every invocation, so there's nothing here that + // ever needs the effect to re-run. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && dialogRef.current?.open) { - dialogRef.current.close(); + if (e.key === 'Escape' && dialogPhaseRef.current !== 'closed') { + handleCloseDialogRef.current(); } }; @@ -133,13 +262,26 @@ export function Main() {
)}
- -
+ -
+ ); } diff --git a/src/index.css b/src/index.css index 4a27183..53a908f 100644 --- a/src/index.css +++ b/src/index.css @@ -56,18 +56,38 @@ main { .home-light-curve { position: relative; + /* Keep in sync with DIALOG_ANIM_DURATION_MS in Main.tsx - that value is used as a fallback + for when this transition's end never fires (see the 'exiting' phase effect there). */ + transition: + opacity 220ms ease, + transform 220ms ease; + opacity: 1; + transform: translateY(0); +} + +.home-light-curve--hidden { + opacity: 0; + transform: translateY(-50px); + /* Avoid the toast being clickable while it's fading out */ + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .home-light-curve { + transition: none; + } } .home-lightcurve-dialog { position: absolute; top: 225px; + /* left+right+margin centers this within its containing block */ left: 0; + right: 0; + margin: 0 auto; z-index: 20; width: 100%; max-width: 960px; - padding: 0; - border: none; - background: transparent; } .home-source-link-container { diff --git a/src/types/aladin.d.ts b/src/types/aladin.d.ts index 1e33c48..bf89a8a 100644 --- a/src/types/aladin.d.ts +++ b/src/types/aladin.d.ts @@ -41,6 +41,10 @@ interface Aladin { xyMouseCoords: { x: number; y: number } ) => void ): void; + ( + event: 'fullScreenToggled', + callback: (isFullscreen: boolean) => void + ): void; }; /** Detaches every catalog/overlay layer added via addCatalog/addOverlay. */ removeLayers: () => void;