{allSkyData ? (
-
) : (
diff --git a/src/components/SkyExplorer.tsx b/src/components/SkyExplorer.tsx
new file mode 100644
index 0000000..7b7607d
--- /dev/null
+++ b/src/components/SkyExplorer.tsx
@@ -0,0 +1,233 @@
+import {
+ useCallback,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Link } from 'react-router';
+import { ColumnDef, InitialTableState } from '@tanstack/react-table';
+import AllSkyMap from './AllSkyMap';
+import { SkySource, DataFileExtensions } from '../types';
+import { DATA_EXT_OPTIONS, MIN_MAX_FLUX_VALUES } from '../configs/constants';
+import { Table } from './Table';
+import { DownloadIcon } from './icons/DownloadIcon';
+import { LinkOutIcon } from './icons/LinkOutIcon';
+import { prefersReducedMotion } from '../utils/prefersReducedMotion';
+import { lightcurveApi } from '../api/client';
+import './styles/sky-explorer.css';
+
+export interface SkyExplorerProps {
+ sources: SkySource[];
+ bands: Set;
+ onSourceClick: (id: string) => void;
+ title?: string;
+ subtitle?: string;
+ height?: number;
+}
+
+// Module-level so its identity never changes; the table is paginated because at the default
+// all-sky zoom every source is in view.
+const TABLE_INITIAL_STATE: InitialTableState = {
+ pagination: { pageSize: 25 },
+};
+
+// Name isn't here since its cell needs SkyExplorer's preview handler; see `columns` below.
+const POSITION_COLUMNS: ColumnDef[] = [
+ {
+ header: 'RA (deg)',
+ accessorFn: (row) => row.ra,
+ cell: ({ row }) => (
+ {row.original.ra.toFixed(3)}
+ ),
+ size: 100,
+ },
+ {
+ header: 'Dec (deg)',
+ accessorFn: (row) => row.dec,
+ cell: ({ row }) => (
+
+ {row.original.dec.toFixed(3)}
+
+ ),
+ size: 100,
+ },
+];
+
+/**
+ * Renders the all-sky map and, below it, a table of the sources that are both within the map's
+ * current field of view and pass its active flux filter. Owns the state shared between the two so
+ * that map interactions only re-render this subtree, not the rest of the home page (Main.tsx).
+ */
+export function SkyExplorer({
+ sources,
+ bands,
+ onSourceClick,
+}: SkyExplorerProps) {
+ const [appliedBand, setAppliedBand] = useState('');
+ const [appliedRange, setAppliedRange] = useState(MIN_MAX_FLUX_VALUES);
+ // The set of sources currently shown on the map. Derived (rather than copied into its own
+ // state on "Apply") so that it automatically recomputes if `sources` itself changes (e.g. a
+ // refetch) while a filter is active; otherwise, a stale filter snapshot would keep hiding
+ // markers from the old source list after the catalog below has already been rebuilt with new
+ // ones.
+ const visibleSources = useMemo(() => {
+ if (appliedBand === '') return sources;
+ return sources.filter((s) => {
+ const flux = s.properties?.median_flux[appliedBand];
+ return flux != null && flux >= appliedRange[0] && flux <= appliedRange[1];
+ });
+ }, [sources, appliedBand, appliedRange]);
+
+ // Ids of every source (filtered or not) within the map's current field of view, reported by
+ // AllSkyMap once the camera settles after a pan/zoom/resize. null until Aladin has
+ // initialized, meaning "treat every source as in view".
+ const [inViewIds, setInViewIds] = useState | null>(null);
+ const [dataDownloadExt, setDataDownloadExt] = useState(DATA_EXT_OPTIONS[0]);
+
+ const sourcesInView = useMemo(
+ () =>
+ inViewIds
+ ? visibleSources.filter((s) => inViewIds.has(s.sourceId))
+ : visibleSources,
+ [visibleSources, inViewIds]
+ );
+
+ // Lets React render the (possibly large) table update at a lower priority than the map, so
+ // re-sorting/re-rendering rows never blocks interacting with Aladin.
+ const deferredSourcesInView = useDeferredValue(sourcesInView);
+
+ const mapRef = useRef(null);
+
+ // Opens the same lightcurve preview a map marker click does, then scrolls the map back into
+ // view; makes clear to the user that the lightcurve preview has opened
+ const handlePreview = useCallback(
+ (sourceId: string) => {
+ onSourceClick(sourceId);
+ mapRef.current?.scrollIntoView({
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
+ block: 'start',
+ });
+ },
+ [onSourceClick]
+ );
+
+ // Only changes when a filter is applied/cleared or when download extension
+ // selector changes; handlePreview is stable, since Main's onSourceClick is
+ const columns = useMemo[]>(() => {
+ return [
+ {
+ header: 'Name',
+ accessorFn: (row) => row.name,
+ cell: ({ row }) => (
+
+ ),
+ size: 100,
+ enableSorting: false,
+ },
+ ...POSITION_COLUMNS,
+ {
+ header:
+ 'Median Flux' +
+ (appliedBand === '' ? ', (unselected)' : `, ${appliedBand} (mJy)`),
+ accessorFn: (row) => row.properties?.median_flux[appliedBand],
+ cell: ({ row }) => {
+ const flux = row.original.properties?.median_flux[appliedBand];
+ return flux == null ? (
+ '-'
+ ) : (
+ {flux.toFixed(3)}
+ );
+ },
+ enableSorting: appliedBand === '' ? false : true,
+ },
+ {
+ id: 'sourcePage',
+ header: 'Source Page',
+ cell: ({ row }) => (
+
+
+
+ ),
+ size: 60,
+ enableSorting: false,
+ },
+ {
+ id: 'download',
+ header: () => (
+
+ ),
+ cell: ({ row }) => (
+
+ ),
+ size: 100,
+ enableSorting: false,
+ },
+ ];
+ }, [appliedBand, handlePreview, dataDownloadExt]);
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/src/components/SourceFluxFilter.tsx b/src/components/SourceFluxFilter.tsx
index ed9e011..080586a 100644
--- a/src/components/SourceFluxFilter.tsx
+++ b/src/components/SourceFluxFilter.tsx
@@ -1,7 +1,7 @@
import { memo, useState } from 'react';
import FluxFilterMenu from './FluxFilterMenu';
import TooltipButton from './TooltipButton';
-import type { SkySource } from './AllSkyMap';
+import type { SkySource } from '../types';
import './styles/flux-filter.css';
interface SourceFluxFilterProps {
diff --git a/src/components/Table.tsx b/src/components/Table.tsx
index 4beee74..7b772b6 100644
--- a/src/components/Table.tsx
+++ b/src/components/Table.tsx
@@ -16,6 +16,7 @@ export type TableProps = {
initialState?: InitialTableState;
paginationControlsPosition?: 'top' | 'bottom' | 'both';
className?: string;
+ paginationClassName?: string;
customPaginationState?: {
totalItems: number;
itemsPerPage: number;
@@ -37,6 +38,7 @@ export function Table({
initialState = undefined,
className = undefined,
customPaginationState,
+ paginationClassName = undefined,
sortable = true,
paginationControlsPosition = 'bottom',
loading = false,
@@ -54,14 +56,15 @@ export function Table({
});
return (
-
+
+
+
+
{isPaginated && ['top', 'both'].includes(paginationControlsPosition) && (
)}
-
+
{table.getHeaderGroups().map((headerGroup) => (