diff --git a/package-lock.json b/package-lock.json index c6a1064..71d14e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "plotly.js-dist-min": "^3.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-range": "^1.10.0", "react-router": "^8.3.0" }, "devDependencies": { @@ -4267,6 +4268,16 @@ "dev": true, "license": "MIT" }, + "node_modules/react-range": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/react-range/-/react-range-1.10.0.tgz", + "integrity": "sha512-kDo0LiBUHIQIP8menx0UoxTnHr7UXBYpIYl/DR9jCaO1o29VwvCLpkP/qOTNQz5hkJadPg1uEM07XJcJ1XGoKw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/react-router": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", diff --git a/package.json b/package.json index 7243b59..8780b23 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "plotly.js-dist-min": "^3.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-range": "^1.10.0", "react-router": "^8.3.0" }, "devDependencies": { diff --git a/src/components/AllSkyMap.tsx b/src/components/AllSkyMap.tsx index 46f66aa..949fbc9 100644 --- a/src/components/AllSkyMap.tsx +++ b/src/components/AllSkyMap.tsx @@ -1,14 +1,28 @@ -import { CSSProperties, useEffect, useRef, useState } from 'react'; +import { + CSSProperties, + useCallback, + useEffect, + useRef, + useState, + useMemo, +} from 'react'; +import SourceFluxFilter from './SourceFluxFilter'; +import { MIN_MAX_FLUX_VALUES } from '../configs/constants'; +import { FREQUENCY_COLORS, SO_FALLBACK_COLOR } from '../configs/socolors'; 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?: CSSProperties['height']; @@ -19,10 +33,31 @@ interface HoveredSource { name: string; ra: number; dec: number; - x: number; y: number; + /** Which side of the marker the tooltip is anchored to, and how far from it; + * lets the tooltip flip to the marker's left near the right edge instead of + * overflowing the (overflow: hidden) all-sky-wrapper. */ + horizontal: { side: 'left' | 'right'; offset: number }; } +// Rough upper bound on the tooltip's rendered width (name + RA/Dec lines), used to decide +// whether anchoring it to the marker's right edge would run it past the container's edge. +const TOOLTIP_WIDTH_ESTIMATE = 180; + +// Creates a shape function for Aladin's catalogs used to update the marker color +const getShapeFunction = + (appliedBand: string) => + (source: { x: number; y: number }, canvasCtx: CanvasRenderingContext2D) => { + canvasCtx.beginPath(); + canvasCtx.arc(source.x, source.y, 4, 0, 2 * Math.PI, false); + canvasCtx.closePath(); + // Sets AllSkyMap marker colors to the filter's applied freq band, if selected + // and defined in FREQUENCY_COLORS + canvasCtx.fillStyle = FREQUENCY_COLORS[appliedBand] ?? SO_FALLBACK_COLOR; + canvasCtx.globalAlpha = 0.8; + canvasCtx.fill(); + }; + /** * 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 @@ -30,6 +65,7 @@ interface HoveredSource { */ export default function AllSkyMap({ sources, + bands, title = 'Sources by position', subtitle = "Click a source's marker to preview its light curve", height = 600, @@ -37,6 +73,9 @@ export default function AllSkyMap({ }: 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 @@ -91,12 +130,17 @@ export default function AllSkyMap({ typeof object.ra === 'number' && typeof object.dec === 'number' ) { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const wouldOverflowRight = + xyMouseCoords.x + TOOLTIP_WIDTH_ESTIMATE + 12 > containerWidth; setHoveredSource({ name, ra: object.ra, dec: object.dec, - x: xyMouseCoords.x, y: xyMouseCoords.y, + horizontal: wouldOverflowRight + ? { side: 'right', offset: containerWidth - xyMouseCoords.x } + : { side: 'left', offset: xyMouseCoords.x }, }); } }); @@ -128,14 +172,7 @@ export default function AllSkyMap({ 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(); - }, + shape: getShapeFunction(''), }); aladin.addCatalog(catalog); catalog.addSources( @@ -146,13 +183,69 @@ 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]); + + // 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 + // effect shares the `sources` dependency), so a newly rebuilt catalog picks the active filter + // back up instead of momentarily showing every source. + useEffect(() => { + const catalog = catalogRef.current; + if (!catalog) return; + const visibleIds = new Set(visibleSources.map((s) => s.sourceId)); + catalog.setShape(getShapeFunction(appliedBand)); + catalog.getSources().forEach((s) => { + const isVisible = visibleIds.has(s.data?.sourceId); + if (isVisible) { + s.show(); + } else { + s.hide(); + } + }); + }, [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); + }, []); + + const handleClearFilter = useCallback(() => { + setAppliedBand(''); + setAppliedRange(MIN_MAX_FLUX_VALUES); + }, []); + return (
-
-

{title}

-

{subtitle}

+
+
+

{title}

+

{subtitle}

+
+
{hoveredSource.name}
RA: {hoveredSource.ra.toFixed(3)}°
diff --git a/src/components/FluxFilterMenu.tsx b/src/components/FluxFilterMenu.tsx new file mode 100644 index 0000000..8756be1 --- /dev/null +++ b/src/components/FluxFilterMenu.tsx @@ -0,0 +1,228 @@ +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 TooltipButton from './TooltipButton'; + +interface FluxFilterMenuProps { + sources: SkySource[]; + bands: Set; + initialBand: string; + initialRange: number[]; + onApply: (band: string, range: number[]) => void; + onClear: () => void; + onClose: () => void; +} + +/** Clamps a source's number input to the slider's bounds, falling back to the previous + * value on non-numeric input (e.g. while the field is momentarily empty) instead of letting + * NaN silently break every flux comparison. */ +function clampFluxInput(rawValue: string, previousValue: number): number { + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) return previousValue; + return Math.min( + Math.max(parsed, MIN_MAX_FLUX_VALUES[0]), + MIN_MAX_FLUX_VALUES[1] + ); +} + +/** + * The popup menu for choosing a frequency band + median flux range to filter the all-sky map + * by. Keeps its own in-progress ("pending") selections local so dragging the slider only + * re-renders this menu, not the rest of the map - see SourceFluxFilter for how it's mounted. + */ +export default function FluxFilterMenu({ + sources, + bands, + initialBand, + initialRange, + onApply, + onClear, + onClose, +}: FluxFilterMenuProps) { + const [pendingBand, setPendingBand] = useState(initialBand); + const [pendingRange, setPendingRange] = useState(initialRange); + + const tempFilteredSources = useMemo(() => { + if (pendingBand === '') return sources; + return sources.filter((s) => { + const flux = s.properties?.median_flux[pendingBand]; + return flux != null && flux >= pendingRange[0] && flux <= pendingRange[1]; + }); + }, [sources, pendingBand, pendingRange]); + + const disableApplyFilterBtn = pendingBand === ''; + + const trackBackground = useMemo(() => { + return getTrackBackground({ + values: pendingRange, + colors: ['#ccc', '#548BF4', '#ccc'], + min: MIN_MAX_FLUX_VALUES[0], + max: MIN_MAX_FLUX_VALUES[1], + }); + }, [pendingRange]); + + return ( +
+ + + + setPendingRange(vals)} + renderThumb={({ props, isDragged }) => { + const { key, ...thumbProps } = props; + return ( +
+
+
+ ); + }} + renderTrack={({ props, children }) => ( +
+
+ {children} +
+
+ )} + /> +
+ + to + +
+ + {tempFilteredSources.length} of {sources.length} sources match + +
+ { + if (disableApplyFilterBtn) return; + onApply(pendingBand, pendingRange); + onClose(); + }} + disabled={disableApplyFilterBtn} + > + Apply filter + + { + setPendingBand(''); + setPendingRange(MIN_MAX_FLUX_VALUES); + onClear(); + }} + > + Clear filter + +
+
+ ); +} diff --git a/src/components/Lightcurve.tsx b/src/components/Lightcurve.tsx index dcdbad2..3f0fd97 100644 --- a/src/components/Lightcurve.tsx +++ b/src/components/Lightcurve.tsx @@ -18,6 +18,7 @@ import { } from '../types'; import Plotly, { Config, + Data, Datum, PlotMouseEvent, ScatterData, @@ -424,6 +425,17 @@ export function Lightcurve({ return; } + // Batch every real trace's marker.line update into a single Plotly.restyle call instead of + // one call per trace: Plotly.restyle isn't free, and a source with many module/frequency + // traces (each with many unbinned points) turned "one call per trace" into a multi-second + // stall on every click and on every tooltip close. Using the dotted-path attribute form + // (rather than a nested `marker: {...}` object) also means restyle only touches + // marker.line.* and leaves marker.color/symbol alone, so there's no need to re-supply them + // defensively on every call. + const traceIndices: number[] = []; + const widths: number[][] = []; + const colors: string[][] = []; + plotData.forEach((d, i) => { // Skip legend-only proxy traces (see makeLegendProxyTrace) - they carry no real flagged // data (data.flags is empty) and restyling them would just overwrite their fixed, @@ -432,9 +444,6 @@ export function Lightcurve({ return; } - // see if band has a marker with styles applied (note: currently just a marker width of 2) - const hasStyledMarker = d.marker.line.width.indexOf(2); - // get a clean marker config that can be used for a reset or to update a single marker const baseMarkerConfig = generateBaseMarkerConfig(d); @@ -447,27 +456,22 @@ export function Lightcurve({ } } - // generateBaseMarkerConfig only sets size/line - Plotly.restyle replaces the whole - // marker object with what's given rather than merging it, so any property left out - // (color, symbol) gets wiped and falls back to Plotly's defaults (positional colorway, - // circle). Re-include the band's real color/symbol so every restyle call - which fires - // on every click and on reset - doesn't undo the socolors styling. - const newMarkerConfig = { - marker: { - ...baseMarkerConfig.marker, - color: d.marker.color, - symbol: d.marker.symbol, - }, - }; - - void Plotly.restyle(plotElement, newMarkerConfig, [i]); - - if (hasStyledMarker !== -1) { - // if the band had a styled marker, then we've already removed all marker styles via the - // newMarkerConfig and can break out of the forEach - return; - } + traceIndices.push(i); + widths.push(baseMarkerConfig.marker.line.width); + colors.push(baseMarkerConfig.marker.line.color); }); + + // @types/plotly.js only models restyle's nested-object update form (Partial), + // not the dotted-attribute-path form used here - the latter is standard, documented + // Plotly.js API (see Plotly.restyle docs), just not one the types account for. + void Plotly.restyle( + plotElement, + { + 'marker.line.width': widths, + 'marker.line.color': colors, + } as unknown as Data, + traceIndices + ); }, [plotData] ); diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 061fb7b..fd01f39 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -72,16 +72,29 @@ export function Main() { // useQuery), so memoizing this transform keeps the array passed to AllSkyMap referentially // stable across re-renders const sources = allSources?.sources; - const skySources: SkySource[] = useMemo( - () => - sources?.map((s) => ({ - ra: s.ra, - dec: s.dec, - name: s.name, - sourceId: s.source_id, - })) ?? [], - [sources] - ); + const allSkyData: { skySources: SkySource[]; bands: Set } = + useMemo(() => { + const bands = new Set(); + const skySources = + sources?.map((s) => { + const source = { + ra: s.ra, + dec: s.dec, + name: s.name, + sourceId: s.source_id, + } as SkySource; + + if (s.properties && s.properties.median_flux) { + source['properties'] = s.properties; + for (const band of Object.keys(s.properties['median_flux'])) { + if (!bands.has(band)) bands.add(band); + } + } + + return source; + }) ?? []; + return { bands, skySources }; + }, [sources]); // 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 @@ -110,9 +123,10 @@ export function Main() { return (
- {skySources ? ( + {allSkyData ? ( ) : ( diff --git a/src/components/SourceFluxFilter.tsx b/src/components/SourceFluxFilter.tsx new file mode 100644 index 0000000..7a195c5 --- /dev/null +++ b/src/components/SourceFluxFilter.tsx @@ -0,0 +1,86 @@ +import { memo, useState } from 'react'; +import FluxFilterMenu from './FluxFilterMenu'; +import TooltipButton from './TooltipButton'; +import type { SkySource } from './AllSkyMap'; +import './styles/flux-filter.css'; + +interface SourceFluxFilterProps { + sources: SkySource[]; + bands: Set; + visibleCount: number; + appliedBand: string; + appliedRange: number[]; + onApply: (band: string, range: number[]) => void; + onClear: () => void; +} + +/** + * Header controls for filtering the all-sky map's sources by frequency band + median flux + * range: the "N of M sources" / active-filter summary, the button that opens the filter menu, + * and the menu itself. Memoized because AllSkyMap re-renders on every hovered-marker change + * (mousemove over the sky map), which has nothing to do with this filter UI. + */ +function SourceFluxFilter({ + sources, + bands, + visibleCount, + appliedBand, + appliedRange, + onApply, + onClear, +}: SourceFluxFilterProps) { + const [showFluxFilter, setShowFluxFilter] = useState(false); + const hasActiveFilter = appliedBand !== ''; + + return ( + <> +
+

+ Showing {visibleCount} of {sources.length} sources +

+ {hasActiveFilter && ( +
+

+ Filtered on {appliedBand} from {appliedRange[0]} to{' '} + {appliedRange[1]} Jy +

+ + ❌ + +
+ )} +
+
+
+ setShowFluxFilter(true)} + > + Filter by median flux + +
+
+ {showFluxFilter && ( + setShowFluxFilter(false)} + /> + )} + + ); +} + +export default memo(SourceFluxFilter); diff --git a/src/components/TooltipButton.tsx b/src/components/TooltipButton.tsx new file mode 100644 index 0000000..e9744d2 --- /dev/null +++ b/src/components/TooltipButton.tsx @@ -0,0 +1,50 @@ +import { ReactNode, useState } from 'react'; + +interface TooltipButtonProps { + tooltipText: string; + tooltipClassName: string; + buttonClassName: string; + onClick: () => void; + /** NOTE: uses aria-disabled in order to propagate hover events */ + disabled?: boolean; + title?: string; + ariaLabel?: string; + children: ReactNode; +} + +/** + * A button that shows a small tooltip on hover. Renders no wrapping element of its own - + * callers are expected to render it inside an already-positioned container (see + * .flux-filter-btn-container / .active-filter-subtitle-container in flux-filter.css) so the + * tooltip's `position: absolute` has the right element to anchor to. + */ +export default function TooltipButton({ + tooltipText, + tooltipClassName, + buttonClassName, + onClick, + disabled, + title, + ariaLabel, + children, +}: TooltipButtonProps) { + const [showTooltip, setShowTooltip] = useState(false); + + return ( + <> + + {showTooltip &&
{tooltipText}
} + + ); +} diff --git a/src/components/styles/flux-filter.css b/src/components/styles/flux-filter.css new file mode 100644 index 0000000..5f28f60 --- /dev/null +++ b/src/components/styles/flux-filter.css @@ -0,0 +1,176 @@ +/* Styles for the flux-filter UI used in the AllSkyMap on the main page; + affects the filter menu, Aladin-like buttons (w/ tooltips), and the + "filter details" display in the header +*/ + +.flux-filter-controls { + align-self: center; + width: 250px; +} + +.flux-filter-btn-container { + position: relative; + font-family: monospace; + color: #ececec; + display: flex; + justify-content: end; + width: 100%; +} + +.flux-filter-btn, +.flux-filter-btn-tooltip, +.flux-filter-menu, +.close-flux-menu-btn, +.clear-filter-btn, +.clear-filter-btn-tooltip { + color: inherit; + background: black; + border: 1px solid #ececec; + border-radius: 5px; + font-size: 14px; +} + +.close-flux-menu-btn { + position: absolute; + top: 4px; + right: 4px; +} + +.flux-filter-btn { + height: 30px; +} + +.flux-filter-btn-tooltip, +.clear-filter-btn-tooltip { + position: absolute; + padding: 0 6px; + z-index: 10; +} + +.flux-filter-btn-tooltip { + height: auto; + bottom: -24px; +} + +.clear-filter-btn-tooltip { + font-size: 13px; + right: 3px; + top: 20px; +} + +.clear-filter-btn { + font-size: 10px; +} + +.flux-filter-btn:hover, +.close-flux-menu-btn:hover, +.clear-filter-btn:hover { + border-color: greenyellow; +} + +.flux-filter-menu { + position: absolute; + z-index: 20; + padding: 5px; + right: 3px; + display: flex; + flex-direction: column; +} + +.flux-filter-menu > label { + width: 300px; + display: flex; + flex-direction: column; + font-weight: bold; +} + +.flux-filter-menu > label > select { + border: 1px solid #ececec; + background-color: black; + color: #ececec; + margin: 5px; + margin-right: 25px; + padding-top: 5px; + padding-bottom: 5px; + border-radius: 5px; +} + +.min-max-inputs { + display: flex; + column-gap: 10px; + align-items: end; + margin: 0 10px; +} + +.min-max-inputs > label { + display: flex; + flex-direction: column; + font-weight: bold; + font-size: 11px; + font-style: italic; + flex-grow: 1; +} + +.filter-details-container { + width: 300px; +} + +.active-filter-subtitle-container { + position: relative; + display: flex; + column-gap: 5px; +} + +.active-filter-count, +.active-filter-details { + margin: 0; + text-wrap: nowrap; +} + +.active-filter-count { + font-weight: bold; + font-size: 13px; +} + +.active-filter-details { + font-style: italic; + font-size: 12px; +} + +.apply-filter-btn.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.apply-filter-btn.disabled:hover { + cursor: not-allowed; + border-color: inherit; +} + +.filter-btns-container { + display: flex; + justify-content: center; + column-gap: 15px; +} + +.flux-filter-btn-tooltip.apply { + left: 25px; +} + +.flux-filter-btn-tooltip.clear { + right: 25px; +} + +.flux-filter-btn.clear:hover { + border-color: red; +} + +.temp-filters-details { + font-size: 12px; + font-style: italic; + margin: 5px 10px; +} + +.filter-btns-container > button { + width: 125px; +} diff --git a/src/configs/constants.ts b/src/configs/constants.ts index f64a5e2..19c7fd8 100644 --- a/src/configs/constants.ts +++ b/src/configs/constants.ts @@ -34,3 +34,5 @@ export const DEFAULT_HOMEPAGE_PLOT_LAYOUT = { width: DEFAULT_PLOT_LAYOUT.width * 0.75, height: DEFAULT_PLOT_LAYOUT.height * 0.75, }; + +export const MIN_MAX_FLUX_VALUES = [0.001, 5]; diff --git a/src/index.css b/src/index.css index 4ee289e..4a27183 100644 --- a/src/index.css +++ b/src/index.css @@ -146,15 +146,28 @@ footer { margin-top: auto; } +.all-sky-header { + display: flex; + justify-content: space-between; + margin: 0 4px; +} + +/* Shared with Lightcurve.tsx's own title-container - position:absolute overlays the title on + top of that component's plot. AllSkyMap's header needs the title to sit inline as a flex + item instead, so it opts back out below. */ .title-container { position: absolute; z-index: 1; } +.all-sky-header .title-container { + position: static; + z-index: auto; +} + .title-text, .subtitle-text { margin: 0; - margin-left: 10px; } .title-text { @@ -169,6 +182,7 @@ footer { .all-sky-wrapper { position: relative; color: white; + overflow: hidden; } .sources-plot-container.all-sky { @@ -177,13 +191,6 @@ footer { max-width: none; } -/* 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; diff --git a/src/types/aladin.d.ts b/src/types/aladin.d.ts index 15cbb59..1e33c48 100644 --- a/src/types/aladin.d.ts +++ b/src/types/aladin.d.ts @@ -14,6 +14,14 @@ interface AladinClickedObject { dec?: number; } +type AladinSource = { + data: { + sourceId: string; + }; + show: () => void; + hide: () => void; +}; + interface Aladin { gotoRaDec: (ra: number, dec: number) => void; addCatalog: (catalog: unknown) => void; @@ -40,6 +48,13 @@ interface Aladin { interface Catalog { addSources: (markers: object[]) => unknown; + getSources: () => AladinSource[]; + setShape: ( + shape: ( + source: { x: number; y: number }, + canvasCtx: CanvasRenderingContext2D + ) => void + ) => void; } interface CatalogOptions { diff --git a/src/types/index.ts b/src/types/index.ts index 05c7b5b..b14a24b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,9 @@ export type SourceResponse = { cross_matches?: { name: string }[]; socat_id?: number; }; + properties?: { + median_flux: Record; + }; }; export type SourcesFeedItem = {