From 67e0ff41877319360e05a3d2bb2d32339c73bc12 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Tue, 16 Jun 2026 11:48:04 -0400 Subject: [PATCH] Replace source scatterplot with map --- src/components/AllSkyMap.tsx | 194 ++++++++++++++++++++++++++++++ src/components/AllSourcesPlot.tsx | 80 ------------ src/components/Lightcurve.tsx | 5 +- src/components/Main.tsx | 31 +++-- 4 files changed, 215 insertions(+), 95 deletions(-) create mode 100644 src/components/AllSkyMap.tsx delete mode 100644 src/components/AllSourcesPlot.tsx diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx new file mode 100644 index 0000000..8f11967 --- /dev/null +++ b/src/components/AllSkyMap.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef, useMemo } from 'react'; +import Plotly, { + Layout, + Data, + PlotMouseEvent, + PlotlyHTMLElement, +} from 'plotly.js-dist-min'; +import { useNavigate } from 'react-router'; + +export interface SkySource { + sourceId: string; + 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; + height?: 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; +} + +export default function AllSkyMap({ + sources, + projection = 'mollweide', + coordinateSystem = 'Equatorial (RA/Dec)', + colorBy = 'none', + title, + height = 500, +}: AllSkyMapProps) { + const containerRef = useRef(null); + const navigate = useNavigate(); + + 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 isColored = colorBy !== 'none'; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + 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 = { + title: title ? { text: title, font: { size: 15 } } : undefined, + height, + 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); + }); + + const resizeObserver = new ResizeObserver(() => Plotly.Plots.resize(el)); + resizeObserver.observe(el); + + return () => { + resizeObserver.disconnect(); + Plotly.purge(el); + }; + }, [ + lon, + lat, + customdata, + navigate, + markerColor, + hoverText, + isColored, + colorBy, + projection, + coordinateSystem, + title, + height, + ]); + + /* @ts-expect-error plotlyRef is an extended version of an HTMLDivElement*/ + return
; +} diff --git a/src/components/AllSourcesPlot.tsx b/src/components/AllSourcesPlot.tsx deleted file mode 100644 index b5296e6..0000000 --- a/src/components/AllSourcesPlot.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import Plotly, { - Config, - Data, - Layout, - PlotlyHTMLElement, - PlotMouseEvent, -} from 'plotly.js-dist-min'; -import { useRef, useEffect, useMemo } from 'react'; -import { SourceResponse } from '../types'; -import { useNavigate } from 'react-router'; - -type AllSourcesPlotProps = { - sources: SourceResponse[]; -}; - -export function AllSourcesPlot({ sources }: AllSourcesPlotProps) { - const navigate = useNavigate(); - const sourcesPlotRef = useRef(null); - - const plotConfig: Partial = useMemo(() => { - return { - displayModeBar: true, - displaylogo: false, - }; - }, []); - - const layout = useMemo( - () => - ({ - width: 960, - xaxis: { - title: { text: 'RA' }, - autorange: 'reversed', - }, - yaxis: { - title: { text: 'Dec' }, - }, - font: { - family: 'sans-serif', - }, - title: { - text: 'Sources', - }, - }) as Layout, - [] - ); - - const trace: Partial = useMemo(() => { - return { - type: 'scatter', - mode: 'markers', - x: sources.map((s) => s.ra), - y: sources.map((s) => s.dec), - customdata: sources.map((s) => s.source_id), - hovertemplate: 'Click to view source page at (%{x}, %{y})', - }; - }, [sources]); - - useEffect(() => { - const stablePlotlyReference = sourcesPlotRef.current; - if (!stablePlotlyReference) return; - void Plotly.newPlot(stablePlotlyReference, [trace], layout, plotConfig); - void stablePlotlyReference.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); - }); - - return () => { - if (stablePlotlyReference) { - Plotly.purge(stablePlotlyReference); - } - }; - }, [sourcesPlotRef, layout, trace, navigate, plotConfig]); - - /* @ts-expect-error plotlyRef is an extended version of an HTMLDivElement*/ - return
; -} diff --git a/src/components/Lightcurve.tsx b/src/components/Lightcurve.tsx index 447911c..724ae36 100644 --- a/src/components/Lightcurve.tsx +++ b/src/components/Lightcurve.tsx @@ -19,7 +19,6 @@ import Plotly, { Datum, PlotMouseEvent, ScatterData, - ErrorBar, PlotDatum, PlotlyHTMLElement, Layout, @@ -147,7 +146,7 @@ export function Lightcurve({ color: undefined, thickness: 1.0, width: 1.0, - } as ErrorBar, + }, type: 'scatter', mode: 'markers', marker: { @@ -175,7 +174,7 @@ export function Lightcurve({ color: undefined, thickness: 1.0, width: 1.0, - } as ErrorBar, + }, type: 'scatter', mode: 'markers', marker: { diff --git a/src/components/Main.tsx b/src/components/Main.tsx index d6ca5a4..14dc6ad 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -11,8 +11,8 @@ import home_content from '../configs/home_content.md?raw'; import ReactMarkdown from 'react-markdown'; import { Link } from 'react-router'; import { useState } from 'react'; -import { AllSourcesPlot } from './AllSourcesPlot'; import { lightcurveApi } from '../api/client'; +import AllSkyMap from './AllSkyMap'; /** Renders the "home" page of the web app */ export function Main() { @@ -52,6 +52,24 @@ export function Main() { return (
{home_content} +

+ Below is a plot of sources by their (RA,Dec) position. Click a source's + marker to view its light curve and data on its source page. +

+ {initialLoadData?.sources ? ( +
+ ({ + ra: s.ra, + dec: s.dec, + name: s.name, + sourceId: s.source_id, + }))} + /> +
+ ) : ( +
+ )}

Below is an example light curve.{' '} View the source page to learn more about the @@ -72,17 +90,6 @@ export function Main() { ) : (

)} -

- Below is a plot of sources by their (RA,Dec) position. Click a source's - marker to view its light curve and data on its source page. -

- {initialLoadData?.sources ? ( -
- -
- ) : ( -
- )}
); }