diff --git a/src/components/Lightcurve.tsx b/src/components/Lightcurve.tsx index 4e101ce..3f45c82 100644 --- a/src/components/Lightcurve.tsx +++ b/src/components/Lightcurve.tsx @@ -2,6 +2,7 @@ import { useCallback, useState, useEffect, + useId, useMemo, useRef, ChangeEvent, @@ -12,6 +13,7 @@ import { FrequencyLightcurveData, FrequencyLightcurveMeasurements, InstrumentLightcurveData, + InstrumentLightcurveMeasurements, isFrequencyLightcurveData, } from '../types'; import Plotly, { @@ -27,6 +29,13 @@ import { useQuery } from '../hooks/useQuery'; import { generateBaseMarkerConfig } from '../utils/lightcurveDataHelpers'; import { ToggleSwitch } from './ToggleSwitch'; import { CUTOUT_EXT_OPTIONS, DEFAULT_PLOT_LAYOUT } from '../configs/constants'; +import { + SO_BASE_COLORWAY, + frequencyColor, + frequencySymbol, + moduleColor, + moduleSymbol, +} from '../configs/socolors'; import { DownloadIcon } from './icons/DownloadIcon'; import { lightcurveApi } from '../api/client'; @@ -69,6 +78,10 @@ export type BaseScatterData = ScatterData & { }; marker: { size: number; + /** One color/symbol per trace: instrument-strategy traces are one frequency, and + * frequency-strategy traces are split one-per-module (see plotData) so this always holds. */ + color: string; + symbol: string; line: { width: number[]; color: string[]; @@ -94,6 +107,79 @@ type BasePlotDatum = PlotDatum & { }; }; +/** Fills in the fields shared by every trace type at a given point index; shared between the + * instrument-strategy (one trace per frequency) and frequency-strategy (one trace per module) + * branches of plotData. */ +function populatePoint( + data: BaseScatterData, + lightcurve: + | FrequencyLightcurveMeasurements + | InstrumentLightcurveMeasurements, + lightcurveKey: string, + idx: number, + isFlagged: boolean +) { + data.x[idx] = new Date(lightcurve.time[idx]); + data.y[idx] = lightcurve.flux[idx]; + data.error_y.array[idx] = lightcurve.flux_err[idx]; + data.measurementId[idx] = lightcurve.measurement_id[idx]; + data.flags[idx] = + lightcurve.extra[idx] && 'flags' in lightcurve.extra[idx] ? 1 : 0; + data.customdata[idx] = lightcurveKey; + + if (isFlagged) { + data.marker.line.color[idx] = 'red'; + data.marker.line.width[idx] = 1.5; + } else { + data.marker.line.color[idx] = '#000'; + // Initially all non-flagged marker lineWidths are 0 so that they do not show; rather, we set + // a marker's lineWidth to 1 only when clicked or hovered + data.marker.line.width[idx] = 0; + } +} + +/** Plotly's legend swatch mirrors marker.line.color[0]/width[0] for array-valued marker.line, so + * a trace whose first point happens to be flagged (red outline) shows a red legend icon even + * though the trace's real marker color/symbol is correct - this can happen any time the first + * point of a trace is flagged, not just when every point is. A companion "legend-only" proxy + * trace with a single null point and a constant, always-neutral marker.line gives the legend a + * stable swatch, fully decoupled from the real trace's per-point flagged/clicked outline styling. + * Pairing it via legendgroup keeps "click legend to hide/show" working for both traces together. */ +function makeLegendProxyTrace( + name: string, + legendgroup: string, + color: string, + symbol: string +): BaseScatterData { + return { + name, + legendgroup, + showlegend: true, + hoverinfo: 'skip', + x: [null] as Datum[], + y: [null] as Datum[], + error_y: { + type: 'data', + array: [] as Datum[], + color: undefined, + thickness: 1.0, + width: 1.0, + }, + type: 'scatter', + mode: 'markers', + marker: { + size: 5, + color, + symbol, + line: { color: ['#000'], width: [0] }, + }, + hovertemplate: '(%{x}, %{y:.1f} +/- %{error_y.array:.1f})', + measurementId: [] as Datum[], + flags: [] as Datum[], + customdata: [] as Datum[], + } as BaseScatterData; +} + /** Uses Plotly to generate a source's lightcurve. Currently plots all lightcurves of a source. */ export function Lightcurve({ lightcurveData, @@ -107,6 +193,12 @@ export function Lightcurve({ }: LightcurveProps) { // set up to use a plotlyRef instead of react-plotly for more control const plotlyRef = useRef(null); + // Unique per mounted instance: Main's own Lightcurve stays permanently mounted in the + // background (see App.tsx), so more than one Lightcurve can exist in the DOM at once. Plotly.js + // uses this container's id internally for more than just our own restyle calls - two elements + // sharing a literal "lightcurve-plot" id caused click events on one plot to be attributed to + // the other instance's data. useId() guarantees this can never collide between instances. + const plotElementId = `lightcurve-plot-${useId()}`; const [isDataReady, setIsDataReady] = useState(false); const [hideFlaggedData, setHideFlaggedData] = useState(false); @@ -123,57 +215,109 @@ export function Lightcurve({ queryKey: [clickedMarkerData], queryFn: async () => { if (clickedMarkerData) { - return await lightcurveApi.getCutoutUrl( - lightcurveData.source_id, - clickedMarkerData.measurementId, - CUTOUT_EXT_OPTIONS[0] - ); + try { + return await lightcurveApi.getCutoutUrl( + lightcurveData.source_id, + clickedMarkerData.measurementId, + CUTOUT_EXT_OPTIONS[0] + ); + } catch { + return 'Not Found'; + } } }, }); /** A plotly-compatible data structure derived from the lightcurveData prop */ const plotData = useMemo(() => { - const finalData = []; + const finalData: (FrequencyScatterData | BaseScatterData)[] = []; const lightcurveKeys = Object.keys(lightcurveData.lightcurves); + const isFrequencyLightcurve = isFrequencyLightcurveData(lightcurveData); + for (const lightcurveKey of lightcurveKeys) { const lightcurve = lightcurveData.lightcurves[lightcurveKey]; - let data: FrequencyScatterData | BaseScatterData; - - const isFrequencyLightcurve = isFrequencyLightcurveData(lightcurveData); if (isFrequencyLightcurve) { - data = { - // String used in the plot legend - name: '', - x: [] as Datum[], - y: [] as Datum[], - error_y: { - type: 'data', - array: [] as Datum[], - color: undefined, - thickness: 1.0, - width: 1.0, - }, - type: 'scatter', - mode: 'markers', - marker: { - size: 5, - line: { - width: [] as number[], - color: [] as string[], - }, - }, - hovertemplate: '(%{x}, %{y:.1f} +/- %{error_y.array:.1f})', - measurementId: [] as Datum[], - module: [] as Datum[], - flags: [] as Datum[], - customdata: [] as Datum[], - } as FrequencyScatterData; + // One trace per module, not one trace per lightcurveKey: a frequency-strategy + // lightcurve can span multiple modules (see FrequencyLightcurveMeasurements.module + // being an array), so a single shared trace would need a per-point color/symbol and + // couldn't have one coherent legend name. Splitting gives each module its own + // correctly-named, correctly-colored trace instead. + const tracesByModule = new Map(); + + lightcurve.extra.forEach((extra, idx) => { + const isFlagged = !!(extra && 'flags' in extra && extra.flags.length); + + // Exclude data point if flagged and hideFlaggedData is true + if (hideFlaggedData && isFlagged) { + return; + } + + const module = ( + lightcurve.module as FrequencyLightcurveMeasurements['module'] + )[idx]; + + let data = tracesByModule.get(module); + if (!data) { + data = { + // String used in the plot legend + name: `${module}, f${lightcurve.frequency}`, + // Real per-point flagged/clicked styling below; see makeLegendProxyTrace for why + // this trace itself is hidden from the legend. + legendgroup: `${lightcurveKey}:${module}`, + showlegend: false, + x: [] as Datum[], + y: [] as Datum[], + error_y: { + type: 'data', + array: [] as Datum[], + color: undefined, + thickness: 1.0, + width: 1.0, + }, + type: 'scatter', + mode: 'markers', + marker: { + size: 5, + color: moduleColor(module), + symbol: moduleSymbol(module), + line: { + width: [] as number[], + color: [] as string[], + }, + }, + hovertemplate: '(%{x}, %{y:.1f} +/- %{error_y.array:.1f})', + measurementId: [] as Datum[], + module: [] as Datum[], + flags: [] as Datum[], + customdata: [] as Datum[], + } as FrequencyScatterData; + tracesByModule.set(module, data); + } + + data.module[idx] = module; + populatePoint(data, lightcurve, lightcurveKey, idx, isFlagged); + }); + + for (const [module, data] of tracesByModule) { + finalData.push(data); + finalData.push( + makeLegendProxyTrace( + data.name, + `${lightcurveKey}:${module}`, + moduleColor(module), + moduleSymbol(module) + ) + ); + } } else { - data = { + const data = { // String used in the plot legend name: `${lightcurveKey}, f${lightcurve.frequency}`, + // Real per-point flagged/clicked styling below; see makeLegendProxyTrace for why + // this trace itself is hidden from the legend. + legendgroup: lightcurveKey, + showlegend: false, x: [] as Datum[], y: [] as Datum[], error_y: { @@ -187,6 +331,10 @@ export function Lightcurve({ mode: 'markers', marker: { size: 5, + // Scalar, not per-point: an instrument-strategy trace is one fixed frequency band + // (lightcurve.frequency), so every point in it shares the same color/symbol. + color: frequencyColor(lightcurve.frequency), + symbol: frequencySymbol(lightcurve.frequency), line: { width: [] as number[], color: [] as string[], @@ -197,51 +345,30 @@ export function Lightcurve({ flags: [] as Datum[], customdata: [] as Datum[], } as BaseScatterData; - } - - // We expect each array of data in the lightcurve's data to be equal length, so - // we could have picked any of them to iterate over - lightcurve.extra.forEach((extra, idx) => { - const isFlagged = !!(extra && 'flags' in extra && extra.flags.length); - // Exclude data point if flagged and hideFlaggedData is true - if (hideFlaggedData && isFlagged) { - return; - } - - const day = new Date(lightcurve.time[idx]); - // Use the index of current iteration to set the data in the various arrays defined in this - // band's `data` object - const flux = lightcurve.flux[idx]; - const errorY = lightcurve.flux_err[idx]; - data.x[idx] = day; - data.y[idx] = flux; - data.error_y.array[idx] = errorY; - data.measurementId[idx] = lightcurve.measurement_id[idx]; - data.flags[idx] = - lightcurve.extra[idx] && 'flags' in lightcurve.extra[idx] ? 1 : 0; - data.customdata[idx] = lightcurveKey; - - if ('module' in data) { - data.name = `${lightcurve.module[idx]}, f${lightcurve.frequency}`; - data.module[idx] = ( - lightcurve.module as FrequencyLightcurveMeasurements['module'] - )[idx]; - } + // We expect each array of data in the lightcurve's data to be equal length, so + // we could have picked any of them to iterate over + lightcurve.extra.forEach((extra, idx) => { + const isFlagged = !!(extra && 'flags' in extra && extra.flags.length); - // marker fill and outline - if (isFlagged) { - data.marker.line.color[idx] = 'red'; - data.marker.line.width[idx] = 1.5; - } else { - data.marker.line.color[idx] = '#000'; - // Initially all non-flagged marker lineWidths are 0 so that they do not show; rather, we set a marker's lineWidth - // to 1 only when clicked or hovered - data.marker.line.width[idx] = 0; - } - }); + // Exclude data point if flagged and hideFlaggedData is true + if (hideFlaggedData && isFlagged) { + return; + } - finalData.push(data); + populatePoint(data, lightcurve, lightcurveKey, idx, isFlagged); + }); + + finalData.push(data); + finalData.push( + makeLegendProxyTrace( + data.name, + lightcurveKey, + frequencyColor(lightcurve.frequency), + frequencySymbol(lightcurve.frequency) + ) + ); + } } return finalData; @@ -271,15 +398,9 @@ export function Lightcurve({ xanchor: 'left', y: 1, }, - colorway: [ - '#f3cec9', - '#e7a4b6', - '#cd7eaf', - '#a262a9', - '#6f4d96', - '#3d3b72', - '#182844', - ], + // Only reached for traces without an explicit marker.color; every trace built above + // has one, so this is just a sane fallback rather than something actively used. + colorway: SO_BASE_COLORWAY, font: { family: 'sans-serif', }, @@ -294,23 +415,52 @@ export function Lightcurve({ pointIndex: number | undefined, reset: boolean ) => { + // Target this specific plot's DOM node, not the 'lightcurve-plot' id string: Main's own + // Lightcurve instance stays permanently mounted in the background (see App.tsx), so more + // than one element can share that id at once, and Plotly.restyle('lightcurve-plot', ...) + // would resolve to whichever one is first in the DOM regardless of which plot was clicked. + const plotElement = plotlyRef.current; + if (!plotElement) { + return; + } + 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, + // single-element marker.line arrays for no visual benefit. + if (d.showlegend) { + 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 newMarkerConfig = generateBaseMarkerConfig(d); + const baseMarkerConfig = generateBaseMarkerConfig(d); if (pointIndex !== undefined && i === curveNumber && !reset) { // we're requesting to update a marker on this band, so update it - if (newMarkerConfig.marker.line.width[pointIndex] === 1.5) { - newMarkerConfig.marker.line.color[pointIndex] = '#000'; + if (baseMarkerConfig.marker.line.width[pointIndex] === 1.5) { + baseMarkerConfig.marker.line.color[pointIndex] = '#000'; } else { - newMarkerConfig.marker.line.width[pointIndex] = 2; + baseMarkerConfig.marker.line.width[pointIndex] = 2; } } - void Plotly.restyle('lightcurve-plot', newMarkerConfig, [i]); + // 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 @@ -343,6 +493,8 @@ export function Lightcurve({ const { name } = data; + const bandColor = (e.points[0] as BasePlotDatum).fullData.marker.color; + // Create an object used for the tooltip's content and positioning const pointData = { x, @@ -353,7 +505,7 @@ export function Lightcurve({ pageY: e.event.offsetY, name, frequency: lightcurveData.lightcurves[key].frequency, - bandColor: (e.points[0] as BasePlotDatum).fullData.marker.color, + bandColor, }; setClickedMarkerData({ @@ -510,7 +662,7 @@ export function Lightcurve({
{clickedMarkerData && imageUrl && ( diff --git a/src/configs/socolors.ts b/src/configs/socolors.ts new file mode 100644 index 0000000..36aa903 --- /dev/null +++ b/src/configs/socolors.ts @@ -0,0 +1,113 @@ +/** + * Colors and Plotly marker symbols ported from https://github.com/simonsobs/socolors, so plots + * across the app use consistent, colorblind-friendly, SO-branded styling. socolors itself is a + * matplotlib style package with no JS distribution, so these values are copied directly from its + * `frequencies.py`/`lat.py`/`sat.py` modules rather than imported. + * + * Linestyles from socolors aren't ported here: lightview's lightcurve traces are markers-only + * (no connecting lines), so dash patterns don't apply. + */ + +/** Fallback color/colorway for anything not covered by the more specific maps below. */ +export const SO_FALLBACK_COLOR = '#BBBBBB'; +export const SO_BASE_COLORWAY = [ + '#F26522', + '#0077BB', + '#009988', + '#CC3311', + '#FF4488', + '#33BBEE', + '#BBBBBB', +]; + +export const FREQUENCY_COLORS: Record = { + f030: '#FF4488', + f040: '#CC3311', + f090: '#F26522', + f150: '#009988', + f220: '#33BBEE', + f280: '#0077BB', +}; + +export const FREQUENCY_SYMBOLS: Record = { + f030: 'x', + f040: 'cross', + f090: 'circle', + f150: 'square', + f220: 'triangle-up', + f280: 'triangle-down', +}; + +/** LAT optics-tube colors/symbols (o1-o6, i1-i6, c1). */ +export const LAT_COLORS: Record = { + o6: '#DC050C', + o5: '#4EB265', + o4: '#90C987', + o3: '#CAE0AB', + o2: '#F7F056', + i1: '#F7CB45', + i3: '#F4A736', + i4: '#EE8026', + i6: '#E65518', + o1: '#AE76A3', + i2: '#882E72', + i5: '#5289C7', + c1: '#7BAFDE', +}; + +export const LAT_SYMBOLS: Record = { + o6: 'x', + o5: 'triangle-right', + o4: 'triangle-left', + o3: 'pentagon', + o2: 'triangle-up', + i1: 'triangle-down', + i3: 'square', + i4: 'circle', + i6: 'diamond', + o1: 'y-up', + i2: 'y-right', + i5: 'y-down', + c1: 'y-left', +}; + +/** SAT platform colors/symbols (satp1-satp3). */ +export const SAT_COLORS: Record = { + satp1: '#33BBEE', + satp2: '#009988', + satp3: '#EE3377', +}; + +export const SAT_SYMBOLS: Record = { + satp1: 'circle', + satp2: 'diamond', + satp3: 'triangle-up', +}; + +/** Turns a raw frequency (e.g. 90) into socolors' zero-padded key (e.g. "f090"). */ +export function frequencyKey(frequency: number): string { + return `f${frequency.toString().padStart(3, '0')}`; +} + +export function frequencyColor(frequency: number): string { + return FREQUENCY_COLORS[frequencyKey(frequency)] ?? SO_FALLBACK_COLOR; +} + +export function frequencySymbol(frequency: number): string { + return FREQUENCY_SYMBOLS[frequencyKey(frequency)] ?? 'circle'; +} + +/** SAT module ids are prefixed "satp"; everything else is a LAT optics-tube id. */ +function isSatModule(module: string): boolean { + return module.startsWith('satp'); +} + +export function moduleColor(module: string): string { + const colors = isSatModule(module) ? SAT_COLORS : LAT_COLORS; + return colors[module] ?? SO_FALLBACK_COLOR; +} + +export function moduleSymbol(module: string): string { + const symbols = isSatModule(module) ? SAT_SYMBOLS : LAT_SYMBOLS; + return symbols[module] ?? 'circle'; +}