Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/AladinViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function AladinViewer({ source, nearbySources }: AladinViewerProps) {
return () => {
aladinInstanceRef.current = null;
};
}, [source.source_id, source.ra, source.dec, nearbySources]);
}, [source.source_id, source.name, source.ra, source.dec, nearbySources]);

return <div ref={aladinContainerRef} className="aladin-viewer-container" />;
}
2 changes: 1 addition & 1 deletion src/components/FluxFilterMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default function FluxFilterMenu({
))}
</select>
</label>
<label>Median flux range (Jy)</label>
<label>Median flux range (mJy)</label>
<ReactRange
values={pendingRange}
min={MIN_MAX_FLUX_VALUES[0]}
Expand Down
35 changes: 29 additions & 6 deletions src/components/Lightcurve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ type LightcurveProps = {
legendBottomRowYOffset?: number;
hideStrategyToggle?: boolean;
hideFlaggedObsToggle?: boolean;
/** Whether flagged observations start out hidden; note that hiding the flagged obs toggle
* would hold the value determined by this prop. Defaults to true. */
defaultHideFlaggedData?: boolean;
/** Whether a flagged point gets the red marker outline. Defaults to true. A caller can turn it
* off when the styling isn't meaningful for its data, e.g. unassigned measurements */
showFlaggedMarkerStyling?: boolean;
title?: string;
subtitle?: string;
};
Expand Down Expand Up @@ -207,6 +213,8 @@ export function Lightcurve({
legendBottomRowYOffset = DEFAULT_INSTRUMENT_LEGEND_LAYOUT.legend2.y,
hideStrategyToggle,
hideFlaggedObsToggle,
defaultHideFlaggedData = true,
showFlaggedMarkerStyling = true,
title,
subtitle,
}: LightcurveProps) {
Expand All @@ -220,7 +228,9 @@ export function Lightcurve({
const plotElementId = `lightcurve-plot-${useId()}`;
const [isDataReady, setIsDataReady] = useState(false);

const [hideFlaggedData, setHideFlaggedData] = useState(true);
const [hideFlaggedData, setHideFlaggedData] = useState(
defaultHideFlaggedData
);

const [viewMode, setViewMode] = useState<'binned' | 'unbinned'>('unbinned');
const [binnedStartTime, setBinnedStartTime] = useState('');
Expand Down Expand Up @@ -419,7 +429,13 @@ export function Lightcurve({
}

data.module[idx] = module;
populatePoint(data, lightcurve, lightcurveKey, idx, isFlagged);
populatePoint(
data,
lightcurve,
lightcurveKey,
idx,
isFlagged && showFlaggedMarkerStyling
);
});

for (const data of tracesByModule.values()) {
Expand All @@ -434,7 +450,7 @@ export function Lightcurve({
const data = {
// Used for the marker click tooltip's header (see handleMarkerClick) -
// showlegend is false below since the dual legend explains color/shape instead.
name: `${lightcurveKey}, f${lightcurve.frequency}`,
name: `${instrumentModule}, f${lightcurve.frequency}`,
showlegend: false,
visible: isGroupHidden(lightcurve.frequency, instrumentModule)
? 'legendonly'
Expand Down Expand Up @@ -475,7 +491,13 @@ export function Lightcurve({
return;
}

populatePoint(data, lightcurve, lightcurveKey, idx, isFlagged);
populatePoint(
data,
lightcurve,
lightcurveKey,
idx,
isFlagged && showFlaggedMarkerStyling
);
});

finalData.push(data);
Expand Down Expand Up @@ -507,6 +529,7 @@ export function Lightcurve({
lightcurveData,
hideFlaggedData,
hiddenLegendGroups,
showFlaggedMarkerStyling,
]);

/**
Expand All @@ -519,7 +542,7 @@ export function Lightcurve({
margin: { t: legendMarginTop },
yaxis: {
title: {
text: 'Flux Density (Jy)',
text: 'Flux Density (mJy)',
},
},
xaxis: {
Expand Down Expand Up @@ -946,7 +969,7 @@ export function Lightcurve({
<span>Flux Density:</span>
{String(Number(clickedMarkerData.data.y).toFixed(3))} +/-{' '}
{String(Number(clickedMarkerData.data.flux_err).toFixed(3))}{' '}
Jy
mJy
</p>
<p>
<span>Flags:</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/SourceFluxFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function SourceFluxFilter({
<div className="active-filter-subtitle-container">
<p className="active-filter-details subtitle-text">
Filtered on {appliedBand} from {appliedRange[0]} to{' '}
{appliedRange[1]} Jy
{appliedRange[1]} mJy
</p>
<TooltipButton
buttonClassName="clear-filter-btn"
Expand Down
134 changes: 28 additions & 106 deletions src/components/UnassignedLightcurvePlot.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,47 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Plotly, { Config, Data, Layout } from 'plotly.js-dist-min';
import { useMemo } from 'react';
import { UnassignedFluxMeasurement } from '../types';
import { frequencyColor, moduleSymbol } from '../configs/socolors';
import {
buildInstrumentLegendTraces,
groupMeasurementsByInstrument,
INSTRUMENT_LEGEND_LAYOUT,
INSTRUMENT_LEGEND_MARGIN,
} from '../utils/unassignedPlotHelpers';
import { Lightcurve } from './Lightcurve';
import { toInstrumentLightcurveData } from '../utils/unassignedPlotHelpers';
import { DEFAULT_PLOT_LAYOUT } from '../configs/constants';

type UnassignedLightcurvePlotProps = {
sourceId: string;
measurements: UnassignedFluxMeasurement[];
height?: number;
};

const PLOT_CONFIG: Partial<Config> = {
displaylogo: false,
responsive: true,
};

function buildTrace(key: string, group: UnassignedFluxMeasurement[]): Data {
const [frequency, module] = key.split(':');
const color = frequencyColor(Number(frequency));
return {
name: `${module} · f${frequency}`,
// The frequency/optics-tube legend is built separately from legend-only proxy traces (see
// buildInstrumentLegendTraces) so it can split color and shape into two legends.
showlegend: false,
x: group.map((m) => new Date(m.time)),
y: group.map((m) => m.flux),
error_y: {
type: 'data',
array: group.map((m) => m.flux_err),
visible: true,
color: color,
thickness: 1.5,
},
customdata: group.map((m) => [m.frequency, m.module]),
mode: 'markers',
type: 'scatter',
marker: {
color,
symbol: moduleSymbol(module),
size: 6,
line: { width: 1, color },
},
hovertemplate:
'Time: %{x|%Y-%m-%d %H:%M:%S UTC}<br>' +
'Flux: %{y:.3f} mJy<br>' +
'Frequency: %{customdata[0]} GHz<br>' +
'Optic tube: %{customdata[1]}<extra></extra>',
};
}

/** Interactive flux-versus-time plot for an unassigned source; one marker trace per
* (frequency, module), colored by frequency and shaped by optics tube. */
/** Interactive flux-versus-time plot for an unassigned source, rendered through the same
* Lightcurve component an assigned source's plot uses. There are no cutout images for
* unassigned measurements yet, so a clicked marker's tooltip will show "Cutout Not Found"
* for now. */
export function UnassignedLightcurvePlot({
sourceId,
measurements,
height = 400,
}: UnassignedLightcurvePlotProps) {
const plotRef = useRef<HTMLDivElement | null>(null);
const [isDataReady, setIsDataReady] = useState(false);

const plotData = useMemo<Data[]>(() => {
const grouped = groupMeasurementsByInstrument(measurements);
return [
...Array.from(grouped.entries()).map(([key, group]) =>
buildTrace(key, group)
),
...buildInstrumentLegendTraces(measurements),
];
}, [measurements]);

const plotLayout = useMemo<Partial<Layout>>(() => {
const maxFlux = measurements.length
? Math.max(...measurements.map((m) => m.flux))
: 1;
return {
autosize: true,
margin: INSTRUMENT_LEGEND_MARGIN,
hovermode: 'closest',
showlegend: true,
...INSTRUMENT_LEGEND_LAYOUT,
xaxis: { title: { text: 'Observation time (UTC)' } },
yaxis: {
title: { text: 'Flux (mJy)' },
range: [0, maxFlux * 1.05],
zeroline: false,
},
};
}, [measurements]);

useEffect(() => {
const el = plotRef.current;
if (!el) return;

setIsDataReady(false);
void Plotly.newPlot(el, plotData, plotLayout, PLOT_CONFIG).then(() =>
setIsDataReady(true)
);

return () => {
Plotly.purge(el);
};
}, [plotData, plotLayout]);
const lightcurveData = useMemo(
() => toInstrumentLightcurveData(sourceId, measurements),
[sourceId, measurements]
);

if (!measurements.length) {
return <p className="small-text">No detections to plot.</p>;
}

return (
<div className="unassigned-plot-wrapper" style={{ height }}>
<div
ref={plotRef}
style={{ visibility: isDataReady ? 'visible' : 'hidden', height }}
/>
{!isDataReady && (
<div className="unassigned-plot-loading" style={{ height }}>
Loading...
</div>
)}
</div>
<Lightcurve
lightcurveData={lightcurveData}
plotLayout={{ width: DEFAULT_PLOT_LAYOUT.width, height }}
legendMarginTop={120}
legendBottomRowYOffset={1.1}
hideStrategyToggle
hideFlaggedObsToggle
// Unlike an assigned source's review flags, an unassigned measurement's extra.flags can
// carry simulation/provenance tags (see toInstrumentLightcurveData) rather than "this point
// is bad"; in practice nearly every point ends up flagged, so there's neither a toggle to
// hide them nor a red outline calling them out; both would be meaningless here.
defaultHideFlaggedData={false}
showFlaggedMarkerStyling={false}
/>
);
}
5 changes: 4 additions & 1 deletion src/components/UnassignedSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,10 @@ export function UnassignedSource() {
showCard={data?.source.status !== 'merged'}
>
{data ? (
<UnassignedLightcurvePlot measurements={data.flux} />
<UnassignedLightcurvePlot
sourceId={data.source.source_id}
measurements={data.flux}
/>
) : (
'Loading...'
)}
Expand Down
10 changes: 5 additions & 5 deletions src/configs/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,26 @@ export const statsKeysToDisplaySpecs: Record<string, StatsDisplaySpecs> = {
min_flux: {
label: 'Min Flux',
precision: numSigFigs,
units: 'Jy',
units: 'mJy',
},
max_flux: {
label: 'Max Flux',
precision: numSigFigs,
units: 'Jy',
units: 'mJy',
},
mean_flux: {
label: 'Mean Flux',
precision: numSigFigs,
units: 'Jy',
units: 'mJy',
},
stddev_flux: {
label: 'Std. Dev. Flux',
precision: numSigFigs,
units: 'Jy',
units: 'mJy',
},
median_flux: {
label: 'Median Flux',
precision: numSigFigs,
units: 'Jy',
units: 'mJy',
},
};
47 changes: 46 additions & 1 deletion src/utils/unassignedPlotHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Data, Legend } from 'plotly.js-dist-min';
import { UnassignedFluxMeasurement } from '../types';
import {
InstrumentLightcurveData,
InstrumentLightcurveMeasurements,
UnassignedFluxMeasurement,
} from '../types';
import {
buildInstrumentLegendTraces as buildInstrumentLegendTracesForItems,
DEFAULT_INSTRUMENT_LEGEND_LAYOUT as BASE_INSTRUMENT_LEGEND_LAYOUT,
Expand All @@ -23,6 +27,47 @@ export function groupMeasurementsByInstrument(
return grouped;
}

/** Converts a flat list of unassigned flux measurements into the same InstrumentLightcurveData
* shape Lightcurve.tsx expects for an assigned source's instrument-strategy lightcurve, grouped
* by (frequency, module) via groupMeasurementsByInstrument - each unassigned measurement is
* already a fixed (frequency, module) pair, exactly like an instrument-strategy point, just not
* yet grouped into a record keyed that way. Lets UnassignedLightcurvePlot.tsx render an
* unassigned source's flux history through the same Lightcurve component/interactions (flagging,
* marker click, cutouts once those exist for unassigned measurements too) as an assigned source,
* rather than maintaining a second plot implementation. */
export function toInstrumentLightcurveData(
sourceId: string,
measurements: UnassignedFluxMeasurement[]
): InstrumentLightcurveData {
const lightcurves: Record<string, InstrumentLightcurveMeasurements> = {};

for (const [key, group] of groupMeasurementsByInstrument(measurements)) {
lightcurves[key] = {
frequency: group[0].frequency,
source_id: sourceId,
module: group[0].module,
measurement_id: group.map((m) => m.measurement_id),
time: group.map((m) => m.time),
ra: group.map((m) => m.ra),
ra_uncertainty: group.map((m) => m.ra_uncertainty ?? 0),
dec: group.map((m) => m.dec),
dec_uncertainty: group.map((m) => m.dec_uncertainty ?? 0),
flux: group.map((m) => m.flux),
flux_err: group.map((m) => m.flux_err),
extra: group.map((m) => m.extra ?? null),
};
}

return {
source_id: sourceId,
// Unassigned measurements aren't fetched through the binned/unbinned endpoints - "none" is
// the same convention BaseLightcurveData uses for an unbinned assigned-source response.
binning_strategy: 'none',
selection_strategy: 'instrument',
lightcurves,
};
}

/** Offsets a position from the source position onto the source's tangent plane, in arcminutes.
* Equivalent to astropy's SkyCoord.spherical_offsets_to at the arcminute scales these plots
* cover. */
Expand Down
Loading