From 7ef233aa54062614f6fb53bc9bff114458933d42 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Wed, 30 Sep 2026 11:54:43 -0400 Subject: [PATCH 1/4] Add and amend className props for table components --- src/components/Table.tsx | 8 ++++++-- src/components/TablePaginationControls.tsx | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) 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) => ( @@ -163,6 +166,7 @@ export function Table({ )} diff --git a/src/components/TablePaginationControls.tsx b/src/components/TablePaginationControls.tsx index eab53a8..187d300 100644 --- a/src/components/TablePaginationControls.tsx +++ b/src/components/TablePaginationControls.tsx @@ -5,6 +5,7 @@ import { useCallback } from 'react'; type TablePaginationControlsProps = { table: Table; customPaginationState: TableProps['customPaginationState']; + className?: string; }; /** @@ -18,6 +19,7 @@ type TablePaginationControlsProps = { export function TablePaginationControls({ table, customPaginationState, + className = undefined, }: TablePaginationControlsProps) { const handleGoToFirstPage = useCallback(() => { if (customPaginationState) { @@ -77,7 +79,7 @@ export function TablePaginationControls({ : (pagination.pageIndex + 1) * pagination.pageSize; return ( -
+
Displaying{' '} From 6eb6fffed54768c9ac27dbde2d59f57408ac43e8 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Wed, 30 Sep 2026 12:09:38 -0400 Subject: [PATCH 2/4] Extent aladin type interface and resolve lint warning --- src/components/AladinViewer.tsx | 2 +- src/types/aladin.d.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/AladinViewer.tsx b/src/components/AladinViewer.tsx index 29bf0ff..b1472e6 100644 --- a/src/components/AladinViewer.tsx +++ b/src/components/AladinViewer.tsx @@ -84,7 +84,7 @@ export function AladinViewer({ source, nearbySources }: AladinViewerProps) { return () => { aladinInstanceRef.current = null; }; - }, [source.source_id, source.ra, source.dec, nearbySources]); + }, [source.name, source.ra, source.dec, nearbySources]); return
; } diff --git a/src/types/aladin.d.ts b/src/types/aladin.d.ts index 240989d..39b99a9 100644 --- a/src/types/aladin.d.ts +++ b/src/types/aladin.d.ts @@ -28,6 +28,10 @@ interface Aladin { setProjection: (projection: string) => void; setFov: (fovDegrees: number) => void; getFov: () => [number, number]; + /** Projects a world position to [x, y] pixel coordinates relative to the view's top-left; + * null if the position can't be projected (e.g. outside the projection's domain). Positions + * outside the visible canvas can still project, so callers must bounds-check the result. */ + world2pix: (ra: number, dec: number) => [number, number] | null; /** Only the events we actually listen for are typed here; add more as needed. */ on: { ( @@ -45,6 +49,21 @@ interface Aladin { event: 'fullScreenToggled', callback: (isFullscreen: boolean) => void ): void; + /** Fires continuously while panning (dragging is true mid-drag). */ + ( + event: 'positionChanged', + callback: (position: { + ra: number; + dec: number; + dragging: boolean; + }) => void + ): void; + /** Fires continuously while zooming. */ + (event: 'zoomChanged', callback: (fovDegrees: number) => void): void; + ( + event: 'resizeChanged', + callback: (width: number, height: number) => void + ): void; }; /** Detaches every catalog/overlay layer added via addCatalog/addOverlay. */ removeLayers: () => void; From 96d968c6b3a5c2abb0fddbbaa0634fe435ea1fa2 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Wed, 30 Sep 2026 12:43:00 -0400 Subject: [PATCH 3/4] Implement table to accompany AllSkyMap in main page --- src/components/AllSkyMap.tsx | 165 +++++++++++------ src/components/FluxFilterMenu.tsx | 2 +- src/components/Main.tsx | 12 +- src/components/SkyExplorer.tsx | 233 +++++++++++++++++++++++++ src/components/SourceFluxFilter.tsx | 2 +- src/components/styles/sky-explorer.css | 79 +++++++++ src/types/index.ts | 10 ++ src/utils/prefersReducedMotion.ts | 4 + 8 files changed, 447 insertions(+), 60 deletions(-) create mode 100644 src/components/SkyExplorer.tsx create mode 100644 src/components/styles/sky-explorer.css create mode 100644 src/utils/prefersReducedMotion.ts diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx index a9a2ffc..43ceb9a 100644 --- a/src/components/AllSkyMap.tsx +++ b/src/components/AllSkyMap.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import SourceFluxFilter from './SourceFluxFilter'; import { MIN_MAX_FLUX_VALUES } from '../configs/constants'; import { @@ -6,25 +6,19 @@ import { SO_FALLBACK_COLOR, frequencyKey, } from '../configs/socolors'; +import { SkyExplorerProps } from './SkyExplorer'; +import { SkySource } from '../types'; -export interface SkySource { - sourceId: string; - ra: number; - dec: number; - name: string; - properties?: { - median_flux: Record; - }; -} - -interface AllSkyMapProps { - sources: SkySource[]; - bands: Set; - title?: string; - subtitle?: string; - height?: number; - setClickedSourceId: (id: string) => void; -} +type AllSkyMapProps = SkyExplorerProps & { + appliedBand: string; + setAppliedBand: (band: string) => void; + appliedRange: number[]; + setAppliedRange: (fluxes: number[]) => void; + visibleSources: SkySource[]; + /** Called with the ids of every source (filtered or not) within the map's current field of + * view, once the camera settles; only called when that set actually changes. */ + setInViewIds: (ids: Set) => void; +}; interface HoveredSource { name: string; @@ -49,6 +43,11 @@ const TOOLTIP_WIDTH_ESTIMATE = 180; // whether anchoring it below the marker would run it past the container's bottom edge. const TOOLTIP_HEIGHT_ESTIMATE = 70; +// How long the camera has to sit still (no pan/zoom/resize events) before the in-view source +// set is recomputed. Aladin fires positionChanged/zoomChanged on nearly every frame of a drag or +// scroll, so recomputing (and re-rendering the parent) on each one would make panning choppy. +const IN_VIEW_DEBOUNCE_MS = 150; + // Creates a shape function for Aladin's catalogs used to update the marker color const getShapeFunction = (appliedBand: string) => @@ -67,22 +66,26 @@ const getShapeFunction = /** * 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). + * 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({ +function AllSkyMap({ sources, bands, title = 'Sources by position', subtitle = "Click a source's marker to preview its light curve", height = 600, - setClickedSourceId, + onSourceClick, + appliedBand, + setAppliedBand, + appliedRange, + setAppliedRange, + visibleSources, + setInViewIds, }: AllSkyMapProps) { const containerRef = useRef(null); const aladinInstanceRef = useRef(null); const catalogRef = useRef(null); - const [appliedBand, setAppliedBand] = useState(''); - const [appliedRange, setAppliedRange] = useState(MIN_MAX_FLUX_VALUES); // 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 @@ -90,8 +93,8 @@ export default function AllSkyMap({ // 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 setClickedSourceIdRef = useRef(onSourceClick); + setClickedSourceIdRef.current = onSourceClick; const [isDataReady, setIsDataReady] = useState(false); const [hoveredSource, setHoveredSource] = useState( @@ -101,11 +104,66 @@ export default function AllSkyMap({ // real Fullscreen API; instead, it makes the container div position:fixed and covers the whole // viewport via a CSS class. That means xyMouseCoords (already relative to the container's own // top-left) become viewport-relative too, but our tooltip's own position:absolute is anchored - // to all-sky-wrapper - a box that no longer corresponds to where the map is actually rendered + // to all-sky-wrapper, a box that no longer corresponds to where the map is actually rendered // once the container escapes it via position:fixed. Tracking this lets the tooltip switch to // position:fixed itself (see the render below) so it keeps tracking the cursor in both modes. const [isFullscreen, setIsFullscreen] = useState(false); + // Read through refs by scheduleInViewUpdate below so it can keep a permanently stable identity + // (it's registered once as an Aladin event handler) while still seeing the latest values. + const setInViewIdsRef = useRef(setInViewIds); + setInViewIdsRef.current = setInViewIds; + const sourcesRef = useRef(sources); + sourcesRef.current = sources; + const lastInViewIdsRef = useRef | null>(null); + const inViewTimeoutRef = useRef(undefined); + + // Debounced (see IN_VIEW_DEBOUNCE_MS): each call restarts the timer, so the recompute only runs + // once the camera has settled. Tests every source rather than just the filtered ones, so a + // filter change never needs a recompute + const scheduleInViewUpdate = useCallback(() => { + window.clearTimeout(inViewTimeoutRef.current); + inViewTimeoutRef.current = window.setTimeout(() => { + const aladin = aladinInstanceRef.current; + const el = containerRef.current; + if (!aladin || !el) return; + + // Zero while the page is hidden (display:none -> see App.tsx); Aladin can still emit events + // then, and every source would test as out of view. The camera can't move while hidden, so + // the last reported set is still correct. + const viewWidth = el.clientWidth; + const viewHeight = el.clientHeight; + if (viewWidth === 0 || viewHeight === 0) return; + + const ids = new Set(); + for (const s of sourcesRef.current) { + const xy = aladin.world2pix(s.ra, s.dec); + if ( + xy && + xy[0] >= 0 && + xy[0] <= viewWidth && + xy[1] >= 0 && + xy[1] <= viewHeight + ) { + ids.add(s.sourceId); + } + } + + // Small pans (especially zoomed out) often don't move any source in or out of view; skip + // re-rendering the parent entirely when that happens. + const prev = lastInViewIdsRef.current; + if ( + prev && + prev.size === ids.size && + [...ids].every((id) => prev.has(id)) + ) { + return; + } + lastInViewIdsRef.current = ids; + setInViewIdsRef.current(ids); + }, IN_VIEW_DEBOUNCE_MS); + }, []); + // 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(() => { @@ -173,8 +231,14 @@ export default function AllSkyMap({ aladin.on('fullScreenToggled', (isInFullscreen) => { setIsFullscreen(isInFullscreen); + // The view's size changes along with fullscreen, which changes what's in view. + scheduleInViewUpdate(); }); + aladin.on('positionChanged', scheduleInViewUpdate); + aladin.on('zoomChanged', scheduleInViewUpdate); + aladin.on('resizeChanged', scheduleInViewUpdate); + setIsDataReady(true); }) .catch(() => { @@ -184,9 +248,10 @@ export default function AllSkyMap({ return () => { cancelled = true; }; - // Intentionally empty: this must only run once for the component's whole lifetime (see - // setClickedSourceIdRef comment above for why setClickedSourceId itself isn't a dependency here). - }, []); + // This must only run once for the component's whole lifetime (see setClickedSourceIdRef + // comment above for why setClickedSourceId itself isn't a dependency here); + // scheduleInViewUpdate has a permanently stable identity, so listing it doesn't change that. + }, [scheduleInViewUpdate]); // 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 @@ -213,20 +278,10 @@ export default function AllSkyMap({ ) ); catalogRef.current = catalog; - }, [sources, isDataReady]); - - // 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]); + // A new source list means a new in-view set even if the camera hasn't moved; this also + // produces the initial set once Aladin is ready. + scheduleInViewUpdate(); + }, [sources, isDataReady, scheduleInViewUpdate]); // Applies the derived visible set to the Aladin catalog. Re-runs whenever `visibleSources` // changes, which includes right after the catalog-rebuild effect above runs (since that @@ -248,16 +303,20 @@ export default function AllSkyMap({ }, [visibleSources, appliedBand]); // Stable identities so the memoized SourceFluxFilter doesn't re-render just because AllSkyMap - // re-rendered for an unrelated reason (e.g. hoveredSource changing on every mouse move). - const handleApplyFilter = useCallback((band: string, range: number[]) => { - setAppliedBand(band); - setAppliedRange(range); - }, []); + // re-rendered for an unrelated reason (e.g. hoveredSource changing on every mouse move). The + // setters are SkyExplorer's useState setters, so they never change either. + const handleApplyFilter = useCallback( + (band: string, range: number[]) => { + setAppliedBand(band); + setAppliedRange(range); + }, + [setAppliedBand, setAppliedRange] + ); const handleClearFilter = useCallback(() => { setAppliedBand(''); setAppliedRange(MIN_MAX_FLUX_VALUES); - }, []); + }, [setAppliedBand, setAppliedRange]); return (
@@ -312,3 +371,7 @@ export default function AllSkyMap({
); } + +// Memoized so SkyExplorer re-rendering for table-only reasons (a new in-view set, which never +// changes any of this component's props) doesn't re-render the map. +export default memo(AllSkyMap); diff --git a/src/components/FluxFilterMenu.tsx b/src/components/FluxFilterMenu.tsx index 8756be1..cd766bc 100644 --- a/src/components/FluxFilterMenu.tsx +++ b/src/components/FluxFilterMenu.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { Range as ReactRange, getTrackBackground } from 'react-range'; import { MIN_MAX_FLUX_VALUES } from '../configs/constants'; -import type { SkySource } from './AllSkyMap'; +import type { SkySource } from '../types'; import TooltipButton from './TooltipButton'; interface FluxFilterMenuProps { diff --git a/src/components/Main.tsx b/src/components/Main.tsx index ef50c51..f69830c 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -2,6 +2,7 @@ import { FrequencyLightcurveData, InstrumentLightcurveData, SourceResponse, + SkySource, } from '../types'; import { useQuery } from '../hooks/useQuery'; import { Lightcurve } from './Lightcurve'; @@ -16,9 +17,10 @@ import { useState, } from 'react'; import { lightcurveApi } from '../api/client'; -import AllSkyMap, { SkySource } from './AllSkyMap'; import { LinkOutIcon } from './icons/LinkOutIcon'; import { CloseIcon } from './icons/CloseIcon'; +import { SkyExplorer } from './SkyExplorer'; +import { prefersReducedMotion } from '../utils/prefersReducedMotion'; /** Duration of the dialog's toast-like enter/exit transition; must match the CSS transition * duration on .home-light-curve in index.css; used as a fallback in case 'transitionend' never @@ -27,10 +29,6 @@ const DIALOG_ANIM_DURATION_MS = 220; type DialogPhase = 'closed' | 'entering' | 'open' | 'exiting'; -function prefersReducedMotion() { - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; -} - /** Renders the "home" page of the web app */ export function Main() { // The animated inner wrapper (not the outer positioned container) - see dialogPhase below. @@ -250,10 +248,10 @@ export function Main() {
{allSkyData ? ( - ) : (
diff --git a/src/components/SkyExplorer.tsx b/src/components/SkyExplorer.tsx new file mode 100644 index 0000000..7dfc03e --- /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 7a195c5..35da054 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/styles/sky-explorer.css b/src/components/styles/sky-explorer.css new file mode 100644 index 0000000..4997bfb --- /dev/null +++ b/src/components/styles/sky-explorer.css @@ -0,0 +1,79 @@ +.all-sky-table-wrapper { + background-color: white; + margin: 10px 5px; +} + +.all-sky-table-wrapper table { + border: none; +} + +.all-sky-pagination { + background-color: black; + color: white; + margin: 0; + padding-left: 5px; + padding-top: 10px; +} + +.all-sky-pagination > .pagination-controls > .pagination-btn { + border: 1px solid var(--so-blue-darker); + background-color: var(--so-blue); + color: white; + font-weight: bolder; + padding-top: 4px; + padding-bottom: 4px; + cursor: pointer; + border-radius: 4px; +} + +.all-sky-pagination > .pagination-controls > .pagination-btn:hover { + background-color: var(--so-blue-darker); +} + +.all-sky-pagination > .pagination-controls > .pagination-btn:disabled { + cursor: not-allowed; + background-color: var(--so-blue-darker); +} + +/* Makes the Name column's preview button read as a link rather than a button */ +.source-name-btn { + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + text-align: left; +} + +.source-name-btn:hover { + text-decoration: underline; +} + +.all-sky-table-wrapper > table > tbody > tr > td { + text-align: center; +} + +.main-table-download { + display: flex; + flex-direction: column; + font-size: 0.8em; +} + +.main-table-download-btn { + background-color: white; + border: 1px solid var(--bg-so-orange-darker); + cursor: pointer; + color: var(--bg-so-orange-darker); +} + +.main-table-download-btn:hover { + background-color: var(--bg-so-orange-lighter); + cursor: pointer; +} + +.main-table-download-btn:disabled { + background-color: #eee; + color: #bbb; + border: 1px solid #bbb; + cursor: not-allowed; +} diff --git a/src/types/index.ts b/src/types/index.ts index af08665..3e365a5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -271,3 +271,13 @@ export function isFrequencyLightcurveData( return false; } } + +export interface SkySource { + sourceId: string; + ra: number; + dec: number; + name: string; + properties?: { + median_flux: Record; + }; +} diff --git a/src/utils/prefersReducedMotion.ts b/src/utils/prefersReducedMotion.ts new file mode 100644 index 0000000..9a261fb --- /dev/null +++ b/src/utils/prefersReducedMotion.ts @@ -0,0 +1,4 @@ +/** Whether the user has asked the OS/browser to minimize non-essential motion. */ +export function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} From b010f291cabe88cb55df312b29b0d8f0434fcca0 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 1 Oct 2026 12:26:26 -0400 Subject: [PATCH 4/4] Enable pagination above and below main page's table w/ style tweaks --- src/components/SkyExplorer.tsx | 2 +- src/components/styles/sky-explorer.css | 69 ++++++++++++++++++++++++-- src/index.css | 1 + 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/components/SkyExplorer.tsx b/src/components/SkyExplorer.tsx index 7dfc03e..7b7607d 100644 --- a/src/components/SkyExplorer.tsx +++ b/src/components/SkyExplorer.tsx @@ -224,7 +224,7 @@ export function SkyExplorer({ data={deferredSourcesInView} columns={columns} initialState={TABLE_INITIAL_STATE} - paginationControlsPosition="bottom" + paginationControlsPosition="both" className="all-sky-table-wrapper" paginationClassName="all-sky-pagination" /> diff --git a/src/components/styles/sky-explorer.css b/src/components/styles/sky-explorer.css index 4997bfb..f1040d6 100644 --- a/src/components/styles/sky-explorer.css +++ b/src/components/styles/sky-explorer.css @@ -1,18 +1,79 @@ .all-sky-table-wrapper { - background-color: white; - margin: 10px 5px; + --all-sky-table-border-radius: 5px; + margin: 5px; } +/* Rounded corners need separate borders (border-radius is ignored on collapsed tables), so each + cell only draws its right/bottom edge to avoid doubled inner borders. The background lives on + the table rather than the wrapper so nothing white shows outside the rounded corners. */ .all-sky-table-wrapper table { border: none; + border-collapse: separate; + border-spacing: 0; + border-radius: var(--all-sky-table-border-radius); + overflow: hidden; + background-color: white; +} + +.all-sky-table-wrapper th, +.all-sky-table-wrapper td { + border: none; + border-right: 1px solid var(--border-soft-blue-gray); + border-bottom: 1px solid var(--border-soft-blue-gray); +} + +.all-sky-table-wrapper thead { + background-color: var(--bg-soft-blue-gray); +} + +/* .all-sky-table-wrapper th { + color: var(--so-blue-darker); +} */ + +.all-sky-table-wrapper tbody > tr:nth-of-type(even) { + background-color: var(--bg-light-blue); +} + +.all-sky-table-wrapper th:last-child, +.all-sky-table-wrapper td:last-child { + border-right: none; +} + +.all-sky-table-wrapper tbody > tr:last-child > td { + border-bottom: none; +} + +/* Rounds the corner cells too, so their row/header backgrounds can't square off the corners */ +.all-sky-table-wrapper thead > tr:first-child > th:first-child { + border-top-left-radius: var(--all-sky-table-border-radius); +} + +.all-sky-table-wrapper thead > tr:first-child > th:last-child { + border-top-right-radius: var(--all-sky-table-border-radius); +} + +.all-sky-table-wrapper tbody > tr:last-child > td:first-child { + border-bottom-left-radius: var(--all-sky-table-border-radius); +} + +.all-sky-table-wrapper tbody > tr:last-child > td:last-child { + border-bottom-right-radius: var(--all-sky-table-border-radius); } .all-sky-pagination { background-color: black; color: white; margin: 0; - padding-left: 5px; - padding-top: 10px; + height: fit-content; + padding: 5px; +} + +.all-sky-pagination:first-child { + padding-top: 0; +} + +.all-sky-pagination:last-child { + padding-bottom: 0; } .all-sky-pagination > .pagination-controls > .pagination-btn { diff --git a/src/index.css b/src/index.css index edcb5c4..349333a 100644 --- a/src/index.css +++ b/src/index.css @@ -16,6 +16,7 @@ --bg-so-orange-lighter: rgb(254 242 242); --bg-soft-blue-gray: #d9e6f0; --bg-light-blue: #eaf2f8; + --border-soft-blue-gray: #b8cddd; --border-so-orange: rgba(242, 101, 34, 0.4); }