Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions src/components/AllSkyMap.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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<HTMLDivElement | null>(null);
const aladinInstanceRef = useRef<Aladin | null>(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<HoveredSource | null>(
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -161,7 +159,6 @@ export default function AllSkyMap({
style={{
width: '100%',
height,
maxWidth: width,
visibility: isDataReady ? 'visible' : 'hidden',
}}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/components/Lightcurve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<div
Expand Down
165 changes: 117 additions & 48 deletions src/components/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,71 @@ import {
import { useQuery } from '../hooks/useQuery';
import { Lightcurve } from './Lightcurve';
import { DEFAULT_HOMEPAGE_PLOT_LAYOUT } from '../configs/constants';
import { Link } from 'react-router';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { useCallback, useEffect, 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';

/** Renders the "home" page of the web app */
export function Main() {
const [selectionStrategy, setSelectionStrategy] =
useState<SelectionStrategy>('instrument');
const dialogRef = useRef<HTMLDialogElement>(null);
const navigate = useNavigate();

const { data: initialLoadData, error: initialLoadError } = useQuery<
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(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 };
},
});

if (initialLoadError) {
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) => ({
Expand All @@ -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 (
<main>
<h2 className="home-page-header">
Use the interactive map below or the search feature above to explore
light curves.
</h2>
{initialLoadData?.sources ? (
<div className="sources-plot-container all-sky">
<div className="sources-plot-container all-sky">
{skySources ? (
<AllSkyMap
sources={skySources}
width={DEFAULT_HOMEPAGE_PLOT_LAYOUT.width}
setClickedSourceId={handleClickedSource}
/>
</div>
) : (
<div className="sources-plot-placeholder"></div>
)}
{initialLoadData?.lightcurveData ? (
) : (
<div className="sources-plot-placeholder"></div>
)}
</div>
<dialog ref={dialogRef} className="home-lightcurve-dialog">
<div className="home-light-curve">
<Lightcurve
lightcurveData={initialLoadData.lightcurveData}
plotLayout={DEFAULT_HOMEPAGE_PLOT_LAYOUT}
selectionStrategy={selectionStrategy}
setSelectionStrategy={setSelectionStrategy}
hideStrategyToggle={true}
hideFlaggedObsToggle={true}
title="Example light curve"
subtitle="View the source page to learn more"
/>
<div className="home-source-link-container">
<Link className="home-source-link" to={sourceUrl}>
<span>
View source page <LinkOutIcon width={16} height={16} />
</span>
</Link>
</div>
<button
type="button"
className="home-dialog-close-button"
aria-label="Close"
onClick={() => dialogRef.current?.close()}
>
<CloseIcon width={16} height={16} />
</button>
{lightcurveData?.lightcurve && !isLightcurveLoading ? (
<>
<Lightcurve
lightcurveData={lightcurveData.lightcurve}
plotLayout={DEFAULT_HOMEPAGE_PLOT_LAYOUT}
selectionStrategy={selectionStrategy}
setSelectionStrategy={setSelectionStrategy}
hideStrategyToggle={true}
hideFlaggedObsToggle={true}
title={lightcurveData.source.name}
subtitle="View the source page to learn more"
/>
<div className="home-source-link-container">
<button
type="button"
className="home-source-link"
onClick={() => {
dialogRef.current?.close();
void navigate(
'/source/' + lightcurveData.lightcurve.source_id
);
}}
>
<span>
View source page <LinkOutIcon width={16} height={16} />
</span>
</button>
</div>
</>
) : (
<div
className="lightcurve-loading"
style={{
height: DEFAULT_HOMEPAGE_PLOT_LAYOUT.height,
width: '100%',
}}
>
Loading...
</div>
)}
</div>
) : (
<div className="home-lightcurve-placeholder" />
)}
</dialog>
</main>
);
}
6 changes: 5 additions & 1 deletion src/components/Source.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import {
InstrumentLightcurveData,
Expand Down Expand Up @@ -34,6 +34,10 @@ export function Source() {
const [selectionStrategy, setSelectionStrategy] =
useState<SelectionStrategy>('instrument');

useEffect(() => {
window.scrollTo(0, 0);
}, []);

const { data: sourceData, error: sourceDataError } = useQuery<
SourceResponse | undefined
>({
Expand Down
27 changes: 27 additions & 0 deletions src/components/icons/CloseIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CSSProperties } from 'react';

export function CloseIcon({
width = 24,
height = 24,
}: {
width?: CSSProperties['width'];
height?: CSSProperties['height'];
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-x-icon lucide-x"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
9 changes: 9 additions & 0 deletions src/components/styles/lightcurve.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading