diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx index 60d163d..46f66aa 100644 --- a/src/components/AllSkyMap.tsx +++ b/src/components/AllSkyMap.tsx @@ -1,5 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router'; +import { CSSProperties, useEffect, useRef, useState } from 'react'; export interface SkySource { sourceId: string; @@ -12,8 +11,8 @@ interface AllSkyMapProps { sources: SkySource[]; title?: string; subtitle?: string; - height?: number; - width?: number; + height?: CSSProperties['height']; + setClickedSourceId: (id: string) => void; } interface HoveredSource { @@ -32,22 +31,21 @@ interface HoveredSource { export default function AllSkyMap({ sources, title = 'Sources by position', - subtitle = "Click a source's marker to view its light curve", - height = 500, - width = 375, + subtitle = "Click a source's marker to preview its light curve", + height = 600, + setClickedSourceId, }: AllSkyMapProps) { const containerRef = useRef(null); const aladinInstanceRef = useRef(null); - // react-router's useNavigate() isn't a stable reference across every render, so use a ref to - // keep it up-to-date for the link-out in the popups. Since it's not stable, putting `navigate` - // directly in the init effect's deps below was tearing down and recreating the entire Aladin/WebGL - // instance on nearly every re-render. But the catalog-rebuild effect doesn't rerun when that happens - // bc its own deps are unchanged, so the fresh instance was left with no markers. The ref allows the - // init effect to depend on nothing and still call current navigate and the markers show up as desired. - const navigate = useNavigate(); - const navigateRef = useRef(navigate); - navigateRef.current = navigate; + // setClickedSourceId isn't guaranteed to be a stable reference across every render (its + // caller may recreate it), so keep it in a ref for the init effect below to read. Putting it + // directly in the init effect's deps would tear down and recreate the entire Aladin/WebGL + // instance on nearly every re-render. But the catalog-rebuild effect doesn't rerun when that + // happens bc its own deps are unchanged, so the fresh instance would be left with no markers. + // The ref lets the init effect depend on nothing while always calling the current callback. + const setClickedSourceIdRef = useRef(setClickedSourceId); + setClickedSourceIdRef.current = setClickedSourceId; const [isDataReady, setIsDataReady] = useState(false); const [hoveredSource, setHoveredSource] = useState( @@ -80,9 +78,9 @@ export default function AllSkyMap({ aladinInstanceRef.current = aladin; aladin.on('objectClicked', (object) => { - const sourceId = object.data?.sourceId; + const sourceId = object?.data?.sourceId; if (typeof sourceId === 'string') { - void navigateRef.current('/source/' + sourceId); + setClickedSourceIdRef.current(sourceId); } }); @@ -114,7 +112,7 @@ export default function AllSkyMap({ cancelled = true; }; // Intentionally empty: this must only run once for the component's whole lifetime (see - // navigateRef comment above for why `navigate` itself isn't a dependency here). + // setClickedSourceIdRef comment above for why setClickedSourceId itself isn't a dependency here). }, []); // Repopulate the sources catalog whenever the source list changes. Relies on the caller @@ -161,7 +159,6 @@ export default function AllSkyMap({ style={{ width: '100%', height, - maxWidth: width, visibility: isDataReady ? 'visible' : 'hidden', }} /> diff --git a/src/components/Lightcurve.tsx b/src/components/Lightcurve.tsx index 3f45c82..dcdbad2 100644 --- a/src/components/Lightcurve.tsx +++ b/src/components/Lightcurve.tsx @@ -663,7 +663,10 @@ export function Lightcurve({ // @ts-expect-error plotlyRef is an extended version of an HTMLDivElement ref={plotlyRef} id={plotElementId} - style={{ visibility: isDataReady ? 'visible' : 'hidden' }} + style={{ + visibility: isDataReady ? 'visible' : 'hidden', + height: plotLayout.height, + }} > {clickedMarkerData && imageUrl && (
('instrument'); + const dialogRef = useRef(null); + const navigate = useNavigate(); - const { data: initialLoadData, error: initialLoadError } = useQuery< + const [selectedSourceId, setSelectedSourceId] = useState(null); + + const { data: allSources, error: initialLoadError } = useQuery< + { sources: SourceResponse[] } | undefined + >({ + initialData: undefined, + queryKey: [], + queryFn: async () => { + const sources = await lightcurveApi.getSources(); + if (!sources) return; + return { sources }; + }, + }); + + const { + data: lightcurveData, + error: lightcurveLoadError, + isLoading: isLightcurveLoading, + } = useQuery< | { - sources: SourceResponse[]; - lightcurveData: FrequencyLightcurveData | InstrumentLightcurveData; + lightcurve: FrequencyLightcurveData | InstrumentLightcurveData; + source: SourceResponse; } | undefined >({ initialData: undefined, - queryKey: [selectionStrategy], + queryKey: [selectedSourceId, selectionStrategy], queryFn: async () => { - const sources = await lightcurveApi.getSources(); - if (!sources) return; - const lightcurveData = await lightcurveApi.getLightcurveData( - sources[0].source_id, + if (selectedSourceId === null) return; + const lightcurve = await lightcurveApi.getLightcurveData( + selectedSourceId, selectionStrategy ); - return { sources, lightcurveData }; + const source = await lightcurveApi.getSourceData(selectedSourceId); + if (!lightcurve || !source) return; + return { lightcurve, source }; }, }); @@ -42,10 +64,14 @@ export function Main() { throw initialLoadError; } + if (lightcurveLoadError) { + throw lightcurveLoadError; + } + // initialLoadData.sources is only ever replaced when a new fetch actually resolves (see // useQuery), so memoizing this transform keeps the array passed to AllSkyMap referentially // stable across re-renders - const sources = initialLoadData?.sources; + const sources = allSources?.sources; const skySources: SkySource[] = useMemo( () => sources?.map((s) => ({ @@ -57,51 +83,94 @@ export function Main() { [sources] ); - const sourceUrl = initialLoadData?.sources - ? '/source/' + initialLoadData.sources[0].source_id - : undefined; + // 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). + const handleClickedSource = useCallback((id: string) => { + setSelectedSourceId(id); + dialogRef.current?.show(); + }, []); - if (!sourceUrl) return null; + // 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. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && dialogRef.current?.open) { + dialogRef.current.close(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); return (
-

- Use the interactive map below or the search feature above to explore - light curves. -

- {initialLoadData?.sources ? ( -
+
+ {skySources ? ( -
- ) : ( -
- )} - {initialLoadData?.lightcurveData ? ( + ) : ( +
+ )} +
+
- -
- - - View source page - - -
+ + {lightcurveData?.lightcurve && !isLightcurveLoading ? ( + <> + +
+ +
+ + ) : ( +
+ Loading... +
+ )}
- ) : ( -
- )} +
); } diff --git a/src/components/Source.tsx b/src/components/Source.tsx index fc713cc..54f0173 100644 --- a/src/components/Source.tsx +++ b/src/components/Source.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router'; import { InstrumentLightcurveData, @@ -34,6 +34,10 @@ export function Source() { const [selectionStrategy, setSelectionStrategy] = useState('instrument'); + useEffect(() => { + window.scrollTo(0, 0); + }, []); + const { data: sourceData, error: sourceDataError } = useQuery< SourceResponse | undefined >({ diff --git a/src/components/icons/CloseIcon.tsx b/src/components/icons/CloseIcon.tsx new file mode 100644 index 0000000..b8874c3 --- /dev/null +++ b/src/components/icons/CloseIcon.tsx @@ -0,0 +1,27 @@ +import { CSSProperties } from 'react'; + +export function CloseIcon({ + width = 24, + height = 24, +}: { + width?: CSSProperties['width']; + height?: CSSProperties['height']; +}) { + return ( + + + + + ); +} diff --git a/src/components/styles/lightcurve.css b/src/components/styles/lightcurve.css index 1be27be..8ef23c4 100644 --- a/src/components/styles/lightcurve.css +++ b/src/components/styles/lightcurve.css @@ -182,3 +182,12 @@ justify-content: center; align-items: center; } + +/* The plot container it sits alongside stays in flow at visibility:hidden while not yet ready + (not display:none - Plotly needs a laid-out element to measure/draw into), so without this the + loading placeholder would stack below that invisible-but-space-occupying plot and roughly + double the perceived height for a moment. Overlay it instead. */ +.lightcurve-container .lightcurve-loading { + position: absolute; + inset: 0; +} diff --git a/src/index.css b/src/index.css index 9eb2b43..4ee289e 100644 --- a/src/index.css +++ b/src/index.css @@ -15,7 +15,7 @@ html, body { - min-width: 1280px; + min-width: 320px; min-height: 100%; margin: 0; padding: 0; @@ -29,27 +29,20 @@ body { } main { - padding: 0 10px; - margin-bottom: 10px; - max-width: 1440px; - margin-top: 1.25em; + padding: 10px; } .home-light-curve, -.home-lightcurve-placeholder, .sources-plot-container, .sources-plot-placeholder { border-radius: 10px; border: 1px solid #c4c8cb; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - width: 960px; + width: 100%; + max-width: 960px; padding: 3px; background-color: white; - margin-bottom: 1.25em; -} - -.home-lightcurve-placeholder { - height: 375px; + /* margin-bottom: 1.25em; */ } .sources-plot-placeholder { @@ -65,6 +58,18 @@ main { position: relative; } +.home-lightcurve-dialog { + position: absolute; + top: 225px; + left: 0; + z-index: 20; + width: 100%; + max-width: 960px; + padding: 0; + border: none; + background: transparent; +} + .home-source-link-container { position: absolute; width: 100%; @@ -88,6 +93,7 @@ main { border-radius: 5px; display: 'flex'; align-items: center; + cursor: pointer; } .home-source-link > span { @@ -96,6 +102,24 @@ main { gap: 5px; } +.home-dialog-close-button { + position: absolute; + z-index: 15; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: white; + background-color: #212529de; + border: 1px solid #c0c1c1; + border-radius: 50%; + cursor: pointer; +} + @media (width > 1440px) { main { align-self: center; @@ -122,11 +146,6 @@ footer { margin-top: auto; } -.home-page-header { - margin-top: 0; - font-size: 1.1em; -} - .title-container { position: absolute; z-index: 1; @@ -154,6 +173,8 @@ footer { .sources-plot-container.all-sky { background-color: black; + width: 100%; + max-width: none; } /* Aladin Lite renders its own coordinate/projection toolbar across the top of the