diff --git a/src/App.tsx b/src/App.tsx index 7ede7ad..4c39b3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,36 @@ -import { Route, Routes } from 'react-router'; +import { Route, Routes, useLocation } from 'react-router'; import { Navigation } from './components/Navigation'; import { Main } from './components/Main'; import { PageNotFound } from './components/PageNotFound'; import { Source } from './components/Source'; import { SearchResults } from './components/SearchResults'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundaryWrapper } from './components/ErrorBoundaryWrapper'; import { Footer } from './Footer'; function App() { + const location = useLocation(); + const isHome = location.pathname === '/'; + return ( <> + {/* + Main (and the AllSkyMap it renders) stays mounted for the whole session instead of + unmounting on every route change: rebuilding AllSkyMap's Aladin/WebGL catalog from + scratch on every home-page revisit gets more expensive as the source count grows, and + there's no official Aladin Lite API to tear down and recreate that catalog cheaply. + It's kept outside ErrorBoundaryWrapper on purpose - that wrapper force-remounts its + children on every navigation (see its key-increment effect), which would defeat this. + */} +
+ +
+ +
- } /> + } /> } /> } /> diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx index 90d4e26..60d163d 100644 --- a/src/components/AllSkyMap.tsx +++ b/src/components/AllSkyMap.tsx @@ -1,10 +1,4 @@ -import { useEffect, useRef, useMemo, useState } from 'react'; -import Plotly, { - Layout, - Data, - PlotMouseEvent, - PlotlyHTMLElement, -} from 'plotly.js-dist-min'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; export interface SkySource { @@ -12,186 +6,149 @@ export interface SkySource { ra: number; dec: number; name: string; - /** Optional: drives marker color via a continuous colorscale */ - value?: number; } interface AllSkyMapProps { sources: SkySource[]; - /** Plotly geo projection type. Aitoff and Mollweide are the most common for astronomy. */ - projection?: - | 'mollweide' - | 'aitoff' - | 'orthographic' - | 'equirectangular' - | 'stereographic'; - /** Axis system label shown on the plot – purely cosmetic, does NOT reproject the data */ - coordinateSystem?: 'Equatorial (RA/Dec)' | 'Galactic (l/b)'; - /** Color the markers by this field. "none" uses a flat color. */ - colorBy?: 'value' | 'dec' | 'none'; title?: string; subtitle?: string; height?: number; width?: number; } -/** - * Remap RA from [0, 360) → [-180, 180] so the map centers on RA = 0h. - * Plotly's geo treats longitude 0 as the center; this keeps the center at RA 0h. - */ -function raToLon(ra: number): number { - return ra > 180 ? ra - 360 : ra; +interface HoveredSource { + name: string; + ra: number; + dec: number; + x: number; + y: number; } +/** + * Renders every source's (RA, Dec) position on an all-sky Mollweide projection using + * Aladin Lite (loaded globally as window.A via the script tag in index.html - see + * AladinViewer.tsx for the same pattern used on the Source page). + */ export default function AllSkyMap({ sources, - projection = 'mollweide', - coordinateSystem = 'Equatorial (RA/Dec)', - colorBy = 'none', title = 'Sources by position', subtitle = "Click a source's marker to view its light curve", height = 500, width = 375, }: AllSkyMapProps) { - const containerRef = useRef(null); + 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 [isDataReady, setIsDataReady] = useState(false); + const navigateRef = useRef(navigate); + navigateRef.current = navigate; - const lon = useMemo(() => sources.map((s) => raToLon(s.ra)), [sources]); - const lat = useMemo(() => sources.map((s) => s.dec), [sources]); - const customdata = useMemo(() => sources.map((s) => s.sourceId), [sources]); - - const markerColor: number[] | string = useMemo(() => { - if (colorBy === 'value') return sources.map((s) => s.value ?? 0); - if (colorBy === 'dec') return lat; - return '#1f77b4'; - }, [sources, colorBy, lat]); - - const hoverText = useMemo( - () => - sources.map( - (s) => - `${s.name ?? 'Source'}
RA: ${s.ra.toFixed(3)}°
Dec: ${s.dec.toFixed(3)}°${ - s.value !== undefined ? `
Value: ${s.value.toFixed(3)}` : '' - }` - ), - [sources] + const [isDataReady, setIsDataReady] = useState(false); + const [hoveredSource, setHoveredSource] = useState( + null ); - const isColored = colorBy !== 'none'; - + // 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). useEffect(() => { const el = containerRef.current; if (!el) return; - setIsDataReady(false); - - const data: Data[] = [ - { - type: 'scattergeo', - lon, - lat, - customdata, - mode: 'markers', - text: hoverText, - hoverinfo: 'text', - marker: { - size: 5, - opacity: 0.8, - color: markerColor as never, - ...(isColored - ? { - colorscale: 'Viridis', - showscale: true, - colorbar: { - title: colorBy === 'dec' ? 'Dec (°)' : 'Value', - thickness: 12, - len: 0.6, - }, - } - : {}), - }, - }, - ]; - - const layout: Partial = { - height, - width, - margin: { t: title ? 48 : 24, b: 8, l: 8, r: 8 }, - paper_bgcolor: 'transparent', - plot_bgcolor: 'transparent', - geo: { - projection: { type: projection as never }, - showland: false, - showocean: false, - showlakes: false, - showcoastlines: false, - showcountries: false, - showframe: true, - framewidth: 1, - lonaxis: { - showgrid: true, - gridcolor: '#2a3a4a', - gridwidth: 0.5, - dtick: 30, - tick0: 0, - }, - lataxis: { - showgrid: true, - gridcolor: '#2a3a4a', - gridwidth: 0.5, - dtick: 30, - }, - }, - modebar: { - bgcolor: 'rgba(255,255,255,0.5)', - }, - annotations: [ - { - text: coordinateSystem, - xref: 'paper', - yref: 'paper', - x: 0.01, - y: 0.01, - showarrow: false, - font: { size: 11, color: '#888888' }, - }, - ], - }; - - void Plotly.react(el, data, layout, { - displayModeBar: true, - displaylogo: false, - modeBarButtonsToRemove: ['select2d', 'lasso2d'], - responsive: true, - }); - - void el.on('plotly_click', (e: PlotMouseEvent) => { - e.event.preventDefault(); - e.event.stopPropagation(); - const sourceId = e.points[0].customdata as string; - const sourcePageUrl = '/source/' + sourceId; - void navigate(sourcePageUrl); - }); - void el.on('plotly_afterplot', () => setIsDataReady(true)); + if (!window.A) { + console.error('Aladin API is not loaded.'); + return; + } + + let cancelled = false; + + window.A.init + .then(() => { + if (cancelled || !window.A) return; + const aladin = window.A.aladin(el, { + fov: 360, + cooFrame: 'equatorial', + projection: 'MOL', + }); + // fov as an init option is unreliable on this build; set it explicitly. + aladin.setFov(360); + aladinInstanceRef.current = aladin; + + aladin.on('objectClicked', (object) => { + const sourceId = object.data?.sourceId; + if (typeof sourceId === 'string') { + void navigateRef.current('/source/' + sourceId); + } + }); + + aladin.on('objectHovered', (object, xyMouseCoords) => { + const name = object.data?.name; + if ( + typeof name === 'string' && + typeof object.ra === 'number' && + typeof object.dec === 'number' + ) { + setHoveredSource({ + name, + ra: object.ra, + dec: object.dec, + x: xyMouseCoords.x, + y: xyMouseCoords.y, + }); + } + }); + aladin.on('objectHoveredStop', () => setHoveredSource(null)); + + setIsDataReady(true); + }) + .catch(() => { + console.error('Aladin API failed to initialize.'); + }); return () => { - Plotly.purge(el); + cancelled = true; }; - }, [ - lon, - lat, - customdata, - navigate, - markerColor, - hoverText, - isColored, - colorBy, - projection, - coordinateSystem, - title, - height, - ]); + // 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). + }, []); + + // Repopulate the sources catalog whenever the source list changes. Relies on the caller + // (Main.tsx) memoizing `sources` so this doesn't refire on unrelated re-renders - this + // component stays mounted for the whole session now (see App.tsx), so an unmemoized caller + // would otherwise retrigger a full catalog teardown/rebuild on every re-render, including + // ones that happen while hidden (display:none), which Aladin doesn't reliably recover from. + useEffect(() => { + const aladin = aladinInstanceRef.current; + if (!aladin || !window.A) return; + + aladin.removeLayers(); + + const catalog = window.A.catalog({ + name: 'All sources', + shape: (source, canvasCtx) => { + canvasCtx.beginPath(); + canvasCtx.arc(source.x, source.y, 4, 0, 2 * Math.PI, false); + canvasCtx.closePath(); + canvasCtx.fillStyle = '#1f77b4'; + canvasCtx.globalAlpha = 0.8; + canvasCtx.fill(); + }, + }); + aladin.addCatalog(catalog); + catalog.addSources( + sources.map((s) => + window.A!.source(s.ra, s.dec, { + sourceId: s.sourceId, + name: s.name, + }) + ) + ); + }, [sources, isDataReady]); return (
@@ -200,10 +157,11 @@ export default function AllSkyMap({

{subtitle}

@@ -212,6 +170,16 @@ export default function AllSkyMap({ Loading...
)} + {hoveredSource && ( +
+
{hoveredSource.name}
+
RA: {hoveredSource.ra.toFixed(3)}°
+
Dec: {hoveredSource.dec.toFixed(3)}°
+
+ )} ); } diff --git a/src/components/Main.tsx b/src/components/Main.tsx index fc4359d..26f9a10 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -8,9 +8,9 @@ import { useQuery } from '../hooks/useQuery'; import { Lightcurve } from './Lightcurve'; import { DEFAULT_HOMEPAGE_PLOT_LAYOUT } from '../configs/constants'; import { Link } from 'react-router'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { lightcurveApi } from '../api/client'; -import AllSkyMap from './AllSkyMap'; +import AllSkyMap, { SkySource } from './AllSkyMap'; import { LinkOutIcon } from './icons/LinkOutIcon'; /** Renders the "home" page of the web app */ @@ -42,6 +42,21 @@ export function Main() { throw initialLoadError; } + // 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 skySources: SkySource[] = useMemo( + () => + sources?.map((s) => ({ + ra: s.ra, + dec: s.dec, + name: s.name, + sourceId: s.source_id, + })) ?? [], + [sources] + ); + const sourceUrl = initialLoadData?.sources ? '/source/' + initialLoadData.sources[0].source_id : undefined; @@ -55,14 +70,9 @@ export function Main() { light curves. {initialLoadData?.sources ? ( -
+
({ - ra: s.ra, - dec: s.dec, - name: s.name, - sourceId: s.source_id, - }))} + sources={skySources} width={DEFAULT_HOMEPAGE_PLOT_LAYOUT.width} />
diff --git a/src/components/styles/source-page.css b/src/components/styles/source-page.css index ef7b25b..cf1dc0a 100644 --- a/src/components/styles/source-page.css +++ b/src/components/styles/source-page.css @@ -78,12 +78,12 @@ .aladin-container { border: 1px solid #c4c8cb; border-radius: 5px; + z-index: 5; } .aladin-viewer-container { width: 400px; height: 400px; - z-index: 1; } .aladin-popupTitle { diff --git a/src/index.css b/src/index.css index 650e362..9eb2b43 100644 --- a/src/index.css +++ b/src/index.css @@ -149,4 +149,33 @@ footer { .all-sky-wrapper { position: relative; + color: white; +} + +.sources-plot-container.all-sky { + background-color: black; +} + +/* Aladin Lite renders its own coordinate/projection toolbar across the top of the + canvas, so make title container relative in order to push the Aladin Lite viewer + beneath the title container */ +.all-sky-wrapper .title-container { + position: relative; +} + +.all-sky-tooltip { + position: absolute; + z-index: 6; + pointer-events: none; + background-color: rgba(0, 0, 0, 0.85); + color: white; + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; +} + +.all-sky-tooltip-name { + font-weight: bold; + margin-bottom: 2px; } diff --git a/src/types/aladin.d.ts b/src/types/aladin.d.ts index 72d7b4a..15cbb59 100644 --- a/src/types/aladin.d.ts +++ b/src/types/aladin.d.ts @@ -8,9 +8,34 @@ * * Refer to the Aladin docs: https://cds-astro.github.io/aladin-lite/Aladin.html */ +interface AladinClickedObject { + data?: Record; + ra?: number; + dec?: number; +} + interface Aladin { gotoRaDec: (ra: number, dec: number) => void; addCatalog: (catalog: unknown) => void; + setProjection: (projection: string) => void; + setFov: (fovDegrees: number) => void; + getFov: () => [number, number]; + /** Only the events we actually listen for are typed here; add more as needed. */ + on: { + ( + event: 'objectClicked', + callback: (object: AladinClickedObject) => void + ): void; + ( + event: 'objectHovered' | 'objectHoveredStop', + callback: ( + object: AladinClickedObject, + xyMouseCoords: { x: number; y: number } + ) => void + ): void; + }; + /** Detaches every catalog/overlay layer added via addCatalog/addOverlay. */ + removeLayers: () => void; } interface Catalog { @@ -19,11 +44,13 @@ interface Catalog { interface CatalogOptions { name: string; - shape: ( + shape?: ( source: { x: number; y: number }, canvasCtx: CanvasRenderingContext2D ) => void; - onClick: string; + onClick?: string; + color?: string; + sourceSize?: number; } /**