From 9ef0073abc4a7f7f61d6b275849d91e2e851935f Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 4 Sep 2026 18:00:52 -0700 Subject: [PATCH 01/21] FIREFLY-2066: Move spectral lines option from SpectrumOptions to its own dialog --- src/firefly/js/charts/ui/ChartPanel.jsx | 3 + src/firefly/js/charts/ui/PlotlyToolbar.jsx | 23 ++- .../js/charts/ui/options/SpectralLines.jsx | 195 +++++++++++++----- .../js/charts/ui/options/SpectrumOptions.jsx | 36 +--- src/firefly/js/visualize/ui/Buttons.jsx | 5 + 5 files changed, 172 insertions(+), 90 deletions(-) diff --git a/src/firefly/js/charts/ui/ChartPanel.jsx b/src/firefly/js/charts/ui/ChartPanel.jsx index 35bd0668a3..b7c87fcabe 100644 --- a/src/firefly/js/charts/ui/ChartPanel.jsx +++ b/src/firefly/js/charts/ui/ChartPanel.jsx @@ -12,6 +12,7 @@ import {useStoreConnector} from '../../ui/SimpleComponent.jsx'; import {allowPinnedCharts} from '../ChartUtil.js'; import {PinChart, ShowTable} from './PinnedChartContainer.jsx'; import {CombinePinnedCharts} from './CombineChart.jsx'; +import {useSpectralLinesSync} from './options/SpectralLines.jsx'; function ChartPanelView(props) { @@ -26,6 +27,8 @@ function ChartPanelView(props) { }; }, [chartId]); + useSpectralLinesSync(chartId); + if (isEmpty(chartData?.chartType) || isUndefined(Toolbar)) { return
; } diff --git a/src/firefly/js/charts/ui/PlotlyToolbar.jsx b/src/firefly/js/charts/ui/PlotlyToolbar.jsx index 77e68d2e2d..d2ce261e8d 100644 --- a/src/firefly/js/charts/ui/PlotlyToolbar.jsx +++ b/src/firefly/js/charts/ui/PlotlyToolbar.jsx @@ -17,12 +17,13 @@ import {HelpIcon} from '../../ui/HelpIcon.jsx'; import {showOptionsPopup} from '../../ui/PopupUtil.jsx'; import {CHART_ADDNEW, CHART_TRACE_MODIFY, showChartsDialog} from './ChartSelectPanel.jsx'; import {TableFilterPopup} from '../../tables/ui/FilterEditor'; -import {getTblIdFromChart, isScatter2d} from '../ChartUtil.js'; +import {getTblIdFromChart, isScatter2d, isSpectrum} from '../ChartUtil.js'; +import {SpectralLinesPanel} from './options/SpectralLines.jsx'; import {findViewerWithItemId, getLayoutType, getMultiViewRoot} from '../../visualize/MultiViewCntlr.js'; import {ListBoxInputFieldView} from 'firefly/ui/ListBoxInputField'; import { AddItem, CheckedButton, CheckedClearButton, ClearFilterButton, ExpandButton, - FilterAddButton, FilterButton, RestoreButton, SaveButton, SettingsButton, Zoom1XIcon, ZoomUpIcon, + FilterAddButton, FilterButton, RestoreButton, SaveButton, SettingsButton, SpectralLinesButton, Zoom1XIcon, ZoomUpIcon, } from '../../visualize/ui/Buttons.jsx'; import SelectIco from 'html/images/icons-2014/select.png'; @@ -82,6 +83,7 @@ function ScatterToolbar({chartId, expandable}) { {tbl_id && } + {isSpectrum(chartId) && } {expandable && } { help_id && } @@ -315,6 +317,12 @@ function OptionsBtn({chartId}) { ); } +function SpectralLinesBtn({chartId, activeTrace}) { + return ( + showSpectralLinesDialog({chartId, activeTrace})}/> + ); +} + export function AddBtn() { return ( , title: 'Filters', modal: true, show: true }); -} \ No newline at end of file +} + + +function showSpectralLinesDialog({chartId, activeTrace}) { + showOptionsPopup({ + title: 'Spectral Lines Options', + modal: false, + content: + }); +} diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 03ce665f3b..40bd99a360 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,17 +1,22 @@ import React, {useEffect} from 'react'; -import {get} from 'lodash'; +import {isEqual} from 'lodash'; import {Stack} from '@mui/joy'; import {SwitchInputField} from 'firefly/ui/SwitchInputField'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; import {useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; -import {getChartData} from '../../ChartsCntlr.js'; +import {getChartData, dispatchChartUpdate, CHART_UPDATE} from '../../ChartsCntlr.js'; +import {isSpectrum} from '../../ChartUtil.js'; import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; import {canUnitConv, convertUnitValue} from '../../dataTypes/SpectrumUnitConversion.js'; import {makeTblRequest} from 'firefly/tables/TableRequestUtil'; -import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableSelect} from 'firefly/tables/TablesCntlr'; -import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, splitVals} from 'firefly/tables/TableUtil'; +import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableSelect, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; +import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; +import {toBoolean} from 'firefly/util/WebUtil'; +import {FieldGroup} from 'firefly/ui/FieldGroup'; +import {getFieldVal} from 'firefly/fieldGroup/FieldGroupUtils'; +import {VALUE_CHANGE, MULTI_VALUE_CHANGE} from 'firefly/fieldGroup/FieldGroupCntlr'; const RECOMMENDED_LINES_TBL_ID = 'recommended-spectral-lines'; const RECOMMENDED_LINES_TBL_UI_ID = `${RECOMMENDED_LINES_TBL_ID}-ui`; @@ -25,6 +30,8 @@ const SPECTRAL_LINE_COLOR = 'gray'; const SPECTRAL_LINE_FONT_FAMILY = "'SF Mono', ui-monospace, monospace"; export const SPECTRAL_LINES_GROUP = 'lines'; +const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; + // Field keys and option values --- const ENABLED_KEY = 'spectralLines.enabled'; const SOURCE_OPTIONS_KEY = 'spectralLines.sourceOptions'; // comma-separated checked values, e.g. CheckboxGroupInputField's value @@ -96,6 +103,81 @@ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { })); } +/** + * Resolves the redshift to correct spectral lines against, from a chart's already-committed spectral frame state. + * Lines are rest-frame (lab) wavelengths; when the spectrum itself is shown in observed frame (i.e. not already + * rest-frame corrected), lines must be shifted by the same redshift to match - no shift needed in rest frame. + * @param {Array} fireflyData + * @param {number} activeTrace + * @returns {number} + */ +function resolveSpectralLinesRedshift(fireflyData, activeTrace) { + // TODO: spectralFrameOption is undefined until Modify Trace is applied at least once, so this + // assumes rest-frame (0) until then even if the real default would be observed with a redshift + const {value: sfOption, redshift: redshiftOption, userSpecified} = fireflyData?.[activeTrace]?.spectralFrameOption ?? {}; + if (sfOption !== 'observed') return 0; + const redshift = redshiftOption === 'userSpecified' ? userSpecified : redshiftOption; + return Number(redshift) || 0; +} + +/** + * Rebuilds a chart's spectral-line shapes from the current Spectral Lines FieldGroup + lines table + * selection, and dispatches only if the result actually differs from what's already on the chart. + * @param {string} chartId + */ +function resyncChartLines(chartId) { + const {activeTrace=0, fireflyData=[], data=[], layout} = getChartData(chartId); + const enabled = toBoolean(getFieldVal(SPECTRAL_LINES_FG_KEY, ENABLED_KEY, false)); + const tblId = sourceOptionToTblId(getFieldVal(SPECTRAL_LINES_FG_KEY, SOURCE_OPTIONS_KEY)); + const xUnit = fireflyData[activeTrace]?.xUnit; + const redshift = resolveSpectralLinesRedshift(fireflyData, activeTrace); + + const otherShapes = (layout?.shapes ?? []).filter((s) => s.legendgroup !== SPECTRAL_LINES_GROUP); + const spectralLineShapes = enabled ? makeSpectralLineShapes(xUnit, tblId, redshift) : []; + const changes = { + 'layout.shapes': [...otherShapes, ...spectralLineShapes], + 'layout.showlegend': data.length > 1 || enabled, + }; + + // don't update chart unless the changes are really new + if (isEqual(changes['layout.shapes'], layout?.shapes) && changes['layout.showlegend'] === layout?.showlegend) return; + dispatchChartUpdate({chartId, changes}); +} + +/** + * Keeps one chart's plotted spectral-line shapes in sync with the (app-wide) Spectral Lines FieldGroup + * and the active lines table's row-selection - no chart ever stores spectral-lines settings itself, this + * just consults the current fields + makeSpectralLineShapes every time something relevant changes. + * Uses explicit action watchers (like ChartUtil.js's setupTableWatcher) rather than a generic store + * subscription, so each relevant change is reacted to individually and repeatedly, not just the first. + * No JSX output - call directly from a component body (e.g. ChartPanel.jsx), not rendered as an element. + * @param {string} chartId + */ +export function useSpectralLinesSync(chartId) { + useEffect(() => { + if (!isSpectrum(chartId)) return; + + resyncChartLines(chartId); // reflect current state immediately + + const resync = () => resyncChartLines(chartId); + const cancels = [ + // the enable switch and source checkboxes + monitorChanges([VALUE_CHANGE, MULTI_VALUE_CHANGE], + (a) => a.payload.groupKey === SPECTRAL_LINES_FG_KEY, + resync, `sl-fg-${chartId}`), + // row (de)selection / (re)load of the lines table itself + watchTableChanges(RECOMMENDED_LINES_TBL_ID, [TABLE_SELECT, TABLE_LOADED], resync, `sl-tbl-${chartId}`), + // this chart's own xUnit/spectral-frame change (Modify Trace) under the 'fireflyData.' path + // note: resyncChartLines's own writes only ever touch 'layout.shapes|showlegend' in chart update so it + // avoids a self-triggering feedback loop + monitorChanges([CHART_UPDATE], + (a) => a.payload.chartId === chartId && Object.keys(a.payload.changes ?? {}).some((k) => k.startsWith('fireflyData')), + resync, `sl-chart-${chartId}`), + ]; + return () => cancels.forEach((cancel) => cancel?.()); + }, [chartId]); +} + /* fetches the recommended spectral lines table if not already loaded, then selects all its rows by default */ async function ensureRecommendedLines() { if (getTblById(RECOMMENDED_LINES_TBL_ID)) return; @@ -111,74 +193,81 @@ async function ensureRecommendedLines() { dispatchTableSelect(RECOMMENDED_LINES_TBL_ID, {selectAll: true, exceptions: new Set(), rowCount: tableModel.totalRows}); } -export function SpectralLinesOptions({activeTrace, chartId}) { +/** + * Standalone Spectral Lines dialog content. keepState=true on the FieldGroup so selections survive the + * dialog being closed/reopened (e.g. from a different chart) - every use input is live (see useSpectralLinesSync) + * @param {object} props + * @param {string} props.chartId + * @param {number} props.activeTrace + */ +export function SpectralLinesPanel({activeTrace, chartId}) { useEffect(() => { // pre-register tbl_ui_id so columns/columnWidths get populated once loaded (TablePanel mounts later, too late) dispatchTableUiUpdate({tbl_ui_id: RECOMMENDED_LINES_TBL_UI_ID, tbl_id: RECOMMENDED_LINES_TBL_ID}); void ensureRecommendedLines(); }, []); - const {enabled: initialEnabled = false, source: initialTblId = RECOMMENDED_LINES_TBL_ID} = - useStoreConnector(() => get(getChartData(chartId), 'fireflyLayout.spectralLines')) ?? {}; - const initialSourceOptions = tblIdToSourceOption(initialTblId); + // FieldGroup has keepState=true, so these fixed defaults only matter the very first time this + // session the dialog is opened - after that, the group's own last-seen values take over + const initialEnabled = false; + const initialSourceOptions = tblIdToSourceOption(RECOMMENDED_LINES_TBL_ID); // spectral lines need a redshift to correct against, which only exists when Spectral Frame options are shown (as opposed to read-only value) const hasSpectralFrame = useStoreConnector(() => isKnownRefPos(getChartData(chartId)?.fireflyData?.[activeTrace]?.spectralFrame?.refPos), [chartId, activeTrace]); - - // The field can be undefined for one render while the dialog mounts - // Use live form state when present, otherwise fall back to chart state - const isEnabledField = useFieldValueOnly(ENABLED_KEY); - const isEnabled = hasSpectralFrame && (isEnabledField ?? initialEnabled); + const isEnabledField = useFieldValueOnly(ENABLED_KEY, false, SPECTRAL_LINES_FG_KEY); + const isEnabled = hasSpectralFrame && isEnabledField; // TODO: combine different source tables to a client-side table - const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions); + const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); const activeTblId = sourceOptionToTblId(sourceOptions); const activeTblUiId = activeTblId && `${activeTblId}-ui`; - if (!hasSpectralFrame) return false; - return ( - - - - - {isEnabled && ( - - - {/* TODO: "Upload mine" — file upload + column mapper */} - {activeTblId && ( - - - - - - {/* TODO: add "Add row" button with comma-delimited input to append custom lines */} - - + + {hasSpectralFrame && ( + + + + + {isEnabled && ( + + + {/* TODO: "Upload mine" — file upload + column mapper */} + {activeTblId && ( + + + + + + {/* TODO: add "Add row" button with comma-delimited input to append custom lines */} + + + )} + )} )} - + ); } diff --git a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx index bb76eb1086..6fcb09c112 100644 --- a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx +++ b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react'; -import {get, omit, range, isEqual} from 'lodash'; +import {get, range, isEqual} from 'lodash'; import {getSpectrumDM, REF_POS, isKnownRefPos} from '../../../voAnalyzer/SpectrumDM.js'; import {getChartData} from '../../ChartsCntlr.js'; @@ -32,7 +32,6 @@ import {RadioGroupInputField} from 'firefly/ui/RadioGroupInputField'; import {Box, FormLabel, Stack, Typography} from '@mui/joy'; import {CollapsibleGroup} from 'firefly/ui/panel/CollapsiblePanel'; import {MathJax} from 'better-react-mathjax'; -import {SpectralLinesOptions, SPECTRAL_LINES_GROUP, makeSpectralLineShapes, sourceOptionToTblId} from './SpectralLines.jsx'; export function SpectrumOptions ({activeTrace:pActiveTrace, tbl_id:ptbl_id, chartId, groupKey}) { @@ -44,7 +43,7 @@ export function SpectrumOptions ({activeTrace:pActiveTrace, tbl_id:ptbl_id, char const {tbl_id} = getChartProps(chartId, ptbl_id, activeTrace); const {xErrArray, yErrArray, xMax, xMin, yMax, yMin, xUnit, yUnit} = getSpectrumProps(tbl_id); - const {Xunit, Yunit, SpectralFrame, SpectralLines} = useSpectrumInputs({activeTrace, tbl_id, chartId, groupKey}); + const {Xunit, Yunit, SpectralFrame} = useSpectrumInputs({activeTrace, tbl_id, chartId, groupKey}); const {UseSpectrum, X, Xmax, Xmin, Y, Ymax, Ymin, Yerrors, Xerrors, GroupBy} = useScatterInputs({activeTrace, tbl_id, chartId, groupKey}); const {XaxisTitle, YaxisTitle} = useBasicOptions({activeTrace, tbl_id, chartId, groupKey}); @@ -83,7 +82,6 @@ export function SpectrumOptions ({activeTrace:pActiveTrace, tbl_id:ptbl_id, char {xMin && } - @@ -340,34 +338,6 @@ export function submitChangesSpectrum({chartId, activeTrace, fields, tbl_id, ren }); } - //preserve chart state while spectral-line fields might be temporarily unmounted while switching traces - const currentSpectralLines = getChartData(chartId)?.fireflyLayout?.spectralLines ?? {}; - const spectralLinesEnabled = fields['spectralLines.enabled'] === undefined - ? toBoolean(currentSpectralLines.enabled) - : toBoolean(fields['spectralLines.enabled']); - const spectralLinesTblId = fields['spectralLines.sourceOptions'] === undefined - ? currentSpectralLines.source - : sourceOptionToTblId(fields['spectralLines.sourceOptions']); - fields = omit(fields, ['spectralLines.enabled', 'spectralLines.sourceOptions']); - - // persisted only so UI controls can seed their initial state from the chart data next time this dialog opens - fields = updateSet(fields, ['fireflyLayout.spectralLines.enabled'], spectralLinesEnabled); - fields = updateSet(fields, ['fireflyLayout.spectralLines.source'], spectralLinesTblId); - - // replace only this feature's shapes, preserve others - const otherShapes = getChartData(chartId)?.layout?.shapes?.filter((s) => s.legendgroup !== SPECTRAL_LINES_GROUP) ?? []; - // lines are rest-frame (lab) wavelengths; when the spectrum itself is shown in observed frame (i.e. not - // already rest-frame corrected), shift the lines by the same redshift to match - no shift needed in rest frame - const {sfOption, redshift} = getRedshiftInfo(fields, (p) => [p], fireflyData, activeTrace); - const spectralLinesRedshift = sfOption === 'observed' ? (Number(redshift) || 0) : 0; - const spectralLineShapes = spectralLinesEnabled ? makeSpectralLineShapes(xUnit, spectralLinesTblId, spectralLinesRedshift) : []; - fields = updateSet(fields, ['layout.shapes'], [...otherShapes, ...spectralLineShapes]); - - // always persist the full, correct value — don't rely on the key being absent, since a stale value from any - // earlier Apply would never get cleared otherwise - fields = updateSet(fields, ['layout.showlegend'], data.length > 1 || spectralLinesEnabled); - // ----- - // propagate all of the above field changes to change the state (i.e. chart data in store) submitChangesScatter({chartId, activeTrace, fields, tbl_id, renderTreeId}); } @@ -427,8 +397,6 @@ export const useSpectrumInputs = ({activeTrace:pActiveTrace, chartId, groupKey}) ? : ; }, [activeTrace, fireflyData, groupKey]), - SpectralLines: useCallback((props={}) => - , [activeTrace, chartId]), }; }; diff --git a/src/firefly/js/visualize/ui/Buttons.jsx b/src/firefly/js/visualize/ui/Buttons.jsx index a98c382b6e..32f2719eb1 100644 --- a/src/firefly/js/visualize/ui/Buttons.jsx +++ b/src/firefly/js/visualize/ui/Buttons.jsx @@ -40,6 +40,7 @@ import TextViewIco from '@mui/icons-material/TextFieldsOutlined'; import TableViewIco from '@mui/icons-material/TableChartOutlined'; import SettingsIco from '@mui/icons-material/SettingsOutlined'; import PropertySheetIco from '@mui/icons-material/ReadMoreOutlined'; +import SpectralLinesIco from '@mui/icons-material/EditRoadOutlined'; import ResetIco from '@mui/icons-material/RestartAltOutlined'; import PanIco from '@mui/icons-material/OpenWithOutlined'; import PinChartIco from '@mui/icons-material/PushPin'; @@ -216,6 +217,10 @@ export const SettingsButton = (props) => ( , tip: 'Chart options and tools', iconButtonSize:'38px', ...props}}/> ); +export const SpectralLinesButton = (props) => ( + , tip: 'Spectral lines', iconButtonSize:'38px', ...props}}/> +); + export const PropertySheetButton = (props) => ( , iconButtonSize:'38px', ...props}}/> ); From 65d94a775bc5183e8e26a1847c5412b85af94660 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 4 Sep 2026 18:03:29 -0700 Subject: [PATCH 02/21] FIREFLY-2066: Make a merged lines lists client-side table control chart lines --- .../js/charts/ui/options/SpectralLines.jsx | 212 ++++++++---------- 1 file changed, 99 insertions(+), 113 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 40bd99a360..cda1b6fc5f 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,30 +1,38 @@ import React, {useEffect} from 'react'; import {isEqual} from 'lodash'; -import {Stack} from '@mui/joy'; -import {SwitchInputField} from 'firefly/ui/SwitchInputField'; +import {Button, Stack} from '@mui/joy'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; -import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; import {useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; import {getChartData, dispatchChartUpdate, CHART_UPDATE} from '../../ChartsCntlr.js'; import {isSpectrum} from '../../ChartUtil.js'; import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; import {canUnitConv, convertUnitValue} from '../../dataTypes/SpectrumUnitConversion.js'; import {makeTblRequest} from 'firefly/tables/TableRequestUtil'; -import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableSelect, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; +import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableAddLocal, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; +import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; -import {toBoolean} from 'firefly/util/WebUtil'; import {FieldGroup} from 'firefly/ui/FieldGroup'; -import {getFieldVal} from 'firefly/fieldGroup/FieldGroupUtils'; -import {VALUE_CHANGE, MULTI_VALUE_CHANGE} from 'firefly/fieldGroup/FieldGroupCntlr'; -const RECOMMENDED_LINES_TBL_ID = 'recommended-spectral-lines'; -const RECOMMENDED_LINES_TBL_UI_ID = `${RECOMMENDED_LINES_TBL_ID}-ui`; +// recommended-lines source tables; more than one will be offered eventually (rec-1, rec-2, ...) +const RECOMMENDED_LINES_TBL_IDS = ['recommended-spectral-lines']; + +// the merged, client-side table that's actually displayed/plotted from - single source of truth +const LINES_TBL_ID = 'spectral-lines'; +const LINES_TBL_UI_ID = `${LINES_TBL_ID}-ui`; const WAVELENGTH_COL = 'wavelength_um'; const LABEL_COL = 'species'; const TRANSITION_COL = 'transition'; const PHASE_COL = 'phase'; +const GROUP_COL = 'group'; const WAVELENGTH_COL_UNIT = 'um'; // unit of WAVELENGTH_COL's values; TODO: source it from table metadata if present +const LINES_TBL_COLUMNS = [ + {name: WAVELENGTH_COL, units: WAVELENGTH_COL_UNIT, type: 'double'}, + {name: LABEL_COL, type: 'char'}, + {name: TRANSITION_COL, type: 'char'}, + {name: PHASE_COL, type: 'char'}, + {name: GROUP_COL, type: 'char'}, +]; const SPECTRAL_LINE_COLOR = 'gray'; const SPECTRAL_LINE_FONT_FAMILY = "'SF Mono', ui-monospace, monospace"; @@ -33,46 +41,23 @@ export const SPECTRAL_LINES_GROUP = 'lines'; const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; // Field keys and option values --- -const ENABLED_KEY = 'spectralLines.enabled'; const SOURCE_OPTIONS_KEY = 'spectralLines.sourceOptions'; // comma-separated checked values, e.g. CheckboxGroupInputField's value const SOURCE_RECOMMENDED = 'recommended'; const SOURCE_UPLOAD = 'upload'; -/** - * Resolves the checked source options to the concrete tbl_id to read lines from. - * Note: only one table can be active for now — combining multiple checked sources (e.g. recommended + uploaded) - * into a single client-side tableModel is deferred to a later pass, once upload is implemented. - * @param {string} sourceOptions - comma-separated checked values from SOURCE_OPTIONS_KEY - * @returns {string|undefined} tbl_id - */ -export function sourceOptionToTblId(sourceOptions) { - return splitVals(sourceOptions).includes(SOURCE_RECOMMENDED) ? RECOMMENDED_LINES_TBL_ID : undefined; -} - -/** - * Resolves a concrete lines-table tbl_id back to the checked source options it corresponds to. - * @param {string} linesTblId - * @returns {string} SOURCE_RECOMMENDED, or '' if none - */ -function tblIdToSourceOption(linesTblId) { - return linesTblId === RECOMMENDED_LINES_TBL_ID ? SOURCE_RECOMMENDED : ''; -} - /** * Builds Plotly vertical-line shapes for the currently selected (checked) rows of a given spectral lines table. * @param {string} xUnit - unit of the chart's x-axis; wavelengths from the spectral lines table are converted to this - * @param {string} linesTblId - tbl_id of the spectral lines table; only selected rows are used + * @param {string} linesTblId - tbl_id of the spectral lines table to read selected rows from; pass LINES_TBL_ID + * for the UI-managed merged table, or any other tbl_id (e.g. from a JS API caller supplying their own table). * @param {number} [redshift] - redshift of the spectrum in observed frame; 0 (default) for a * spectrum already shown in rest frame. * @returns {Array} Plotly shape objects, one per selected row with a valid wavelength */ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { - // TODO: build shapes from an uploaded lines table once file upload + column mapping is implemented - if (linesTblId !== RECOMMENDED_LINES_TBL_ID) return []; - if (!canUnitConv({from: WAVELENGTH_COL_UNIT, to: xUnit})) return []; - const selectedLines = getSelectedDataSync(RECOMMENDED_LINES_TBL_ID); + const selectedLines = getSelectedDataSync(linesTblId); const linesToPlot = []; for (let rowIdx = 0; rowIdx < selectedLines.totalRows; rowIdx++) { const row = getTblRowAsObj(selectedLines, rowIdx); @@ -121,22 +106,21 @@ function resolveSpectralLinesRedshift(fireflyData, activeTrace) { } /** - * Rebuilds a chart's spectral-line shapes from the current Spectral Lines FieldGroup + lines table - * selection, and dispatches only if the result actually differs from what's already on the chart. + * Rebuilds a chart's spectral-line shapes from the merged lines table's current row selection, and dispatches + * only if the result actually differs from what's already on the chart. No group selected -> table has no + * selected rows -> makeSpectralLineShapes naturally returns no shapes -> achieves spectral lines disabled behavior. * @param {string} chartId */ function resyncChartLines(chartId) { const {activeTrace=0, fireflyData=[], data=[], layout} = getChartData(chartId); - const enabled = toBoolean(getFieldVal(SPECTRAL_LINES_FG_KEY, ENABLED_KEY, false)); - const tblId = sourceOptionToTblId(getFieldVal(SPECTRAL_LINES_FG_KEY, SOURCE_OPTIONS_KEY)); const xUnit = fireflyData[activeTrace]?.xUnit; const redshift = resolveSpectralLinesRedshift(fireflyData, activeTrace); const otherShapes = (layout?.shapes ?? []).filter((s) => s.legendgroup !== SPECTRAL_LINES_GROUP); - const spectralLineShapes = enabled ? makeSpectralLineShapes(xUnit, tblId, redshift) : []; + const spectralLineShapes = makeSpectralLineShapes(xUnit, LINES_TBL_ID, redshift); const changes = { 'layout.shapes': [...otherShapes, ...spectralLineShapes], - 'layout.showlegend': data.length > 1 || enabled, + 'layout.showlegend': data.length > 1 || spectralLineShapes.length > 0, }; // don't update chart unless the changes are really new @@ -145,11 +129,10 @@ function resyncChartLines(chartId) { } /** - * Keeps one chart's plotted spectral-line shapes in sync with the (app-wide) Spectral Lines FieldGroup - * and the active lines table's row-selection - no chart ever stores spectral-lines settings itself, this - * just consults the current fields + makeSpectralLineShapes every time something relevant changes. - * Uses explicit action watchers (like ChartUtil.js's setupTableWatcher) rather than a generic store - * subscription, so each relevant change is reacted to individually and repeatedly, not just the first. + * Keeps one chart's plotted spectral-line shapes in sync with the merged lines table's row-selection - no chart + * ever stores spectral-lines settings itself, this just consults makeSpectralLineShapes every time something + * relevant changes. Uses explicit action watchers (like ChartUtil.js's setupTableWatcher) rather than a generic + * store subscription, so each relevant change is reacted to individually and repeatedly, not just the first. * No JSX output - call directly from a component body (e.g. ChartPanel.jsx), not rendered as an element. * @param {string} chartId */ @@ -161,12 +144,8 @@ export function useSpectralLinesSync(chartId) { const resync = () => resyncChartLines(chartId); const cancels = [ - // the enable switch and source checkboxes - monitorChanges([VALUE_CHANGE, MULTI_VALUE_CHANGE], - (a) => a.payload.groupKey === SPECTRAL_LINES_FG_KEY, - resync, `sl-fg-${chartId}`), - // row (de)selection / (re)load of the lines table itself - watchTableChanges(RECOMMENDED_LINES_TBL_ID, [TABLE_SELECT, TABLE_LOADED], resync, `sl-tbl-${chartId}`), + // row (de)selection / (re)build of the merged lines table itself + watchTableChanges(LINES_TBL_ID, [TABLE_SELECT, TABLE_LOADED], resync, `sl-tbl-${chartId}`), // this chart's own xUnit/spectral-frame change (Modify Trace) under the 'fireflyData.' path // note: resyncChartLines's own writes only ever touch 'layout.shapes|showlegend' in chart update so it // avoids a self-triggering feedback loop @@ -178,19 +157,43 @@ export function useSpectralLinesSync(chartId) { }, [chartId]); } -/* fetches the recommended spectral lines table if not already loaded, then selects all its rows by default */ +/* ensures every recommended-lines source table is fetched; a no-op for any already loaded */ async function ensureRecommendedLines() { - if (getTblById(RECOMMENDED_LINES_TBL_ID)) return; + await Promise.all(RECOMMENDED_LINES_TBL_IDS.map(async (tbl_id) => { + if (getTblById(tbl_id)) return; + const request = makeTblRequest('spectralLines', 'Spectral Lines', {}, {tbl_id}); + dispatchTableFetch(request); // headless: doesn't render in results UI + await onTableLoaded(tbl_id); + })); +} - const request = makeTblRequest( - 'spectralLines', 'Spectral Lines', {}, - {tbl_id: RECOMMENDED_LINES_TBL_ID} - ); - dispatchTableFetch(request); // headless: doesn't render in results UI +/** + * Rebuilds the merged, client-side lines table (LINES_TBL_ID) from the checked source options: one row per + * line, tagged with its source under GROUP_COL, with every row selected by default. This is the only place + * LINES_TBL_ID's content changes - called once on panel mount and again on the "Update Lines" button click, + * never automatically on checkbox change, so checking a box doesn't plot anything until applied. + * @param {string} sourceOptions - comma-separated checked values from SOURCE_OPTIONS_KEY + */ +async function buildMergedLinesTable(sourceOptions) { + const checked = splitVals(sourceOptions); + const groups = []; + if (checked.includes(SOURCE_RECOMMENDED)) { + await ensureRecommendedLines(); + RECOMMENDED_LINES_TBL_IDS.forEach((tbl_id) => groups.push({tbl_id, label: 'Recommended'})); + } + // TODO: once upload + column mapping is implemented, push {tbl_id, label: 'Upload'} groups for checked uploads here + + const data = groups.flatMap(({tbl_id, label}) => { + const src = getTblById(tbl_id); + return Array.from({length: src?.totalRows ?? 0}, (_, rowIdx) => { + const row = getTblRowAsObj(src, rowIdx); + return [row[WAVELENGTH_COL], row[LABEL_COL], row[TRANSITION_COL], row[PHASE_COL], label]; + }); + }); - // all lines are checked by default; set post-load since request.META_INFO.selectInfo can silently get lost in transit - const tableModel = await onTableLoaded(RECOMMENDED_LINES_TBL_ID); - dispatchTableSelect(RECOMMENDED_LINES_TBL_ID, {selectAll: true, exceptions: new Set(), rowCount: tableModel.totalRows}); + const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}}; + table.selectInfo = SelectInfo.newInstance({selectAll: true, rowCount: data.length}).data; + dispatchTableAddLocal(table, undefined, false); } /** @@ -201,71 +204,54 @@ async function ensureRecommendedLines() { * @param {number} props.activeTrace */ export function SpectralLinesPanel({activeTrace, chartId}) { + // FieldGroup has keepState=true, so this fixed default only matters the very first time this + // session the dialog is opened - after that, the group's own last-seen value takes over + const initialSourceOptions = ''; // nothing checked by default - lines table starts empty until applied + const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); + useEffect(() => { // pre-register tbl_ui_id so columns/columnWidths get populated once loaded (TablePanel mounts later, too late) - dispatchTableUiUpdate({tbl_ui_id: RECOMMENDED_LINES_TBL_UI_ID, tbl_id: RECOMMENDED_LINES_TBL_ID}); - void ensureRecommendedLines(); + dispatchTableUiUpdate({tbl_ui_id: LINES_TBL_UI_ID, tbl_id: LINES_TBL_ID}); + // build only if it doesn't exist yet - once built, row selection is user-owned and must survive + // the dialog being closed/reopened; only the "Update Lines" button rebuilds after this point + if (!getTblById(LINES_TBL_ID)) void buildMergedLinesTable(sourceOptions); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only; button click handles later rebuilds }, []); - // FieldGroup has keepState=true, so these fixed defaults only matter the very first time this - // session the dialog is opened - after that, the group's own last-seen values take over - const initialEnabled = false; - const initialSourceOptions = tblIdToSourceOption(RECOMMENDED_LINES_TBL_ID); - // spectral lines need a redshift to correct against, which only exists when Spectral Frame options are shown (as opposed to read-only value) const hasSpectralFrame = useStoreConnector(() => isKnownRefPos(getChartData(chartId)?.fireflyData?.[activeTrace]?.spectralFrame?.refPos), [chartId, activeTrace]); - const isEnabledField = useFieldValueOnly(ENABLED_KEY, false, SPECTRAL_LINES_FG_KEY); - const isEnabled = hasSpectralFrame && isEnabledField; - - // TODO: combine different source tables to a client-side table - const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); - const activeTblId = sourceOptionToTblId(sourceOptions); - const activeTblUiId = activeTblId && `${activeTblId}-ui`; return ( {hasSpectralFrame && ( - - + + {/* TODO: "Upload mine" — file upload + column mapper */} + + + + + - {isEnabled && ( - - - {/* TODO: "Upload mine" — file upload + column mapper */} - {activeTblId && ( - - - - - - {/* TODO: add "Add row" button with comma-delimited input to append custom lines */} - - - )} - - )} )} From 9420d490cde1501bc5d9417833f38e7cbdc5b818 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Tue, 11 Aug 2026 15:52:51 -0700 Subject: [PATCH 03/21] FIREFLY-2066: Add two line lists and make ui handle their merging --- .../ipac/firefly/resources/jwst_linelist.tbl | 42 +++++++++++++ .../firefly/resources/linelist_combined.csv | 45 -------------- .../ipac/firefly/resources/luisa_linelist.csv | 27 ++++++++ .../server/query/SpectralLinesProcessor.java | 19 ++++-- .../js/charts/ui/options/SpectralLines.jsx | 61 +++++++++---------- 5 files changed, 113 insertions(+), 81 deletions(-) create mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl delete mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/linelist_combined.csv create mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl b/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl new file mode 100644 index 0000000000..46a54d6e65 --- /dev/null +++ b/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl @@ -0,0 +1,42 @@ +\ Curated subset (2.6-5.0 um): gas-phase transitions plus PAH bands. +\ Wavelengths are exact vacuum values from source; H I labels via Rydberg. +\ Column wavelength [micron]: rest-frame vacuum wavelength +\ Column label: species / ion (bracketed = forbidden transition) +\ Column description: transition identification; phase (gas/PAH) +\title='JWST-SpecTool derived line list (2.6-5.0 um)' +\source='Lai JWST-SpecTool line_list_gt3um.csv' +\wavelength_frame='rest-frame, vacuum' +\n_lines=29 +|wavelength| label| description| +| double| char| char| +| micron| | | +| null| null| null| + 3.0039 H2 v=1-0 O(4); gas + 3.0392 H I Pf epsilon (10-5); gas + 3.0984 O I 3P-3Do 1 - 1; gas + 3.2890 PAH 3.3um C-H aromatic; PAH + 3.2970 H I Pf delta (9-5); gas + 3.4000 PAH 3.4um aliphatic C-H; PAH + 3.4600 PAH C-H band; PAH + 3.5100 PAH C-H band; PAH + 3.6146 CH+ v=1-0 R(0); gas + 3.6876 CH+ v=1-0 P(1); gas + 3.7035 He I 3Po-3D 2 - 1; gas + 3.7406 H I Pf gamma (8-5); gas + 3.8461 H2 v=0-0 S(13); gas + 4.0523 H I Br alpha (5-4); gas + 4.0763 [Fe II] a6D-a4F 7/2 - 5/2; gas + 4.0820 [Fe II] a6D-a4F 5/2 - 3/2; gas + 4.1150 [Fe II] a6D-a4F 9/2 - 7/2; gas + 4.1811 H2 v=0-0 S(11); gas + 4.2954 He I 3S-3Po 1 - 0; gas + 4.3765 H I Hu (12-6); gas + 4.4098 H2 v=0-0 S(10); gas + 4.6077 [Fe II] a6D-a4F 5/2 - 5/2; gas + 4.6493 CO v=1-0 R(1); gas + 4.6538 H I Pf beta (7-5); gas + 4.6742 CO v=1-0 P(1); gas + 4.6946 H2 v=0-0 S(9); gas + 4.7326 CO v=2-1 P(1); gas + 4.8891 [Fe II] a6D-a4F 7/2 - 7/2; gas + 4.9908 CO v=1-0 P(32); gas diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/linelist_combined.csv b/src/firefly/java/edu/caltech/ipac/firefly/resources/linelist_combined.csv deleted file mode 100644 index 814579be35..0000000000 --- a/src/firefly/java/edu/caltech/ipac/firefly/resources/linelist_combined.csv +++ /dev/null @@ -1,45 +0,0 @@ -wavelength_um,species,transition,phase,origin,ref -2.6259,H I,Br beta (6-4),gas,xlsx+calc,Lai+ -2.8730,H I,Pf 11 (11-5),gas,xlsx+calc,Lai+ -2.9600,NH3,N-H stretch (nu2),ice,xlsx,Gibbs -3.0039,H2,v=1-0 O(4),gas,JWST-SpecTool,MEUDON -3.0392,H I,Pf epsilon (10-5),gas,JWST-SpecTool,CLOUDY -3.0500,H2O,O-H stretch,ice,xlsx,Gibbs/Lai+ -3.0984,O I,3P-3Do 1 - 1,gas,JWST-SpecTool,OTHERS -3.2890,PAH,3.3um C-H aromatic,PAH,JWST-SpecTool/xlsx,Lai+/Tokunaga -3.2970,H I,Pf delta (9-5),gas,JWST-SpecTool,CLOUDY -3.4000,PAH,3.4um aliphatic C-H,PAH,JWST-SpecTool/xlsx,Lai+/Tokunaga -3.4600,PAH,C-H band,PAH,JWST-SpecTool/xlsx,Lai+/Tokunaga -3.4700,-CH2-/-CH3-,C-H stretch (aliphatic),ice,xlsx,Gibbs -3.5100,PAH,C-H band,PAH,JWST-SpecTool/xlsx,Lai+/Tokunaga -3.5300,CH3OH,C-H stretch,ice,xlsx,Gibbs -3.6146,CH+,v=1-0 R(0),gas,JWST-SpecTool,OTHERS -3.6876,CH+,v=1-0 P(1),gas,JWST-SpecTool,OTHERS -3.7035,He I,3Po-3D 2 - 1,gas,JWST-SpecTool,CLOUDY -3.7406,H I,Pf gamma (8-5),gas,JWST-SpecTool,CLOUDY -3.8461,H2,v=0-0 S(13),gas,JWST-SpecTool,MEUDON -3.9500,CH3OH/H2S,C-H / S-H stretch,ice,xlsx,Gibbs -4.0523,H I,Br alpha (5-4),gas,JWST-SpecTool,CLOUDY -4.0763,[Fe II],a6D-a4F 7/2 - 5/2,gas,JWST-SpecTool,CLOUDY -4.0820,[Fe II],a6D-a4F 5/2 - 3/2,gas,JWST-SpecTool,CLOUDY -4.1150,[Fe II],a6D-a4F 9/2 - 7/2,gas,JWST-SpecTool,CLOUDY -4.1811,H2,v=0-0 S(11),gas,JWST-SpecTool,MEUDON -4.2700,CO2,C-O stretch (nu3),ice,xlsx,Gibbs/Lai+ -4.2954,He I,3S-3Po 1 - 0,gas,JWST-SpecTool,CLOUDY -4.3765,H I,Hu (12-6),gas,JWST-SpecTool,CLOUDY -4.3800,13CO2,13C-O stretch,ice,xlsx,Gibbs -4.4098,H2,v=0-0 S(10),gas,JWST-SpecTool,MEUDON -4.5000,H2O,combination mode,ice,xlsx,Gibbs -4.6077,[Fe II],a6D-a4F 5/2 - 5/2,gas,JWST-SpecTool,CLOUDY -4.6200,XCN (OCN-),C=N stretch,ice,xlsx,Gibbs -4.6493,CO,v=1-0 R(1),gas,JWST-SpecTool,OTHERS -4.6538,H I,Pf beta (7-5),gas,JWST-SpecTool,CLOUDY -4.6700,CO,12C-O stretch (solid),ice,xlsx,Gibbs/Lai+ -4.6742,CO,v=1-0 P(1),gas,JWST-SpecTool,OTHERS -4.6946,H2,v=0-0 S(9),gas,JWST-SpecTool,MEUDON -4.7200,Dust continuum,cloud-depth indicator,continuum,xlsx,Hora+ -4.7326,CO,v=2-1 P(1),gas,JWST-SpecTool,OTHERS -4.7800,13CO,13C-O stretch,ice,xlsx,Gibbs -4.8891,[Fe II],a6D-a4F 7/2 - 7/2,gas,JWST-SpecTool,CLOUDY -4.9100,OCS,C-S stretch,ice,xlsx,Gibbs -4.9908,CO,v=1-0 P(32),gas,JWST-SpecTool,OTHERS diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv b/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv new file mode 100644 index 0000000000..32d205c7b1 --- /dev/null +++ b/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv @@ -0,0 +1,27 @@ +wavelength,label,description +2.6259,H I,Br beta (6-4); gas +2.8730,H I,Pf 11 (11-5); gas +2.9600,NH3,N-H stretch (nu2); ice +3.0392,H I,Pf epsilon (10-5); gas +3.0500,H2O,O-H stretch; ice +3.2890,PAH,3.3um C-H aromatic; PAH +3.4000,PAH,3.4um aliphatic C-H; PAH +3.4600,PAH,C-H band; PAH +3.4700,-CH2-/-CH3-,C-H stretch (aliphatic); ice +3.5300,CH3OH,C-H stretch; ice +3.7406,H I,Pf gamma (8-5); gas +3.9500,CH3OH/H2S,C-H / S-H stretch; ice +4.0523,H I,Br alpha (5-4); gas +4.1811,H2,v=0-0 S(11); gas +4.2700,CO2,C-O stretch (nu3); ice +4.2954,He I,3S-3Po 1 - 0; gas +4.3800,13CO2,13C-O stretch; ice +4.4098,H2,v=0-0 S(10); gas +4.5000,H2O,combination mode; ice +4.6200,XCN (OCN-),C=N stretch; ice +4.6538,H I,Pf beta (7-5); gas +4.6700,CO,12C-O stretch (solid); ice +4.6946,H2,v=0-0 S(9); gas +4.7200,Dust continuum,cloud-depth indicator; continuum +4.7800,13CO,13C-O stretch; ice +4.9100,OCS,C-S stretch; ice diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java index c26220e211..cde4dcbab3 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java @@ -11,20 +11,29 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.Map; /** - * Serves the recommended spectral line list (a fixed resource dataset) as a table. + * Serves one of the recommended spectral line lists (fixed resource datasets) as a table, + * selected by the request's "listId" param. */ @SearchProcessorImpl(id = "spectralLines") public class SpectralLinesProcessor extends EmbeddedDbProcessor { - private static final String RESOURCE = "/edu/caltech/ipac/firefly/resources/linelist_combined.csv"; + private static final Map RESOURCES = Map.of( + "luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv", + "jwst", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl" + ); public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessException { - try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(RESOURCE)) { - if (is == null) throw new IOException("Resource not found: " + RESOURCE); + String listId = req.getParam("listId"); + String resource = RESOURCES.get(listId); + if (resource == null) throw new DataAccessException("Unknown or missing spectral lines listId: " + listId); + + try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(resource)) { + if (is == null) throw new IOException("Resource not found: " + resource); // readAnyFormat needs a File; copy the classpath resource to a temp file first - String ext = RESOURCE.substring(RESOURCE.lastIndexOf('.')); + String ext = resource.substring(resource.lastIndexOf('.')); File tempFile = createTempFile(req, ext); FileUtil.writeToFile(is, tempFile, null); diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index cda1b6fc5f..4bc1d41926 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -14,23 +14,27 @@ import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; -// recommended-lines source tables; more than one will be offered eventually (rec-1, rec-2, ...) -const RECOMMENDED_LINES_TBL_IDS = ['recommended-spectral-lines']; +// recommended-lines source lists, each independently checkable; listId is sent to the server's +// "spectralLines" search processor to pick which resource file it serves. Adding a list later is +// just appending an entry here (plus its listId -> resource mapping server-side). +const RECOMMENDED_LINE_LISTS = [ + {listId: 'luisa', listLabel: 'Luisa'}, + {listId: 'jwst', listLabel: 'JWST'}, +]; +const recLinesTblId = (listId) => `rec-${listId}`; // the merged, client-side table that's actually displayed/plotted from - single source of truth const LINES_TBL_ID = 'spectral-lines'; const LINES_TBL_UI_ID = `${LINES_TBL_ID}-ui`; -const WAVELENGTH_COL = 'wavelength_um'; -const LABEL_COL = 'species'; -const TRANSITION_COL = 'transition'; -const PHASE_COL = 'phase'; +const WAVELENGTH_COL = 'wavelength'; +const LABEL_COL = 'label'; +const DESCRIPTION_COL = 'description'; const GROUP_COL = 'group'; const WAVELENGTH_COL_UNIT = 'um'; // unit of WAVELENGTH_COL's values; TODO: source it from table metadata if present const LINES_TBL_COLUMNS = [ {name: WAVELENGTH_COL, units: WAVELENGTH_COL_UNIT, type: 'double'}, {name: LABEL_COL, type: 'char'}, - {name: TRANSITION_COL, type: 'char'}, - {name: PHASE_COL, type: 'char'}, + {name: DESCRIPTION_COL, type: 'char'}, {name: GROUP_COL, type: 'char'}, ]; @@ -42,7 +46,6 @@ const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; // Field keys and option values --- const SOURCE_OPTIONS_KEY = 'spectralLines.sourceOptions'; // comma-separated checked values, e.g. CheckboxGroupInputField's value -const SOURCE_RECOMMENDED = 'recommended'; const SOURCE_UPLOAD = 'upload'; /** @@ -64,10 +67,10 @@ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { const lineWvl = row[WAVELENGTH_COL] * (1 + redshift); // redshift the rest-frame wavelength of a spectral line const x = convertUnitValue(lineWvl, WAVELENGTH_COL_UNIT, xUnit); if (!Number.isFinite(x)) continue; // skip rows with a missing/unparsable wavelength - linesToPlot.push({x, label: row[LABEL_COL], transition: row[TRANSITION_COL], phase: row[PHASE_COL]}); + linesToPlot.push({x, label: row[LABEL_COL], description: row[DESCRIPTION_COL]}); } - return linesToPlot.map(({x, label, transition, phase}, i) => ({ + return linesToPlot.map(({x, label, description}, i) => ({ type: 'line', x0: x, x1: x, y0: 0, y1: 1, @@ -80,8 +83,7 @@ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { font: {size: 9.5, color: SPECTRAL_LINE_COLOR, family: SPECTRAL_LINE_FONT_FAMILY}, padding: 2, }, - hovertext: `${label} λ ${x} ${xUnit}` + - (transition ? `
${transition}` : '') + (phase ? `
${phase}` : ''), + hovertext: `${label} λ ${x} ${xUnit}` + (description ? `
${description}` : ''), legendgroup: SPECTRAL_LINES_GROUP, showlegend: i === 0, // legend entry goes only on the first shape item name: 'Lines', @@ -157,14 +159,13 @@ export function useSpectralLinesSync(chartId) { }, [chartId]); } -/* ensures every recommended-lines source table is fetched; a no-op for any already loaded */ -async function ensureRecommendedLines() { - await Promise.all(RECOMMENDED_LINES_TBL_IDS.map(async (tbl_id) => { - if (getTblById(tbl_id)) return; - const request = makeTblRequest('spectralLines', 'Spectral Lines', {}, {tbl_id}); - dispatchTableFetch(request); // headless: doesn't render in results UI - await onTableLoaded(tbl_id); - })); +/* fetches a single recommended-lines source table (by its listId) if not already loaded */ +async function ensureRecommendedList(listId) { + const tbl_id = recLinesTblId(listId); + if (getTblById(tbl_id)) return; + const request = makeTblRequest('spectralLines', 'Spectral Lines', {listId}, {tbl_id}); + dispatchTableFetch(request); // headless: doesn't render in results UI + await onTableLoaded(tbl_id); } /** @@ -176,18 +177,15 @@ async function ensureRecommendedLines() { */ async function buildMergedLinesTable(sourceOptions) { const checked = splitVals(sourceOptions); - const groups = []; - if (checked.includes(SOURCE_RECOMMENDED)) { - await ensureRecommendedLines(); - RECOMMENDED_LINES_TBL_IDS.forEach((tbl_id) => groups.push({tbl_id, label: 'Recommended'})); - } - // TODO: once upload + column mapping is implemented, push {tbl_id, label: 'Upload'} groups for checked uploads here + const checkedLists = RECOMMENDED_LINE_LISTS.filter(({listId}) => checked.includes(listId)); + await Promise.all(checkedLists.map(({listId}) => ensureRecommendedList(listId))); + // TODO: once upload + column mapping is implemented, include checked upload groups here too - const data = groups.flatMap(({tbl_id, label}) => { - const src = getTblById(tbl_id); + const data = checkedLists.flatMap(({listId, listLabel}) => { + const src = getTblById(recLinesTblId(listId)); return Array.from({length: src?.totalRows ?? 0}, (_, rowIdx) => { const row = getTblRowAsObj(src, rowIdx); - return [row[WAVELENGTH_COL], row[LABEL_COL], row[TRANSITION_COL], row[PHASE_COL], label]; + return [row[WAVELENGTH_COL], row[LABEL_COL], row[DESCRIPTION_COL], listLabel]; }); }); @@ -231,7 +229,8 @@ export function SpectralLinesPanel({activeTrace, chartId}) { label='Lines list:' initialState={{value: initialSourceOptions}} options={[ - {label: 'Recommended', value: SOURCE_RECOMMENDED}, + ...RECOMMENDED_LINE_LISTS.map(({listId, listLabel}) => + ({label: `${listLabel} (recommended)`, value: listId})), {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} ]}/> {/* TODO: "Upload mine" — file upload + column mapper */} From 37a989c58a0b7c9b6c74032e606062427e183c3b Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 4 Sep 2026 18:04:44 -0700 Subject: [PATCH 04/21] FIREFLY-2066: Improve the UI of SpectralLinesPanel --- src/firefly/js/charts/ui/PlotlyToolbar.jsx | 10 +- .../js/charts/ui/options/SpectralLines.jsx | 94 +++++++++++++------ 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/firefly/js/charts/ui/PlotlyToolbar.jsx b/src/firefly/js/charts/ui/PlotlyToolbar.jsx index d2ce261e8d..27d2976cb7 100644 --- a/src/firefly/js/charts/ui/PlotlyToolbar.jsx +++ b/src/firefly/js/charts/ui/PlotlyToolbar.jsx @@ -83,7 +83,7 @@ function ScatterToolbar({chartId, expandable}) { {tbl_id && } - {isSpectrum(chartId) && } + {isSpectrum(chartId) && } {expandable && } { help_id && } @@ -317,9 +317,9 @@ function OptionsBtn({chartId}) { ); } -function SpectralLinesBtn({chartId, activeTrace}) { +function SpectralLinesBtn() { return ( - showSpectralLinesDialog({chartId, activeTrace})}/> + showSpectralLinesDialog()}/> ); } @@ -405,10 +405,10 @@ function showFilterDialog(tbl_id, tbl_ui_id) { } -function showSpectralLinesDialog({chartId, activeTrace}) { +function showSpectralLinesDialog() { showOptionsPopup({ title: 'Spectral Lines Options', modal: false, - content: + content: }); } diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 4bc1d41926..3dbe51e113 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,7 +1,8 @@ import React, {useEffect} from 'react'; import {isEqual} from 'lodash'; -import {Button, Stack} from '@mui/joy'; +import {Button, Stack, Typography} from '@mui/joy'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; +import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; import {useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; import {getChartData, dispatchChartUpdate, CHART_UPDATE} from '../../ChartsCntlr.js'; import {isSpectrum} from '../../ChartUtil.js'; @@ -9,10 +10,11 @@ import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; import {canUnitConv, convertUnitValue} from '../../dataTypes/SpectrumUnitConversion.js'; import {makeTblRequest} from 'firefly/tables/TableRequestUtil'; import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableAddLocal, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; -import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; +import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, getColumnValues, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; +import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; // recommended-lines source lists, each independently checkable; listId is sent to the server's // "spectralLines" search processor to pick which resource file it serves. Adding a list later is @@ -43,6 +45,7 @@ const SPECTRAL_LINE_FONT_FAMILY = "'SF Mono', ui-monospace, monospace"; export const SPECTRAL_LINES_GROUP = 'lines'; const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; +const SOURCES_COLLAPSIBLE_KEY = 'spectralLinesSources'; // panel is shared app-wide, not chart-specific - one fixed key // Field keys and option values --- const SOURCE_OPTIONS_KEY = 'spectralLines.sourceOptions'; // comma-separated checked values, e.g. CheckboxGroupInputField's value @@ -96,9 +99,13 @@ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { * rest-frame corrected), lines must be shifted by the same redshift to match - no shift needed in rest frame. * @param {Array} fireflyData * @param {number} activeTrace - * @returns {number} + * @returns {number|undefined} undefined if this trace has no known spectral frame at all (lines aren't + * applicable to it), as opposed to a known rest frame (0) or a known observed-frame redshift. */ function resolveSpectralLinesRedshift(fireflyData, activeTrace) { + // a redshift is only resolvable when Spectral Frame options are shown (as opposed to a read-only value) + if (!isKnownRefPos(fireflyData?.[activeTrace]?.spectralFrame?.refPos)) return undefined; + // TODO: spectralFrameOption is undefined until Modify Trace is applied at least once, so this // assumes rest-frame (0) until then even if the real default would be observed with a redshift const {value: sfOption, redshift: redshiftOption, userSpecified} = fireflyData?.[activeTrace]?.spectralFrameOption ?? {}; @@ -109,7 +116,7 @@ function resolveSpectralLinesRedshift(fireflyData, activeTrace) { /** * Rebuilds a chart's spectral-line shapes from the merged lines table's current row selection, and dispatches - * only if the result actually differs from what's already on the chart. No group selected -> table has no + * only if the result actually differs from what's already on the chart. When lines table has no * selected rows -> makeSpectralLineShapes naturally returns no shapes -> achieves spectral lines disabled behavior. * @param {string} chartId */ @@ -119,7 +126,7 @@ function resyncChartLines(chartId) { const redshift = resolveSpectralLinesRedshift(fireflyData, activeTrace); const otherShapes = (layout?.shapes ?? []).filter((s) => s.legendgroup !== SPECTRAL_LINES_GROUP); - const spectralLineShapes = makeSpectralLineShapes(xUnit, LINES_TBL_ID, redshift); + const spectralLineShapes = redshift === undefined ? [] : makeSpectralLineShapes(xUnit, LINES_TBL_ID, redshift); const changes = { 'layout.shapes': [...otherShapes, ...spectralLineShapes], 'layout.showlegend': data.length > 1 || spectralLineShapes.length > 0, @@ -195,13 +202,11 @@ async function buildMergedLinesTable(sourceOptions) { } /** - * Standalone Spectral Lines dialog content. keepState=true on the FieldGroup so selections survive the - * dialog being closed/reopened (e.g. from a different chart) - every use input is live (see useSpectralLinesSync) - * @param {object} props - * @param {string} props.chartId - * @param {number} props.activeTrace + * Standalone Spectral Lines dialog content - not specific to the chart it was opened from; the lines table + * it manages is shared app-wide across all spectrum charts (see `useSpectralLinesSync`). keepState=true on the + * FieldGroup so selections survive the dialog being closed/reopened. */ -export function SpectralLinesPanel({activeTrace, chartId}) { +export function SpectralLinesPanel() { // FieldGroup has keepState=true, so this fixed default only matters the very first time this // session the dialog is opened - after that, the group's own last-seen value takes over const initialSourceOptions = ''; // nothing checked by default - lines table starts empty until applied @@ -216,27 +221,55 @@ export function SpectralLinesPanel({activeTrace, chartId}) { // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only; button click handles later rebuilds }, []); - // spectral lines need a redshift to correct against, which only exists when Spectral Frame options are shown (as opposed to read-only value) - const hasSpectralFrame = useStoreConnector(() => - isKnownRefPos(getChartData(chartId)?.fireflyData?.[activeTrace]?.spectralFrame?.refPos), - [chartId, activeTrace]); + const {selectedCount, groupsCount, linesCount} = useStoreConnector(() => { + const tbl = getTblById(LINES_TBL_ID); + const linesCount = tbl?.totalRows ?? 0; + const groupsCount = linesCount ? new Set(getColumnValues(tbl, GROUP_COL)).size : 0; + const selectedCount = SelectInfo.newInstance(tbl?.selectInfo).getSelectedCount(); + return {selectedCount, groupsCount, linesCount}; + }, []); + + const onUpdateLines = () => { + void buildMergedLinesTable(sourceOptions); + dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below + }; return ( - {hasSpectralFrame && ( - - - ({label: `${listLabel} (recommended)`, value: listId})), - {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} - ]}/> - {/* TODO: "Upload mine" — file upload + column mapper */} - - - + + + ( + + Select line lists to load + {!isOpen && + + {linesCount === 0 + ? 'No lines loaded' + : `${groupsCount} group${groupsCount === 1 ? '' : 's'}, ${linesCount} line${linesCount === 1 ? '' : 's'} loaded`} + } + + )} + isOpen={true}> + + + ({label: `${listLabel} (recommended)`, value: listId})), + {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} + ]}/> + {/* TODO: "Upload mine" — file upload + column mapper */} + + + + + + + + Select lines to plot: + {selectedCount} lines selected - plotted live on spectral chart(s) - )} + ); } From ef21f0c3e0c4b451429ad89443527056ff695a98 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Wed, 12 Aug 2026 16:34:23 -0700 Subject: [PATCH 05/21] FIREFLY-2066: Add helper texts to make limbo states between loading and plotting clear --- .../js/charts/ui/options/SpectralLines.jsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 3dbe51e113..39a42b3a26 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,6 +1,7 @@ import React, {useEffect} from 'react'; import {isEqual} from 'lodash'; -import {Button, Stack, Typography} from '@mui/joy'; +import {Button, Divider, Stack, Typography} from '@mui/joy'; +import {Insights} from '@mui/icons-material'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; import {useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; @@ -25,6 +26,9 @@ const RECOMMENDED_LINE_LISTS = [ ]; const recLinesTblId = (listId) => `rec-${listId}`; +// order-insensitive: checking source options checkboxes in a different order shouldn't count as a real difference +const sameSourceOptions = (a, b) => isEqual(splitVals(a).filter(Boolean).sort(), splitVals(b).filter(Boolean).sort()); + // the merged, client-side table that's actually displayed/plotted from - single source of truth const LINES_TBL_ID = 'spectral-lines'; const LINES_TBL_UI_ID = `${LINES_TBL_ID}-ui`; @@ -196,7 +200,8 @@ async function buildMergedLinesTable(sourceOptions) { }); }); - const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}}; + // store sourceOptions this table was built from in meta, so the panel can tell when the checked lists have since diverged + const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}, tableMeta: {sourceOptions}}; table.selectInfo = SelectInfo.newInstance({selectAll: true, rowCount: data.length}).data; dispatchTableAddLocal(table, undefined, false); } @@ -221,14 +226,26 @@ export function SpectralLinesPanel() { // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only; button click handles later rebuilds }, []); - const {selectedCount, groupsCount, linesCount} = useStoreConnector(() => { + const {selectedCount, groupsCount, linesCount, loadedSourceOptions} = useStoreConnector(() => { const tbl = getTblById(LINES_TBL_ID); const linesCount = tbl?.totalRows ?? 0; const groupsCount = linesCount ? new Set(getColumnValues(tbl, GROUP_COL)).size : 0; const selectedCount = SelectInfo.newInstance(tbl?.selectInfo).getSelectedCount(); - return {selectedCount, groupsCount, linesCount}; + const loadedSourceOptions = tbl?.tableMeta?.sourceOptions ?? ''; + return {selectedCount, groupsCount, linesCount, loadedSourceOptions}; }, []); + // true whenever the checked lists above no longer match what's actually loaded into the table below + const hasPendingChanges = !sameSourceOptions(sourceOptions, loadedSourceOptions); + + const plotHelperText = linesCount === 0 + ? (hasPendingChanges + ? 'No lines loaded yet - click "Load Lines" above to reflect the changes in list(s) selection' + : 'No lines loaded - select list(s) above and then click "Load Lines"') + : (hasPendingChanges + ? 'Showing previously loaded lines - click "Load Lines" above to reflect the changes in list(s) selection' + : undefined); + const onUpdateLines = () => { void buildMergedLinesTable(sourceOptions); dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below @@ -251,7 +268,7 @@ export function SpectralLinesPanel() { )} isOpen={true}> - + {/* TODO: "Upload mine" — file upload + column mapper */} - - + + + + {hasPendingChanges && + + changes in list(s) selection not yet loaded in table below + } Select lines to plot: + {plotHelperText && + {plotHelperText}} - {selectedCount} lines selected - plotted live on spectral chart(s) + {selectedCount === 0 + ? 0 lines selected - nothing plotted on spectral chart(s) + : }> + {selectedCount} lines +  selected - plotted live on spectral chart(s) ↘ + } From 70ad5129b66d5d20f379484f42c60ce4e9cb422b Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Wed, 12 Aug 2026 18:03:57 -0700 Subject: [PATCH 06/21] FIREFLY-2066: Move spectral lines button to left side of chart toolbar --- src/firefly/js/charts/ui/ChartsContainer.jsx | 8 ++++++-- src/firefly/js/charts/ui/MultiChartToolbar.jsx | 16 +++++++++------- src/firefly/js/charts/ui/PlotlyToolbar.jsx | 5 ++--- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/firefly/js/charts/ui/ChartsContainer.jsx b/src/firefly/js/charts/ui/ChartsContainer.jsx index d5b564b0e5..18a06aef99 100644 --- a/src/firefly/js/charts/ui/ChartsContainer.jsx +++ b/src/firefly/js/charts/ui/ChartsContainer.jsx @@ -12,12 +12,13 @@ import {monitorChanges, findGroupByTblId, getActiveTableId, isFullyLoaded, } fro import {TBL_RESULTS_ACTIVE, TABLE_LOADED, TABLE_SELECT} from '../../tables/TablesCntlr'; import {DEFAULT_PLOT2D_VIEWER_ID, PLOT2D} from '../../visualize/VisConst'; import {CHART_ADD, CHART_REMOVE, getChartIdsInGroup, getChartData, dispatchChartAdd, getExpandedChartProps} from '../ChartsCntlr.js'; -import {getDefaultChartProps, allowPinnedCharts} from '../ChartUtil.js'; +import {getDefaultChartProps, allowPinnedCharts, isSpectrum} from '../ChartUtil.js'; import {CloseButton} from '../../ui/CloseButton.jsx'; import {ChartPanel, ChartToolbar} from './ChartPanel.jsx'; import {MultiChartViewer, getActiveViewerItemId} from './MultiChartViewer.jsx'; import {PinnedChartContainer} from 'firefly/charts/ui/PinnedChartContainer.jsx'; +import {SpectralLinesBtn} from './PlotlyToolbar.jsx'; import {Stack} from '@mui/joy'; @@ -235,7 +236,10 @@ const ChartToolbarExt = ({chartId, viewerId, tbl_group, noChartToolbar, closeabl return ( - {closeable && closeExpandedChart(viewerId)}/>} + + {closeable && closeExpandedChart(viewerId)}/>} + {isSpectrum(chartId) && } + {!noChartToolbar && } ); diff --git a/src/firefly/js/charts/ui/MultiChartToolbar.jsx b/src/firefly/js/charts/ui/MultiChartToolbar.jsx index a5d9b93e52..64ee11bd30 100644 --- a/src/firefly/js/charts/ui/MultiChartToolbar.jsx +++ b/src/firefly/js/charts/ui/MultiChartToolbar.jsx @@ -10,13 +10,14 @@ import {AppPropertiesCtx} from '../../ui/AppPropertiesCtx.jsx'; import {BeforeButton, DisplayTypeButtonGroup, NextButton} from '../../visualize/ui/Buttons.jsx'; import {getChartData} from '../ChartsCntlr.js'; +import {isSpectrum} from '../ChartUtil.js'; import {dispatchChangeViewerLayout, dispatchUpdateCustom, getViewerItemIds, getViewer, getLayoutType, getMultiViewRoot} from '../../visualize/MultiViewCntlr.js'; import {PagingControl} from '../../visualize/iv/ExpandedTools.jsx'; import {ChartToolbar} from './ChartPanel'; import {CloseButton} from '../../ui/CloseButton'; import {closeExpandedChart} from 'firefly/charts/ui/ChartsContainer.jsx'; -import {AddBtn} from './PlotlyToolbar.jsx'; +import {AddBtn, SpectralLinesBtn} from './PlotlyToolbar.jsx'; export function MultiChartToolbarStandard({viewerId, chartId, tbl_group, expandable, expandedMode, showAddChart, toolbarVariant, @@ -28,10 +29,11 @@ export function MultiChartToolbarStandard({viewerId, chartId, tbl_group, expanda return ( - + {!jsApi && showAddChart && } - + {isSpectrum(chartId) && } + @@ -61,11 +63,12 @@ export function MultiChartToolbarExpanded({viewerId, chartId, tbl_group, expanda return ( - {closeable && closeExpandedChart(viewerId)}/>} - + + {closeable && closeExpandedChart(viewerId)}/>} {!jsApi && showAddChart && } - + {isSpectrum(chartId) && } + @@ -131,7 +134,6 @@ const MultiChartExt = ({viewerId, layoutType, activeItemId}) => { return ( {tbl_id && } - {isSpectrum(chartId) && } {expandable && } { help_id && } @@ -317,7 +316,7 @@ function OptionsBtn({chartId}) { ); } -function SpectralLinesBtn() { +export function SpectralLinesBtn() { return ( showSpectralLinesDialog()}/> ); From c8947d4bfb0ebcb5ee47425d6baed1ca89267e81 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 21 Aug 2026 17:40:27 -0700 Subject: [PATCH 07/21] FIREFLY-2066: Minor wording/styling updates --- src/firefly/js/charts/ui/options/SpectralLines.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 39a42b3a26..d303e6676f 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -17,6 +17,7 @@ import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; +// TODO: move it to server side entirely // recommended-lines source lists, each independently checkable; listId is sent to the server's // "spectralLines" search processor to pick which resource file it serves. Adding a list later is // just appending an entry here (plus its listId -> resource mapping server-side). @@ -35,7 +36,7 @@ const LINES_TBL_UI_ID = `${LINES_TBL_ID}-ui`; const WAVELENGTH_COL = 'wavelength'; const LABEL_COL = 'label'; const DESCRIPTION_COL = 'description'; -const GROUP_COL = 'group'; +const GROUP_COL = 'list'; const WAVELENGTH_COL_UNIT = 'um'; // unit of WAVELENGTH_COL's values; TODO: source it from table metadata if present const LINES_TBL_COLUMNS = [ {name: WAVELENGTH_COL, units: WAVELENGTH_COL_UNIT, type: 'double'}, @@ -175,6 +176,7 @@ async function ensureRecommendedList(listId) { const tbl_id = recLinesTblId(listId); if (getTblById(tbl_id)) return; const request = makeTblRequest('spectralLines', 'Spectral Lines', {listId}, {tbl_id}); + // TODO: first get the list IDs via request.action = props or something and then a particular list dispatchTableFetch(request); // headless: doesn't render in results UI await onTableLoaded(tbl_id); } @@ -275,7 +277,7 @@ export function SpectralLinesPanel() { initialState={{value: initialSourceOptions}} options={[ ...RECOMMENDED_LINE_LISTS.map(({listId, listLabel}) => - ({label: `${listLabel} (recommended)`, value: listId})), + ({label: listLabel, value: listId})), {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} ]}/> {/* TODO: "Upload mine" — file upload + column mapper */} @@ -309,8 +311,8 @@ export function SpectralLinesPanel() { /> {selectedCount === 0 - ? 0 lines selected - nothing plotted on spectral chart(s) - : 0 lines selected - nothing plotted on spectral chart(s) + : }> {selectedCount} lines From 780986ef1a2ba0ad3d39403eafec579916e8d45d Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Thu, 3 Sep 2026 17:15:10 -0700 Subject: [PATCH 08/21] FIREFLY-2066: Get recommended line lists from server-side only --- .../server/query/SpectralLinesProcessor.java | 43 +++++++++++++---- .../js/charts/ui/options/SpectralLines.jsx | 48 ++++++++++++------- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java index cde4dcbab3..02874995cc 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java @@ -5,35 +5,45 @@ import edu.caltech.ipac.firefly.data.TableServerRequest; import edu.caltech.ipac.table.DataGroup; +import edu.caltech.ipac.table.DataType; import edu.caltech.ipac.table.TableUtil; import edu.caltech.ipac.util.FileUtil; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.util.Map; +import java.util.List; /** * Serves one of the recommended spectral line lists (fixed resource datasets) as a table, - * selected by the request's "listId" param. + * selected by the request's "listId" param. When "metaOnly" is true, returns an empty table + * whose tableMeta.lineLists carries the available {listId, listLabel} pairs as a JSON array, + * so the client can discover what's available. */ @SearchProcessorImpl(id = "spectralLines") public class SpectralLinesProcessor extends EmbeddedDbProcessor { - private static final Map RESOURCES = Map.of( - "luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv", - "jwst", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl" + + public record LineListInfo(String listId, String listLabel, String resource) {} + + public static final List LINE_LISTS = List.of( + new LineListInfo("luisa", "Luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv"), + new LineListInfo("jwst", "JWST", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl") ); public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessException { + if (req.getBooleanParam("metaOnly")) return lineListsMetaDataGroup(); + String listId = req.getParam("listId"); - String resource = RESOURCES.get(listId); - if (resource == null) throw new DataAccessException("Unknown or missing spectral lines listId: " + listId); + LineListInfo info = LINE_LISTS.stream().filter(l -> l.listId().equals(listId)).findFirst() + .orElseThrow(() -> new DataAccessException("Unknown or missing spectral lines listId: " + listId)); - try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(resource)) { - if (is == null) throw new IOException("Resource not found: " + resource); + try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(info.resource())) { + if (is == null) throw new IOException("Resource not found: " + info.resource()); // readAnyFormat needs a File; copy the classpath resource to a temp file first - String ext = resource.substring(resource.lastIndexOf('.')); + String ext = info.resource().substring(info.resource().lastIndexOf('.')); File tempFile = createTempFile(req, ext); FileUtil.writeToFile(is, tempFile, null); @@ -42,4 +52,17 @@ public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessExcepti throw new DataAccessException("Unable to read spectral lines resource", e); } } + + private static DataGroup lineListsMetaDataGroup() { + DataGroup dg = new DataGroup("Spectral Line Lists", new DataType[0]); + JSONArray lists = new JSONArray(); + LINE_LISTS.forEach(info -> { + JSONObject o = new JSONObject(); + o.put("listId", info.listId()); + o.put("listLabel", info.listLabel()); + lists.add(o); + }); + dg.getTableMeta().setAttribute("lineLists", lists.toJSONString()); + return dg; + } } diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index d303e6676f..f83089c754 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {isEqual} from 'lodash'; import {Button, Divider, Stack, Typography} from '@mui/joy'; import {Insights} from '@mui/icons-material'; @@ -11,20 +11,12 @@ import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; import {canUnitConv, convertUnitValue} from '../../dataTypes/SpectrumUnitConversion.js'; import {makeTblRequest} from 'firefly/tables/TableRequestUtil'; import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableAddLocal, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; -import {onTableLoaded, getTblById, getSelectedDataSync, getTblRowAsObj, getColumnValues, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; +import {onTableLoaded, doFetchTable, getTblById, getSelectedDataSync, getTblRowAsObj, getColumnValues, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; -// TODO: move it to server side entirely -// recommended-lines source lists, each independently checkable; listId is sent to the server's -// "spectralLines" search processor to pick which resource file it serves. Adding a list later is -// just appending an entry here (plus its listId -> resource mapping server-side). -const RECOMMENDED_LINE_LISTS = [ - {listId: 'luisa', listLabel: 'Luisa'}, - {listId: 'jwst', listLabel: 'JWST'}, -]; const recLinesTblId = (listId) => `rec-${listId}`; // order-insensitive: checking source options checkboxes in a different order shouldn't count as a real difference @@ -171,12 +163,26 @@ export function useSpectralLinesSync(chartId) { }, [chartId]); } +let lineListsPromise = null; // cache populated by fetchLineLists() + +/** + * Fetches the server's info on available recommended line lists ({listId, listLabel} pairs), once per + * session (cached in lineListsPromise) - the server is the single source of truth for which lists exist. + * @returns {Promise>} + */ +function fetchLineLists() { + if (!lineListsPromise) { + const request = makeTblRequest('spectralLines', 'Spectral Line Lists', {metaOnly: true}); + lineListsPromise = doFetchTable(request).then((tbl) => JSON.parse(tbl.tableMeta?.lineLists ?? '[]')); + } + return lineListsPromise; +} + /* fetches a single recommended-lines source table (by its listId) if not already loaded */ async function ensureRecommendedList(listId) { const tbl_id = recLinesTblId(listId); if (getTblById(tbl_id)) return; const request = makeTblRequest('spectralLines', 'Spectral Lines', {listId}, {tbl_id}); - // TODO: first get the list IDs via request.action = props or something and then a particular list dispatchTableFetch(request); // headless: doesn't render in results UI await onTableLoaded(tbl_id); } @@ -187,10 +193,11 @@ async function ensureRecommendedList(listId) { * LINES_TBL_ID's content changes - called once on panel mount and again on the "Update Lines" button click, * never automatically on checkbox change, so checking a box doesn't plot anything until applied. * @param {string} sourceOptions - comma-separated checked values from SOURCE_OPTIONS_KEY + * @param {Array<{listId: string, listLabel: string}>} lineLists - the fetched info on available lists */ -async function buildMergedLinesTable(sourceOptions) { +async function buildMergedLinesTable(sourceOptions, lineLists) { const checked = splitVals(sourceOptions); - const checkedLists = RECOMMENDED_LINE_LISTS.filter(({listId}) => checked.includes(listId)); + const checkedLists = lineLists.filter(({listId}) => checked.includes(listId)); await Promise.all(checkedLists.map(({listId}) => ensureRecommendedList(listId))); // TODO: once upload + column mapping is implemented, include checked upload groups here too @@ -219,12 +226,17 @@ export function SpectralLinesPanel() { const initialSourceOptions = ''; // nothing checked by default - lines table starts empty until applied const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); + const [lineLists, setLineLists] = useState([]); + useEffect(() => { // pre-register tbl_ui_id so columns/columnWidths get populated once loaded (TablePanel mounts later, too late) dispatchTableUiUpdate({tbl_ui_id: LINES_TBL_UI_ID, tbl_id: LINES_TBL_ID}); - // build only if it doesn't exist yet - once built, row selection is user-owned and must survive - // the dialog being closed/reopened; only the "Update Lines" button rebuilds after this point - if (!getTblById(LINES_TBL_ID)) void buildMergedLinesTable(sourceOptions); + void fetchLineLists().then((lists) => { + setLineLists(lists); + // build only if it doesn't exist yet - once built, row selection is user-owned and must survive + // the dialog being closed/reopened; only the "Update Lines" button rebuilds after this point + if (!getTblById(LINES_TBL_ID)) void buildMergedLinesTable(sourceOptions, lists); + }); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only; button click handles later rebuilds }, []); @@ -249,7 +261,7 @@ export function SpectralLinesPanel() { : undefined); const onUpdateLines = () => { - void buildMergedLinesTable(sourceOptions); + void buildMergedLinesTable(sourceOptions, lineLists); dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below }; @@ -276,7 +288,7 @@ export function SpectralLinesPanel() { alignment='vertical' initialState={{value: initialSourceOptions}} options={[ - ...RECOMMENDED_LINE_LISTS.map(({listId, listLabel}) => + ...lineLists.map(({listId, listLabel}) => ({label: listLabel, value: listId})), {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} ]}/> From 6613d94de0238a2649cfdd5493b5bf2048c62a5c Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 4 Sep 2026 17:23:57 -0700 Subject: [PATCH 09/21] FIREFLY-2066: Get recommended line lists from app config --- config/app.config | 4 + src/firefly/config/app.prop | 2 +- .../server/query/SpectralLinesProcessor.java | 78 ++++++++++++++----- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/config/app.config b/config/app.config index 7c4a84d16e..b1541f70b9 100644 --- a/config/app.config +++ b/config/app.config @@ -36,6 +36,10 @@ visualize.fits.Security= true // ehcahe replication port; suggest 4015-local, 5015-dev, 6015-test, 7015-ops, 7515-ops_int ehcache.multicast.port = "7015" +// Recommended spectral line lists in the Spectral Lines panel: a JSON array of {label, src} objects, in order. +// Omit "src" to use one of Firefly's bundled lists - any other label with no src is dropped (logged as an error). +// Set to "[]" to offer no spectral line lists at startup. +charts.spectrum.linelists = "[{\"label\": \"Luisa\"}, {\"label\": \"JWST\"}, {\"label\": \"JWST remote\", \"src\": \"https://gist.githubusercontent.com/jaladh-singhal/2b4230e2fc64586fbe7b51519d26ad3f/raw/21f503d13bc0e859d269d8acc782083f7fa84c7e/jwst_linelist.tbl\"}]" /* ------------------------ IRSA services --------------------------------- */ GatorHost = "https://irsa.ipac.caltech.edu" diff --git a/src/firefly/config/app.prop b/src/firefly/config/app.prop index 8888531480..5a11bdc8e9 100644 --- a/src/firefly/config/app.prop +++ b/src/firefly/config/app.prop @@ -18,7 +18,7 @@ ignore.auth=@ignore.auth@ visualize.fits.Security= @visualize.fits.Security@ python.exe= @python.exe@ - +charts.spectrum.linelists=@charts.spectrum.linelists@ # IRSA Periodogram API irsa.gator.service.periodogram.url=@irsa.gator.service.periodogram.url@ diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java index 02874995cc..aeaea15db0 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java @@ -4,34 +4,70 @@ package edu.caltech.ipac.firefly.server.query; import edu.caltech.ipac.firefly.data.TableServerRequest; +import edu.caltech.ipac.firefly.server.packagedata.obscorepackager.ObsCoreUtil; +import edu.caltech.ipac.firefly.server.util.Logger; import edu.caltech.ipac.table.DataGroup; import edu.caltech.ipac.table.DataType; import edu.caltech.ipac.table.TableUtil; +import edu.caltech.ipac.util.AppProperties; import edu.caltech.ipac.util.FileUtil; +import edu.caltech.ipac.util.download.URLDownload; import org.json.simple.JSONArray; import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; import java.util.List; +import java.util.Map; /** - * Serves one of the recommended spectral line lists (fixed resource datasets) as a table, - * selected by the request's "listId" param. When "metaOnly" is true, returns an empty table - * whose tableMeta.lineLists carries the available {listId, listLabel} pairs as a JSON array, - * so the client can discover what's available. + * Serves one of the recommended spectral line lists as a table, selected by the request's "listId" param. + * When "metaOnly" is true, returns an empty table whose tableMeta.lineLists carries the available + * {listId, listLabel} pairs as a JSON array, so the client can discover what's available. + *

+ * The active set and ordering is driven by the "charts.spectrum.linelists" app config property, a JSON + * array of {label, src?} - src omitted falls back to BUNDLED_RESOURCES. The resolved src is fetched as a + * URL if it starts with http/https, otherwise as a classpath resource - so BUNDLED_RESOURCES can be a URL too. */ @SearchProcessorImpl(id = "spectralLines") public class SpectralLinesProcessor extends EmbeddedDbProcessor { + private static final Logger.LoggerImpl LOGGER = Logger.getLogger(); - public record LineListInfo(String listId, String listLabel, String resource) {} - - public static final List LINE_LISTS = List.of( - new LineListInfo("luisa", "Luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv"), - new LineListInfo("jwst", "JWST", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl") + private static final Map BUNDLED_RESOURCES = Map.of( + "Luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv", + "JWST", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl" ); + public record LineListInfo(String listId, String listLabel, String src) {} + + public static final List LINE_LISTS = parseLineListsConfig(); + + private static List parseLineListsConfig() { + List lists = new ArrayList<>(); + try { + JSONArray entries = (JSONArray) new JSONParser().parse(AppProperties.getProperty("charts.spectrum.linelists", "[]")); + for (Object o : entries) { + JSONObject entry = (JSONObject) o; + String label = (String) entry.get("label"); + String src = (String) entry.get("src"); + if (src == null) src = BUNDLED_RESOURCES.get(label); + if (src == null) { + LOGGER.error("charts.spectrum.linelists: no bundled resource for label \"" + label + "\" - dropping from spectral lines list"); + continue; + } + String id = ObsCoreUtil.makeValidString(label).replace(".", "-"); + lists.add(new LineListInfo(id, label, src)); + } + } catch (Exception e) { + LOGGER.error(e, "charts.spectrum.linelists: failed to parse config - no spectral line lists will be available"); + } + return lists; + } + public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessException { if (req.getBooleanParam("metaOnly")) return lineListsMetaDataGroup(); @@ -39,17 +75,21 @@ public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessExcepti LineListInfo info = LINE_LISTS.stream().filter(l -> l.listId().equals(listId)).findFirst() .orElseThrow(() -> new DataAccessException("Unknown or missing spectral lines listId: " + listId)); - try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(info.resource())) { - if (is == null) throw new IOException("Resource not found: " + info.resource()); - - // readAnyFormat needs a File; copy the classpath resource to a temp file first - String ext = info.resource().substring(info.resource().lastIndexOf('.')); - File tempFile = createTempFile(req, ext); - FileUtil.writeToFile(is, tempFile, null); - + boolean isUrl = info.src().toLowerCase().startsWith("http"); + try { + File tempFile = createTempFile(req, isUrl ? null : info.src().substring(info.src().lastIndexOf('.'))); + if (isUrl) { + URLDownload.getDataToFile(new URI(info.src()).toURL(), tempFile); + } else { + try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(info.src())) { + if (is == null) throw new IOException("Resource not found: " + info.src()); + FileUtil.writeToFile(is, tempFile, null); + } + } return TableUtil.readAnyFormat(tempFile, 0, req); - } catch (IOException e) { - throw new DataAccessException("Unable to read spectral lines resource", e); + } catch (Exception e) { + LOGGER.error(e, "Unable to load spectral line list \"" + info.listLabel() + "\" from " + info.src()); + throw new DataAccessException("Unable to read spectral lines resource for " + info.listLabel(), e); } } From 276ee64ad04c9de2e8dd9635b266c74a9b438066 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Tue, 8 Sep 2026 18:14:42 -0700 Subject: [PATCH 10/21] FIREFLY-2066: Add Upload table UI to SpectralLinesPanel --- .../js/charts/ui/options/SpectralLines.jsx | 65 +++++++++++++++---- src/firefly/js/ui/UploadTableChooser.js | 18 ++--- src/firefly/js/ui/UploadTableSelector.jsx | 18 ++++- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index f83089c754..de611378e3 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -4,7 +4,7 @@ import {Button, Divider, Stack, Typography} from '@mui/joy'; import {Insights} from '@mui/icons-material'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; -import {useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; +import {useFieldGroupValue, useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; import {getChartData, dispatchChartUpdate, CHART_UPDATE} from '../../ChartsCntlr.js'; import {isSpectrum} from '../../ChartUtil.js'; import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; @@ -16,6 +16,7 @@ import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; +import {MISSING_COLS_HEADER_MSG, UploadTableSelector} from 'firefly/ui/UploadTableSelector'; const recLinesTblId = (listId) => `rec-${listId}`; @@ -43,10 +44,17 @@ export const SPECTRAL_LINES_GROUP = 'lines'; const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; const SOURCES_COLLAPSIBLE_KEY = 'spectralLinesSources'; // panel is shared app-wide, not chart-specific - one fixed key - -// Field keys and option values --- const SOURCE_OPTIONS_KEY = 'spectralLines.sourceOptions'; // comma-separated checked values, e.g. CheckboxGroupInputField's value -const SOURCE_UPLOAD = 'upload'; +const UPLOAD_INFO_KEY = 'spectralLines.upload.info'; +const UPLOAD_WAVELENGTH_COL_KEY = 'spectralLines.upload.wavelengthCol'; +const UPLOAD_LABEL_COL_KEY = 'spectralLines.upload.labelCol'; +const UPLOAD_DESCRIPTION_COL_KEY = 'spectralLines.upload.descriptionCol'; +const UPLOAD_MAPPING_PANEL_KEY = 'spectralLinesUploadMapping'; +const UPLOAD_TBL_OPTIONS = { + // keeps the uploaded line list table from appearing in the Results view (as tbl_group defaults to 'main') + tbl_group: 'spectralLinesUpload' +}; + /** * Builds Plotly vertical-line shapes for the currently selected (checked) rows of a given spectral lines table. @@ -215,6 +223,36 @@ async function buildMergedLinesTable(sourceOptions, lineLists) { dispatchTableAddLocal(table, undefined, false); } +const uploadColumnFields = () => [ + {fieldKey: UPLOAD_WAVELENGTH_COL_KEY, name: 'Wavelength', + guessValue: (columns) => columns?.find(({name}) => ['wavelength', 'lambda'].includes(name.toLowerCase()))?.name ?? ''}, + {fieldKey: UPLOAD_LABEL_COL_KEY, name: 'Species Label'}, + {fieldKey: UPLOAD_DESCRIPTION_COL_KEY, name: 'Description (optional)'}, +]; + +// Wavelength/Label are required for the upload to be included in the merged table; Description stays optional, +// so (unlike UploadTableSelector's default header) it shouldn't trip the "Unspecified Column(s)" warning on its own +const uploadColumnMappingHeader = ([wavelengthCol, labelCol, descriptionCol]) => + (!wavelengthCol || !labelCol) + ? {MISSING_COLS_HEADER_MSG} + : `${wavelengthCol}, ${labelCol}` + (descriptionCol ? `, ${descriptionCol}` : ''); + +/* wraps the generic UploadTableSelector with the wavelength/label/description mapping for a spectral line list */ +function UploadTableSelectorSpectralLines({uploadInfo, setUploadInfo}) { + return ( + + ); +} + /** * Standalone Spectral Lines dialog content - not specific to the chart it was opened from; the lines table * it manages is shared app-wide across all spectrum charts (see `useSpectralLinesSync`). keepState=true on the @@ -226,6 +264,9 @@ export function SpectralLinesPanel() { const initialSourceOptions = ''; // nothing checked by default - lines table starts empty until applied const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); + const [getUploadInfo, setUploadInfo] = useFieldGroupValue(UPLOAD_INFO_KEY, SPECTRAL_LINES_FG_KEY); + const uploadInfo = getUploadInfo() || undefined; + const [lineLists, setLineLists] = useState([]); useEffect(() => { @@ -287,15 +328,15 @@ export function SpectralLinesPanel() { label='Line Lists:' alignment='vertical' initialState={{value: initialSourceOptions}} - options={[ - ...lineLists.map(({listId, listLabel}) => - ({label: listLabel, value: listId})), - {label: 'Upload mine', value: SOURCE_UPLOAD, disabled: true} - ]}/> - {/* TODO: "Upload mine" — file upload + column mapper */} + options={lineLists.map(({listId, listLabel}) => + ({label: listLabel, value: listId}))}/> + + Upload your own line list: + + + - - + {hasPendingChanges && changes in list(s) selection not yet loaded in table below diff --git a/src/firefly/js/ui/UploadTableChooser.js b/src/firefly/js/ui/UploadTableChooser.js index 1bbbdb54fa..6d1a156a3a 100644 --- a/src/firefly/js/ui/UploadTableChooser.js +++ b/src/firefly/js/ui/UploadTableChooser.js @@ -44,16 +44,17 @@ function getFitsColumnInfo(data) { }); } + +let tblCount = 0; /** * handle submit for an uploaded table * @param request * @param setUploadInfo * @param {DefaultColsEnabled} defaultColsEnabled - + * @param {TblOptions} [uploadTblOptions] - options for the uploaded table (see dispatchTableSearch()) * @returns {boolean} */ -let tblCount = 0; -function uploadSubmit(request,setUploadInfo,defaultColsEnabled) { +function uploadSubmit(request,setUploadInfo,defaultColsEnabled,uploadTblOptions) { if (!request) return false; const {additionalParams = {}, fileUpload: serverFile} = request; const {detailsModel, report, message, summaryModel, groupKey: summaryTblId, acceptList, @@ -100,7 +101,7 @@ function uploadSubmit(request,setUploadInfo,defaultColsEnabled) { const tblReq = makeFileRequest('Upload_Tbl_'+tblCount, serverFile, null, options); //tblReq.tbl_id = 'Upload_Tbl_' + tblReq.tbl_id; const uploadInfo = {serverFile, fileName, columns:columnsSelected, totalRows, fileSize, tableSource: UPLOAD_TBL_SOURCE, tbl_id: tblReq.tbl_id}; - dispatchTableSearch(tblReq); + dispatchTableSearch(tblReq, uploadTblOptions); setUploadInfo(uploadInfo); dispatchHideDialog(dialogId); return false; @@ -265,17 +266,18 @@ const LoadedTables= (props) => { * @param setUploadInfo * @param groupKey * @param {DefaultColsEnabled} defaultColsEnabledObj if this is non-empty, it will be used to replace the default selection of the uploaded table cols + * @param {TblOptions} [uploadTblOptions] - options for the uploaded table (see dispatchTableSearch()) */ -export function showUploadTableChooser(setUploadInfo,groupKey= 'table-chooser',defaultColsEnabledObj=undefined) { +export function showUploadTableChooser(setUploadInfo,groupKey= 'table-chooser',defaultColsEnabledObj=undefined,uploadTblOptions) { DialogRootContainer.defineDialog(dialogId, - + ); dispatchShowDialog(dialogId); } -const TableUploadPanel= ({setUploadInfo,groupKey= 'table-chooser',defaultColsEnabledObj}) => { +const TableUploadPanel= ({setUploadInfo,groupKey= 'table-chooser',defaultColsEnabledObj,uploadTblOptions}) => { const [isLoading, setLoading]= useState(false); return ( @@ -291,7 +293,7 @@ const TableUploadPanel= ({setUploadInfo,groupKey= 'table-chooser',defaultColsEna }, acceptOneItem:true, acceptList:[TABLES], keepState:true, groupKey:groupKey+'-fileUpload', onCancel:() => dispatchHideDialog(dialogId), - onSubmit:(request) => uploadSubmit(request,setUploadInfo,defaultColsEnabledObj), + onSubmit:(request) => uploadSubmit(request,setUploadInfo,defaultColsEnabledObj,uploadTblOptions), }}/> diff --git a/src/firefly/js/ui/UploadTableSelector.jsx b/src/firefly/js/ui/UploadTableSelector.jsx index 66cdbb6cf1..61b81312d3 100644 --- a/src/firefly/js/ui/UploadTableSelector.jsx +++ b/src/firefly/js/ui/UploadTableSelector.jsx @@ -38,12 +38,16 @@ const TAB_COLUMNS_EMPTY_MSG = 'Unable to identify coordinate columns for spatial * @param props.allowUploadColumnsSelection {boolean} - if true, show a button to select columns to upload * (note: this is different from columns mapping; selected upload columns can be more than the mapped columns) * @param props.defaultUploadColumnsSelection {DefaultColsEnabled} - default selection of columns to upload + * @param [props.allowClear] {boolean} - if true, show a chip button next to the uploaded table's name to clear it + * (resets uploadInfo and all mapped column field values, going back to the no-table-selected state) + * @param [props.uploadTblOptions] {TblOptions} - options for the uploaded table (see dispatchTableSearch()); + * e.g. {tbl_group} to add it somewhere other than 'main' (the Results view) * @param props.slotProps {Object} - slotProps for the component * @returns {Element} */ export function UploadTableSelector({uploadInfo, setUploadInfo, columnFields=[], columnMappingPanelKey, allowUploadColumnsSelection=true, defaultUploadColumnsSelection, - slotProps}) { + allowClear=false, uploadTblOptions, slotProps}) { const {getVal, setVal, register, unregister}= useContext(FieldGroupCtx); const columnFieldValues = useStoreConnector(() => columnFields.map(({fieldKey}) => getVal(fieldKey))); @@ -130,18 +134,26 @@ export function UploadTableSelector({uploadInfo, setUploadInfo, columnFields=[], const haveTable= Boolean(fileName && columns); + const onClear = () => { + setUploadInfo(undefined); + columnFields.forEach(({fieldKey}) => setVal(fieldKey, '')); + }; + return ( showUploadTableChooser(preSetUploadInfo, undefined, - defaultUploadColumnsSelection)} /> + defaultUploadColumnsSelection, uploadTblOptions)} /> {haveTable && {fileName} } + {haveTable && allowClear && + Clear + } {haveTable && @@ -191,6 +203,8 @@ UploadTableSelector.propTypes = { colTypes: PropTypes.arrayOf(PropTypes.string), colCount: PropTypes.number }), + allowClear: PropTypes.bool, + uploadTblOptions: PropTypes.object, slotProps: PropTypes.shape({ fileInfo: PropTypes.object, columnMappingPanel: PropTypes.shape({ From 5e38247b0bfaebac17709d78c087f8a759e64998 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Tue, 8 Sep 2026 19:09:45 -0700 Subject: [PATCH 11/21] FIREFLY-2066: Wire line merging logic in the Upload table feature --- .../js/charts/ui/options/SpectralLines.jsx | 99 +++++++++++++++---- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index de611378e3..25191ba099 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -196,29 +196,87 @@ async function ensureRecommendedList(listId) { } /** - * Rebuilds the merged, client-side lines table (LINES_TBL_ID) from the checked source options: one row per - * line, tagged with its source under GROUP_COL, with every row selected by default. This is the only place - * LINES_TBL_ID's content changes - called once on panel mount and again on the "Update Lines" button click, - * never automatically on checkbox change, so checking a box doesn't plot anything until applied. + * Builds the merged table's rows for the uploaded line list, per the user's column mapping. Wavelength and Label + * are required for the upload to contribute rows at all; Description is optional. + * @param {object} [uploadInfo] - uploadInfo from UploadTableSelector, or undefined if nothing's uploaded + * @param {string} wavelengthCol - name of the uploaded table's column mapped to wavelength + * @param {string} labelCol - name of the uploaded table's column mapped to label + * @param {string} descriptionCol - name of the uploaded table's column mapped to description, if any + * @returns {Promise>} + */ +async function uploadedLinesRows(uploadInfo, wavelengthCol, labelCol, descriptionCol) { + if (!uploadInfo?.tbl_id || !wavelengthCol || !labelCol) return []; + + await onTableLoaded(uploadInfo.tbl_id); + const src = getTblById(uploadInfo.tbl_id); + const rows = []; + for (let rowIdx = 0; rowIdx < (src?.totalRows ?? 0); rowIdx++) { + const row = getTblRowAsObj(src, rowIdx); + // TODO: do unit parsing and ensure wavelength is in microns in merged table + const wavelength = Number(row[wavelengthCol]); + if (!Number.isFinite(wavelength)) continue; // skip rows with a missing/unparsable wavelength + rows.push([wavelength, row[labelCol], descriptionCol ? row[descriptionCol] : '', uploadInfo.fileName]); + } + return rows; +} + +/** + * Builds the merged table's rows for the checked recommended line lists, fetching any not already loaded. * @param {string} sourceOptions - comma-separated checked values from SOURCE_OPTIONS_KEY * @param {Array<{listId: string, listLabel: string}>} lineLists - the fetched info on available lists + * @returns {Promise>} */ -async function buildMergedLinesTable(sourceOptions, lineLists) { +async function recommendedLinesRows(sourceOptions, lineLists) { const checked = splitVals(sourceOptions); const checkedLists = lineLists.filter(({listId}) => checked.includes(listId)); await Promise.all(checkedLists.map(({listId}) => ensureRecommendedList(listId))); - // TODO: once upload + column mapping is implemented, include checked upload groups here too - const data = checkedLists.flatMap(({listId, listLabel}) => { + return checkedLists.flatMap(({listId, listLabel}) => { const src = getTblById(recLinesTblId(listId)); return Array.from({length: src?.totalRows ?? 0}, (_, rowIdx) => { const row = getTblRowAsObj(src, rowIdx); return [row[WAVELENGTH_COL], row[LABEL_COL], row[DESCRIPTION_COL], listLabel]; }); }); +} - // store sourceOptions this table was built from in meta, so the panel can tell when the checked lists have since diverged - const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}, tableMeta: {sourceOptions}}; +/** + * Identity of "what would be merged from the upload right now", to detect when the upload/mapping has changed + * since the merged table was last built (see hasPendingChanges in SpectralLinesPanel). '' when the upload + * doesn't (yet) have a usable mapping, i.e. when it wouldn't contribute any rows - see uploadedLinesRows. + * @param {object} [uploadInfo] - see uploadedLinesRows + * @param {string} wavelengthCol - see uploadedLinesRows + * @param {string} labelCol - see uploadedLinesRows + * @param {string} descriptionCol - see uploadedLinesRows + * @returns {string} + */ +const uploadSignature = (uploadInfo, wavelengthCol, labelCol, descriptionCol) => + (uploadInfo?.tbl_id && wavelengthCol && labelCol) + ? [uploadInfo.tbl_id, wavelengthCol, labelCol, descriptionCol].join(';') + : ''; + +/** + * Rebuilds the merged, client-side lines table (LINES_TBL_ID) from recommendedLinesRows + uploadedLinesRows: one + * row per line, tagged with its source under GROUP_COL, every row selected by default. This is the only place + * LINES_TBL_ID's content changes - called once on panel mount and again on the "Update Lines" button click, + * never automatically on checkbox/mapping change, so those don't plot anything until applied. + * @param {string} sourceOptions - see recommendedLinesRows + * @param {Array<{listId: string, listLabel: string}>} lineLists - see recommendedLinesRows + * @param {object} [uploadInfo] - see uploadedLinesRows + * @param {string} wavelengthCol - see uploadedLinesRows + * @param {string} labelCol - see uploadedLinesRows + * @param {string} descriptionCol - see uploadedLinesRows + */ +async function buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, wavelengthCol, labelCol, descriptionCol) { + const [recRows, uploadRows] = await Promise.all([ + recommendedLinesRows(sourceOptions, lineLists), + uploadedLinesRows(uploadInfo, wavelengthCol, labelCol, descriptionCol), + ]); + const data = [...recRows, ...uploadRows]; + + // store what this table was built from in meta, so the panel can tell when the checked lists/upload have since diverged + const tableMeta = {sourceOptions, uploadSignature: uploadSignature(uploadInfo, wavelengthCol, labelCol, descriptionCol)}; + const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}, tableMeta}; table.selectInfo = SelectInfo.newInstance({selectAll: true, rowCount: data.length}).data; dispatchTableAddLocal(table, undefined, false); } @@ -266,6 +324,9 @@ export function SpectralLinesPanel() { const [getUploadInfo, setUploadInfo] = useFieldGroupValue(UPLOAD_INFO_KEY, SPECTRAL_LINES_FG_KEY); const uploadInfo = getUploadInfo() || undefined; + const uploadWavelengthCol = useFieldValueOnly(UPLOAD_WAVELENGTH_COL_KEY, '', SPECTRAL_LINES_FG_KEY); + const uploadLabelCol = useFieldValueOnly(UPLOAD_LABEL_COL_KEY, '', SPECTRAL_LINES_FG_KEY); + const uploadDescriptionCol = useFieldValueOnly(UPLOAD_DESCRIPTION_COL_KEY, '', SPECTRAL_LINES_FG_KEY); const [lineLists, setLineLists] = useState([]); @@ -276,22 +337,26 @@ export function SpectralLinesPanel() { setLineLists(lists); // build only if it doesn't exist yet - once built, row selection is user-owned and must survive // the dialog being closed/reopened; only the "Update Lines" button rebuilds after this point - if (!getTblById(LINES_TBL_ID)) void buildMergedLinesTable(sourceOptions, lists); + if (!getTblById(LINES_TBL_ID)) { + void buildMergedLinesTable(sourceOptions, lists, uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol); + } }); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only; button click handles later rebuilds }, []); - const {selectedCount, groupsCount, linesCount, loadedSourceOptions} = useStoreConnector(() => { + const {selectedCount, groupsCount, linesCount, loadedSourceOptions, loadedUploadSignature} = useStoreConnector(() => { const tbl = getTblById(LINES_TBL_ID); const linesCount = tbl?.totalRows ?? 0; const groupsCount = linesCount ? new Set(getColumnValues(tbl, GROUP_COL)).size : 0; const selectedCount = SelectInfo.newInstance(tbl?.selectInfo).getSelectedCount(); const loadedSourceOptions = tbl?.tableMeta?.sourceOptions ?? ''; - return {selectedCount, groupsCount, linesCount, loadedSourceOptions}; + const loadedUploadSignature = tbl?.tableMeta?.uploadSignature ?? ''; + return {selectedCount, groupsCount, linesCount, loadedSourceOptions, loadedUploadSignature}; }, []); - // true whenever the checked lists above no longer match what's actually loaded into the table below - const hasPendingChanges = !sameSourceOptions(sourceOptions, loadedSourceOptions); + // true whenever the checked lists/upload mapping above no longer match what's actually loaded into the table below + const hasPendingChanges = !sameSourceOptions(sourceOptions, loadedSourceOptions) || + uploadSignature(uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol) !== loadedUploadSignature; const plotHelperText = linesCount === 0 ? (hasPendingChanges @@ -302,7 +367,7 @@ export function SpectralLinesPanel() { : undefined); const onUpdateLines = () => { - void buildMergedLinesTable(sourceOptions, lineLists); + void buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol); dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below }; @@ -325,7 +390,7 @@ export function SpectralLinesPanel() { isOpen={true}> @@ -339,7 +404,7 @@ export function SpectralLinesPanel() { {hasPendingChanges && - changes in list(s) selection not yet loaded in table below + changes above not yet loaded in table below } From 8e90cd3e5581d9a5f31376cec0634734701cb852 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Wed, 9 Sep 2026 17:07:33 -0700 Subject: [PATCH 12/21] FIREFLY-2066: Polish the layout of SpectralLinesPanel --- .../js/charts/ui/options/SpectralLines.jsx | 115 ++++++++++-------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 25191ba099..0b9c234bb7 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -373,33 +373,38 @@ export function SpectralLinesPanel() { return ( - - - ( - - Select line lists to load - {!isOpen && - - {linesCount === 0 - ? 'No lines loaded' - : `${groupsCount} group${groupsCount === 1 ? '' : 's'}, ${linesCount} line${linesCount === 1 ? '' : 's'} loaded`} - } - - )} - isOpen={true}> - - - ({label: listLabel, value: listId}))}/> - - Upload your own line list: - + + + + ( + + Select line lists to load + {!isOpen && + + {linesCount === 0 + ? 'No lines loaded' + : `${groupsCount} list${groupsCount === 1 ? '' : 's'}, ${linesCount} line${linesCount === 1 ? '' : 's'} loaded`} + } + + )} + isOpen={true}> + + + ({label: listLabel, value: listId}))}/> + + Upload your own line list: + + - + {hasPendingChanges && @@ -407,35 +412,39 @@ export function SpectralLinesPanel() { changes above not yet loaded in table below } + + + + Select lines to plot: + {plotHelperText && + {plotHelperText}} + + undefined} + /> - - - - Select lines to plot: - {plotHelperText && - {plotHelperText}} - - - {selectedCount === 0 - ? 0 lines selected - nothing plotted on spectral chart(s) - : }> - {selectedCount} lines -  selected - plotted live on spectral chart(s) ↘ - } + + + + }> + {selectedCount === 0 + ? '0 lines selected - nothing plotted on spectral chart(s) ↘' + : <> + {selectedCount} lines +  selected - plotted live on spectral chart(s) ↘ + } + From 05cae644965e3626d20cf69ac0ac68a96a2a31ad Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Wed, 9 Sep 2026 17:30:50 -0700 Subject: [PATCH 13/21] FIREFLY-2066: Add a "Clear All" button --- .../js/charts/ui/options/SpectralLines.jsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 0b9c234bb7..648f638b41 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -4,7 +4,7 @@ import {Button, Divider, Stack, Typography} from '@mui/joy'; import {Insights} from '@mui/icons-material'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; import {CollapsibleGroup, CollapsibleItem} from 'firefly/ui/panel/CollapsiblePanel'; -import {useFieldGroupValue, useFieldValueOnly, useStoreConnector} from 'firefly/ui/SimpleComponent'; +import {useFieldGroupValue, useStoreConnector} from 'firefly/ui/SimpleComponent'; import {getChartData, dispatchChartUpdate, CHART_UPDATE} from '../../ChartsCntlr.js'; import {isSpectrum} from '../../ChartUtil.js'; import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; @@ -320,13 +320,17 @@ export function SpectralLinesPanel() { // FieldGroup has keepState=true, so this fixed default only matters the very first time this // session the dialog is opened - after that, the group's own last-seen value takes over const initialSourceOptions = ''; // nothing checked by default - lines table starts empty until applied - const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions, SPECTRAL_LINES_FG_KEY); + const [getSourceOptions, setSourceOptions] = useFieldGroupValue(SOURCE_OPTIONS_KEY, SPECTRAL_LINES_FG_KEY); + const sourceOptions = getSourceOptions() ?? initialSourceOptions; const [getUploadInfo, setUploadInfo] = useFieldGroupValue(UPLOAD_INFO_KEY, SPECTRAL_LINES_FG_KEY); const uploadInfo = getUploadInfo() || undefined; - const uploadWavelengthCol = useFieldValueOnly(UPLOAD_WAVELENGTH_COL_KEY, '', SPECTRAL_LINES_FG_KEY); - const uploadLabelCol = useFieldValueOnly(UPLOAD_LABEL_COL_KEY, '', SPECTRAL_LINES_FG_KEY); - const uploadDescriptionCol = useFieldValueOnly(UPLOAD_DESCRIPTION_COL_KEY, '', SPECTRAL_LINES_FG_KEY); + const [getUploadWavelengthCol, setUploadWavelengthCol] = useFieldGroupValue(UPLOAD_WAVELENGTH_COL_KEY, SPECTRAL_LINES_FG_KEY); + const uploadWavelengthCol = getUploadWavelengthCol() ?? ''; + const [getUploadLabelCol, setUploadLabelCol] = useFieldGroupValue(UPLOAD_LABEL_COL_KEY, SPECTRAL_LINES_FG_KEY); + const uploadLabelCol = getUploadLabelCol() ?? ''; + const [getUploadDescriptionCol, setUploadDescriptionCol] = useFieldGroupValue(UPLOAD_DESCRIPTION_COL_KEY, SPECTRAL_LINES_FG_KEY); + const uploadDescriptionCol = getUploadDescriptionCol() ?? ''; const [lineLists, setLineLists] = useState([]); @@ -366,11 +370,21 @@ export function SpectralLinesPanel() { ? 'Showing previously loaded lines - click "Load Lines" above to reflect the changes in list(s) selection' : undefined); - const onUpdateLines = () => { + const onLoadLines = () => { void buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol); dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below }; + // clears every source (checked lists + upload/mapping) and immediately rebuilds to a truly empty lines table + const onClearAll = () => { + setSourceOptions(''); + setUploadInfo(undefined); + setUploadWavelengthCol(''); + setUploadLabelCol(''); + setUploadDescriptionCol(''); + void buildMergedLinesTable('', lineLists, undefined, '', '', ''); + }; + return ( @@ -405,8 +419,11 @@ export function SpectralLinesPanel() { mt: 3, mb: 1, mx: 'calc(-1 * var(--ListItem-paddingX))' // to extend to the edges of collapsible }}/> - - + + + + + {hasPendingChanges && changes above not yet loaded in table below From 58831f0cfb71f3f2c5b2bab61cabb1b162c455ef Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Thu, 10 Sep 2026 12:47:05 -0700 Subject: [PATCH 14/21] FIREFLY-2066: Handle unit conversion at source to lines loading boundary --- .../server/query/SpectralLinesProcessor.java | 13 +++- .../js/charts/ui/options/SpectralLines.jsx | 73 +++++++++++++------ src/firefly/js/ui/UploadTableSelector.jsx | 20 +++-- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java index aeaea15db0..4c3a2de41f 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java @@ -11,6 +11,7 @@ import edu.caltech.ipac.table.TableUtil; import edu.caltech.ipac.util.AppProperties; import edu.caltech.ipac.util.FileUtil; +import edu.caltech.ipac.util.StringUtils; import edu.caltech.ipac.util.download.URLDownload; import org.json.simple.JSONArray; import org.json.simple.JSONObject; @@ -41,6 +42,7 @@ public class SpectralLinesProcessor extends EmbeddedDbProcessor { "Luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv", "JWST", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl" ); + private static final String WAVELENGTH_COL = "wavelength"; // must match SpectralLines.jsx's WAVELENGTH_COL public record LineListInfo(String listId, String listLabel, String src) {} @@ -86,7 +88,16 @@ public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessExcepti FileUtil.writeToFile(is, tempFile, null); } } - return TableUtil.readAnyFormat(tempFile, 0, req); + DataGroup dg = TableUtil.readAnyFormat(tempFile, 0, req); + DataType wlCol = dg.getDataDefintion(WAVELENGTH_COL); + if (wlCol == null) { + LOGGER.warn(String.format("Spectral line list \"%s\" from %s: \"%s\" column is missing - no lines will be loaded from this list.", + info.listLabel(), info.src(), WAVELENGTH_COL)); + } else if (StringUtils.isEmpty(wlCol.getUnits())) { + LOGGER.warn(String.format("Spectral line list \"%s\" from %s: \"%s\" column has no units metadata - client will assume microns.", + info.listLabel(), info.src(), WAVELENGTH_COL)); + } + return dg; } catch (Exception e) { LOGGER.error(e, "Unable to load spectral line list \"" + info.listLabel() + "\" from " + info.src()); throw new DataAccessException("Unable to read spectral lines resource for " + info.listLabel(), e); diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 648f638b41..ba6d9dbb56 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -11,7 +11,8 @@ import {isKnownRefPos} from 'firefly/voAnalyzer/SpectrumDM'; import {canUnitConv, convertUnitValue} from '../../dataTypes/SpectrumUnitConversion.js'; import {makeTblRequest} from 'firefly/tables/TableRequestUtil'; import {dispatchTableFetch, dispatchTableUiUpdate, dispatchTableAddLocal, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; -import {onTableLoaded, doFetchTable, getTblById, getSelectedDataSync, getTblRowAsObj, getColumnValues, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; +import {onTableLoaded, doFetchTable, getColumn, isColumnType, COL_TYPE, getTblById, getSelectedDataSync, + getTblRowAsObj, getColumnValues, splitVals, monitorChanges, watchTableChanges} from 'firefly/tables/TableUtil'; import {SelectInfo} from 'firefly/tables/SelectInfo'; import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; @@ -30,7 +31,7 @@ const WAVELENGTH_COL = 'wavelength'; const LABEL_COL = 'label'; const DESCRIPTION_COL = 'description'; const GROUP_COL = 'list'; -const WAVELENGTH_COL_UNIT = 'um'; // unit of WAVELENGTH_COL's values; TODO: source it from table metadata if present +const WAVELENGTH_COL_UNIT = 'um'; // canonical unit of WAVELENGTH_COL's values const LINES_TBL_COLUMNS = [ {name: WAVELENGTH_COL, units: WAVELENGTH_COL_UNIT, type: 'double'}, {name: LABEL_COL, type: 'char'}, @@ -195,6 +196,31 @@ async function ensureRecommendedList(listId) { await onTableLoaded(tbl_id); } +/** + * Builds rows for the merged-table from a source table: converts each row's wavelength to the merged table's + * canonical unit (WAVELENGTH_COL_UNIT) and tags every row with the given list group label. + * @param {TableModel} src + * @param {string} wavelengthCol - name of src's wavelength column + * @param {string} labelCol - name of src's label column + * @param {string} descriptionCol - name of src's description column; if none, description row values are set to '' + * @param {string} group - value for GROUP_COL, tagging where these rows came from + * @returns {Array} [] if src's wavelength unit isn't convertible to WAVELENGTH_COL_UNIT at all; rows with + * a missing/unparsable wavelength are skipped individually + */ +function makeLinesRows(src, wavelengthCol, labelCol, descriptionCol, group) { + const wavelengthUnit = getColumn(src, wavelengthCol)?.units || WAVELENGTH_COL_UNIT; + if (!canUnitConv({from: wavelengthUnit, to: WAVELENGTH_COL_UNIT})) return []; + + const rows = []; + for (let rowIdx = 0; rowIdx < (src?.totalRows ?? 0); rowIdx++) { + const row = getTblRowAsObj(src, rowIdx); + const wavelength = convertUnitValue(Number(row[wavelengthCol]), wavelengthUnit, WAVELENGTH_COL_UNIT); + if (!Number.isFinite(wavelength)) continue; // skip rows with a missing/unparsable wavelength + rows.push([wavelength, row[labelCol], descriptionCol ? row[descriptionCol] : '', group]); + } + return rows; +} + /** * Builds the merged table's rows for the uploaded line list, per the user's column mapping. Wavelength and Label * are required for the upload to contribute rows at all; Description is optional. @@ -208,16 +234,7 @@ async function uploadedLinesRows(uploadInfo, wavelengthCol, labelCol, descriptio if (!uploadInfo?.tbl_id || !wavelengthCol || !labelCol) return []; await onTableLoaded(uploadInfo.tbl_id); - const src = getTblById(uploadInfo.tbl_id); - const rows = []; - for (let rowIdx = 0; rowIdx < (src?.totalRows ?? 0); rowIdx++) { - const row = getTblRowAsObj(src, rowIdx); - // TODO: do unit parsing and ensure wavelength is in microns in merged table - const wavelength = Number(row[wavelengthCol]); - if (!Number.isFinite(wavelength)) continue; // skip rows with a missing/unparsable wavelength - rows.push([wavelength, row[labelCol], descriptionCol ? row[descriptionCol] : '', uploadInfo.fileName]); - } - return rows; + return makeLinesRows(getTblById(uploadInfo.tbl_id), wavelengthCol, labelCol, descriptionCol, uploadInfo.fileName); } /** @@ -231,13 +248,8 @@ async function recommendedLinesRows(sourceOptions, lineLists) { const checkedLists = lineLists.filter(({listId}) => checked.includes(listId)); await Promise.all(checkedLists.map(({listId}) => ensureRecommendedList(listId))); - return checkedLists.flatMap(({listId, listLabel}) => { - const src = getTblById(recLinesTblId(listId)); - return Array.from({length: src?.totalRows ?? 0}, (_, rowIdx) => { - const row = getTblRowAsObj(src, rowIdx); - return [row[WAVELENGTH_COL], row[LABEL_COL], row[DESCRIPTION_COL], listLabel]; - }); - }); + return checkedLists.flatMap(({listId, listLabel}) => + makeLinesRows(getTblById(recLinesTblId(listId)), WAVELENGTH_COL, LABEL_COL, DESCRIPTION_COL, listLabel)); } /** @@ -282,8 +294,21 @@ async function buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, wavel } const uploadColumnFields = () => [ - {fieldKey: UPLOAD_WAVELENGTH_COL_KEY, name: 'Wavelength', - guessValue: (columns) => columns?.find(({name}) => ['wavelength', 'lambda'].includes(name.toLowerCase()))?.name ?? ''}, + { + fieldKey: UPLOAD_WAVELENGTH_COL_KEY, + name: 'Wavelength', + guessValue: (columns) => columns?.find(({name}) => + ['wavelength', 'lambda'].includes(name.toLowerCase()))?.name ?? '', + getFeedback: (value, columns) => { + if (!value) return undefined; + const col = columns?.find((c) => c.name === value); + if (!isColumnType(col, COL_TYPE.NUMBER)) return 'Column type is not numeric - none of its rows will load as lines.'; + const unit = col?.units; + if (!unit) return 'Column unit is unspecified - rows will load as lines assuming µm, which may be wrong.'; + return canUnitConv({from: unit, to: WAVELENGTH_COL_UNIT}) + ? `Column unit "${unit}" recognized - rows will load as lines in µm.` + : `Column unit "${unit}" not recognized - none of its rows will load as lines.`;} + }, {fieldKey: UPLOAD_LABEL_COL_KEY, name: 'Species Label'}, {fieldKey: UPLOAD_DESCRIPTION_COL_KEY, name: 'Description (optional)'}, ]; @@ -293,7 +318,9 @@ const uploadColumnFields = () => [ const uploadColumnMappingHeader = ([wavelengthCol, labelCol, descriptionCol]) => (!wavelengthCol || !labelCol) ? {MISSING_COLS_HEADER_MSG} - : `${wavelengthCol}, ${labelCol}` + (descriptionCol ? `, ${descriptionCol}` : ''); + : + {`${wavelengthCol}, ${labelCol}` + (descriptionCol ? `, ${descriptionCol}` : '')} + ; /* wraps the generic UploadTableSelector with the wavelength/label/description mapping for a spectral line list */ function UploadTableSelectorSpectralLines({uploadInfo, setUploadInfo}) { @@ -387,7 +414,7 @@ export function SpectralLinesPanel() { return ( - + string + guessValue: PropTypes.func, //(columns) => string + getFeedback: PropTypes.func //(value, columns) => node })); UploadTableSelector.propTypes = { @@ -268,11 +270,15 @@ export function ColumnMappingPanel({cols, columnFieldValues, columnFields, panel } {!children && ( - {columnFields.map((columnField) => ( - - - - ))} + {columnFields.map((columnField, i) => { + const feedback = columnField.getFeedback?.(columnFieldValues[i], cols); + return ( + + + {feedback && {feedback}} + + ); + })} )} {children} From 8c72644daff519a04802c2fc9c5f3684ff40083e Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Thu, 10 Sep 2026 14:58:33 -0700 Subject: [PATCH 15/21] FIREFLY-2066: Show a better empty table message Fix the layout styling issues with NoDataTableView --- src/firefly/js/charts/ui/options/SpectralLines.jsx | 1 + src/firefly/js/tables/ui/BasicTableView.jsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index ba6d9dbb56..bb46f871fa 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -289,6 +289,7 @@ async function buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, wavel // store what this table was built from in meta, so the panel can tell when the checked lists/upload have since diverged const tableMeta = {sourceOptions, uploadSignature: uploadSignature(uploadInfo, wavelengthCol, labelCol, descriptionCol)}; const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}, tableMeta}; + if (data.length === 0) table.status = {code: 204, message: 'No lines to display yet'}; // to replace default "No Data Found" status table.selectInfo = SelectInfo.newInstance({selectAll: true, rowCount: data.length}).data; dispatchTableAddLocal(table, undefined, false); } diff --git a/src/firefly/js/tables/ui/BasicTableView.jsx b/src/firefly/js/tables/ui/BasicTableView.jsx index 1b028c2837..4cce059bb7 100644 --- a/src/firefly/js/tables/ui/BasicTableView.jsx +++ b/src/firefly/js/tables/ui/BasicTableView.jsx @@ -95,7 +95,8 @@ const tableStyleOverrides = { export const NoDataTableView = ({sx, children}) => ( - + {children} ); @@ -217,7 +218,8 @@ const BasicTableViewInternal = React.memo(({ selectable:selectableIn= false, sho tstate === TBL_STATE.NO_MATCH ? msg || noDataFromFilter : tstate === TBL_STATE.LOADING ? 'Loading...' : ''; - if (status) return {status} ; + // adjust "top" sx to center within the rows area below the header, not the header+rows box as a whole + if (status) return {status} ; else return null; }; From d32257dcd273eb052cb9d7128b8c00b1a5ec63aa Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Thu, 10 Sep 2026 18:30:05 -0700 Subject: [PATCH 16/21] FIREFLY-2066: Fix the resolveSpectralLinesRedshift when spectral options is unmounted --- .../js/charts/ui/options/SpectralLines.jsx | 6 +++--- .../js/charts/ui/options/SpectrumOptions.jsx | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index bb46f871fa..1f72b0019d 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -18,6 +18,7 @@ import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; import {MISSING_COLS_HEADER_MSG, UploadTableSelector} from 'firefly/ui/UploadTableSelector'; +import {getEffectiveSpectralFrameOption} from './SpectrumOptions.jsx'; const recLinesTblId = (listId) => `rec-${listId}`; @@ -112,9 +113,8 @@ function resolveSpectralLinesRedshift(fireflyData, activeTrace) { // a redshift is only resolvable when Spectral Frame options are shown (as opposed to a read-only value) if (!isKnownRefPos(fireflyData?.[activeTrace]?.spectralFrame?.refPos)) return undefined; - // TODO: spectralFrameOption is undefined until Modify Trace is applied at least once, so this - // assumes rest-frame (0) until then even if the real default would be observed with a redshift - const {value: sfOption, redshift: redshiftOption, userSpecified} = fireflyData?.[activeTrace]?.spectralFrameOption ?? {}; + // falls back to the same default a fresh spectrum options panel would show, rather than assuming rest-frame + const {value: sfOption, redshift: redshiftOption, userSpecified} = getEffectiveSpectralFrameOption(fireflyData?.[activeTrace]); if (sfOption !== 'observed') return 0; const redshift = redshiftOption === 'userSpecified' ? userSpecified : redshiftOption; return Number(redshift) || 0; diff --git a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx index 6fcb09c112..e1d12cdca9 100644 --- a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx +++ b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx @@ -280,7 +280,7 @@ export function submitChangesSpectrum({chartId, activeTrace, fields, tbl_id, ren // get units and spectral frame options from the fields of active trace const xUnit = fields[`fireflyData.${activeTrace}.xUnit`]; const yUnit = fields[`fireflyData.${activeTrace}.yUnit`]; // undefined if no field for yUnit - const currentSFOptionFields = getEffectiveSFOptionFields(fireflyData?.[activeTrace]); + const currentSFOptionFields = getEffectiveSpectralFrameOption(fireflyData?.[activeTrace]); const sfFieldKeys = SFOptionFieldKeys(activeTrace); const sfOptionFields = { value: fields[sfFieldKeys.value] ?? currentSFOptionFields.value, @@ -342,13 +342,20 @@ export function submitChangesSpectrum({chartId, activeTrace, fields, tbl_id, ren submitChangesScatter({chartId, activeTrace, fields, tbl_id, renderTreeId}); } -function getEffectiveSFOptionFields(trace={}) { +/** + * Get a trace's spectral-frame option: explicit spectralFrameOption fields, or the same defaults SpectralFrameOptions + * would show on first mount for any field not yet chosen. + * @param {object} [trace] - fireflyData[traceIdx] + * @returns {{value: string, redshift: string, userSpecified: string}} + */ +export function getEffectiveSpectralFrameOption(trace={}) { const spectralFrame = trace.spectralFrame || {}; const spectralFrameOption = trace.spectralFrameOption || {}; const refPos = spectralFrame.refPos?.toUpperCase?.(); return { value: spectralFrameOption.value ?? (refPos === REF_POS.TOPOCENTER ? 'observed' : 'rest'), - redshift: spectralFrameOption.redshift ?? 'userSpecified', + // redshift defaults to getRedshiftOptions' first entry (because of radio button group) + redshift: spectralFrameOption.redshift ?? getRedshiftOptions(trace)[0]?.value ?? 'userSpecified', userSpecified: spectralFrameOption.userSpecified ?? '0' }; } @@ -405,8 +412,8 @@ const SFOptionFieldKeys = (activeTrace) => { return Object.fromEntries(['value', 'redshift', 'userSpecified'].map((subKey)=>[subKey, `${baseKey}.${subKey}`])); }; -function getRedshiftOptions({target, derivedRedshift, spectralFrame}){ //TODO: memoize it? - const refPos = spectralFrame.refPos.toUpperCase(); +function getRedshiftOptions({target, derivedRedshift, spectralFrame}={}){ //TODO: memoize it? + const refPos = spectralFrame?.refPos?.toUpperCase?.(); let options = []; if (target?.redshift) { From b88ed8e86fb2654e40d40e0d381f0dc98f1be240 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Thu, 10 Sep 2026 18:55:54 -0700 Subject: [PATCH 17/21] FIREFLY-2066: Cleanup redundant and hardcoded stuff in SpectrumOptions.jsx --- .../js/charts/ui/options/SpectralLines.jsx | 6 +-- .../js/charts/ui/options/SpectrumOptions.jsx | 44 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index 1f72b0019d..ad946aa27f 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -18,7 +18,7 @@ import {TablePanel} from 'firefly/tables/ui/TablePanel'; import {FieldGroup} from 'firefly/ui/FieldGroup'; import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; import {MISSING_COLS_HEADER_MSG, UploadTableSelector} from 'firefly/ui/UploadTableSelector'; -import {getEffectiveSpectralFrameOption} from './SpectrumOptions.jsx'; +import {getEffectiveSpectralFrameOption, SF_OPTION, USER_SPECIFIED_REDSHIFT} from './SpectrumOptions.jsx'; const recLinesTblId = (listId) => `rec-${listId}`; @@ -115,8 +115,8 @@ function resolveSpectralLinesRedshift(fireflyData, activeTrace) { // falls back to the same default a fresh spectrum options panel would show, rather than assuming rest-frame const {value: sfOption, redshift: redshiftOption, userSpecified} = getEffectiveSpectralFrameOption(fireflyData?.[activeTrace]); - if (sfOption !== 'observed') return 0; - const redshift = redshiftOption === 'userSpecified' ? userSpecified : redshiftOption; + if (sfOption !== SF_OPTION.OBSERVED) return 0; + const redshift = redshiftOption === USER_SPECIFIED_REDSHIFT ? userSpecified : redshiftOption; return Number(redshift) || 0; } diff --git a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx index e1d12cdca9..15759c5f44 100644 --- a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx +++ b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx @@ -33,6 +33,10 @@ import {Box, FormLabel, Stack, Typography} from '@mui/joy'; import {CollapsibleGroup} from 'firefly/ui/panel/CollapsiblePanel'; import {MathJax} from 'better-react-mathjax'; +// spectralFrameOption.value: whether spectral lines or the spectrum itself gets the redshift correction +export const SF_OPTION = {OBSERVED: 'observed', REST: 'rest'}; +// a special non-numeric spectralFrameOption.redshift value: instructs the use of spectralFrameOption.userSpecified (dynamic user-input value) +export const USER_SPECIFIED_REDSHIFT = 'userSpecified'; export function SpectrumOptions ({activeTrace:pActiveTrace, tbl_id:ptbl_id, chartId, groupKey}) { @@ -148,10 +152,10 @@ const getRedshiftCorrectedExpr = ({cname, spectralFrame, sfOption, redshift=unde const multiplyBy = refPos?.toUpperCase?.() === REF_POS.CUSTOM ? ` * (1 + ${customRedshift ?? '0'})` : ''; - const divideBy = sfOption === 'rest' && redshift ? ` / (1 + ${redshift})` : ''; + const divideBy = sfOption === SF_OPTION.REST && redshift ? ` / (1 + ${redshift})` : ''; let expr = `${quoteNonAlphanumeric(cname)}${multiplyBy}${divideBy}`; // multiplyBy = divideBy when correcting a spectrum with custom redshift to the rest frame - if (sfOption === 'rest' && customRedshift === redshift) expr = quoteNonAlphanumeric(cname); + if (sfOption === SF_OPTION.REST && customRedshift === redshift) expr = quoteNonAlphanumeric(cname); return expr; }; @@ -175,18 +179,18 @@ const getRedshiftInfo = (inFields, path, fireflyData, activeTrace) => { .map((fieldKey) => get(inFields, path(fieldKey))); // resolve the redshift number regardless of frame — spectral lines need it in observed frame too, not just rest - const redshift = redshiftOption==='userSpecified' ? userSpecifiedRedshift : redshiftOption; + const redshift = redshiftOption===USER_SPECIFIED_REDSHIFT ? userSpecifiedRedshift : redshiftOption; let sfLabel = 'Observed Frame'; let redshiftLabel = ''; - if(sfOption==='rest') { + if(sfOption===SF_OPTION.REST) { sfLabel = 'Rest Frame'; - redshiftLabel = redshiftOption==='userSpecified' + redshiftLabel = redshiftOption===USER_SPECIFIED_REDSHIFT ? `Redshift = ${userSpecifiedRedshift}` : getRedshiftLabel(fireflyData, activeTrace, redshiftOption); } - else if(sfOption!=='observed') sfLabel = `${sfOption} Spectral Frame`; + else if(sfOption!==SF_OPTION.OBSERVED) sfLabel = `${sfOption} Spectral Frame`; return {sfOption, sfLabel, redshift, redshiftLabel}; }; @@ -353,9 +357,9 @@ export function getEffectiveSpectralFrameOption(trace={}) { const spectralFrameOption = trace.spectralFrameOption || {}; const refPos = spectralFrame.refPos?.toUpperCase?.(); return { - value: spectralFrameOption.value ?? (refPos === REF_POS.TOPOCENTER ? 'observed' : 'rest'), + value: spectralFrameOption.value ?? (refPos === REF_POS.TOPOCENTER ? SF_OPTION.OBSERVED : SF_OPTION.REST), // redshift defaults to getRedshiftOptions' first entry (because of radio button group) - redshift: spectralFrameOption.redshift ?? getRedshiftOptions(trace)[0]?.value ?? 'userSpecified', + redshift: spectralFrameOption.redshift ?? getRedshiftOptions(trace)[0]?.value ?? USER_SPECIFIED_REDSHIFT, userSpecified: spectralFrameOption.userSpecified ?? '0' }; } @@ -401,7 +405,7 @@ export const useSpectrumInputs = ({activeTrace:pActiveTrace, chartId, groupKey}) const allProps = {label: 'Spectral frame:', ...props}; const sfRefPos = fireflyData[activeTrace].spectralFrame.refPos.toUpperCase(); return isKnownRefPos(sfRefPos) //only show options when TOPOCENTER or CUSTOM - ? + ? : ; }, [activeTrace, fireflyData, groupKey]), }; @@ -435,7 +439,7 @@ function getRedshiftOptions({target, derivedRedshift, spectralFrame}={}){ //TODO options.push({ label: 'Enter Redshift: ', - value: 'userSpecified' + value: USER_SPECIFIED_REDSHIFT }); if (refPos === REF_POS.CUSTOM) { @@ -448,23 +452,23 @@ function getRedshiftOptions({target, derivedRedshift, spectralFrame}={}){ //TODO return options; } -function SpectralFrameOptions ({groupKey, activeTrace, refPos, fireflyData, ...props}) { - const {spectralFrameOption} = fireflyData[activeTrace]; - const spectralFrameOptions = [{label: 'Observed Frame', value: 'observed'}, {label: 'Rest Frame', value: 'rest'}]; - const redshiftOptions = getRedshiftOptions(fireflyData[activeTrace]); - const defaultSFOption = refPos===REF_POS.TOPOCENTER ? 'observed' : 'rest'; +function SpectralFrameOptions ({groupKey, activeTrace, fireflyData, ...props}) { + const trace = fireflyData[activeTrace]; + const {value, redshift, userSpecified} = getEffectiveSpectralFrameOption(trace); + const spectralFrameOptions = [{label: 'Observed Frame', value: SF_OPTION.OBSERVED}, {label: 'Rest Frame', value: SF_OPTION.REST}]; + const redshiftOptions = getRedshiftOptions(trace); const isRestFrame = useStoreConnector(()=> - getFieldVal(groupKey, SFOptionFieldKeys(activeTrace).value)==='rest'); + getFieldVal(groupKey, SFOptionFieldKeys(activeTrace).value)===SF_OPTION.REST); const isUserSpecifiedOption = useStoreConnector(()=> - getFieldVal(groupKey, SFOptionFieldKeys(activeTrace).redshift)==='userSpecified'); + getFieldVal(groupKey, SFOptionFieldKeys(activeTrace).redshift)===USER_SPECIFIED_REDSHIFT); return ( isFloat('Redshift', val)} readonly={!isUserSpecifiedOption} tooltip='Redshift value'/> From 0a7f553dbba376625d3836374ab4a4f0a07baadb Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 11 Sep 2026 15:39:39 -0700 Subject: [PATCH 18/21] Additional spectrum options and unit enhancements --- .../__tests__/SpectrumUnitConversion-test.js | 14 +++++++++++++- .../js/charts/dataTypes/SpectrumUnitConversion.js | 13 +++++++++++++ src/firefly/js/charts/ui/ChartSelectPanel.jsx | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/firefly/js/charts/__tests__/SpectrumUnitConversion-test.js b/src/firefly/js/charts/__tests__/SpectrumUnitConversion-test.js index 189059c2f8..9ab8b48352 100644 --- a/src/firefly/js/charts/__tests__/SpectrumUnitConversion-test.js +++ b/src/firefly/js/charts/__tests__/SpectrumUnitConversion-test.js @@ -39,6 +39,8 @@ describe('SpectrumUnitConversion', () => { expect(canUnitConv({from: 'erg/s/cm^2/Hz', to: 'W/m^2/Hz'})).toBe(true); expect(canUnitConv({from: 'erg/s/cm^2/Hz', to: 'Jy'})).toBe(true); expect(canUnitConv({from: 'erg.s**-1.cm**-2.Hz**-1', to: 'Jy'})).toBe(true); // multiplication expression, same as above + expect(canUnitConv({from: 'Jy', to: 'uJy'})).toBe(true); + expect(canUnitConv({from: 'uJy', to: 'erg/s/cm^2/Hz'})).toBe(true); // F expect(canUnitConv({from: 'erg/s/cm^2', to: 'W/m^2'})).toBe(true); @@ -125,6 +127,12 @@ describe('SpectrumUnitConversion', () => { expect( getUnitConvExpr({cname: 'SIGNAL', from: 'erg.s**-1.cm**-2.Hz**-1', to: 'Jy'}) ).toBe('"SIGNAL" * 1.0E+23'); // multiplication expression, same as above + expect( + getUnitConvExpr({cname: 'SIGNAL', from: 'Jy', to: 'uJy'}) + ).toBe('"SIGNAL" * 1.0E+6'); + expect( + getUnitConvExpr({cname: 'SIGNAL', from: 'uJy', to: 'erg/s/cm^2/Hz'}) + ).toBe('"SIGNAL" / 1.0E+29'); // F --- expect( @@ -211,7 +219,8 @@ describe('SpectrumUnitConversion', () => { expect(getUnitOptions(unit)).toEqual([ { value: 'W/m^2/Hz', label: '$\\mathrm{W/m^{2}/Hz}$' }, { value: 'erg/s/cm^2/Hz', label: '$\\mathrm{erg/s/cm^{2}/Hz}$' }, - { value: 'Jy', label: '$\\mathrm{Jy}$' } + { value: 'Jy', label: '$\\mathrm{Jy}$' }, + { value: 'uJy', label: '$\\mathrm{\\mu Jy}$' } ]); }); @@ -296,6 +305,8 @@ describe('SpectrumUnitConversion', () => { }); // F_NU in Jy expect(getYLabel('Jy', 'signal')).toBe('$F_{\\nu}\\ [\\mathrm{Jy}]$'); + // F_NU in uJy + expect(getYLabel('uJy', 'signal')).toBe('$F_{\\nu}\\ [\\mathrm{\\mu Jy}]$'); // F in CGS units expect(getYLabel('erg/s/cm^2', 'signal')).toBe('$\\nu \\cdot F_{\\nu}\\ [\\mathrm{erg/s/cm^{2}}]$'); }); @@ -308,6 +319,7 @@ describe('SpectrumUnitConversion', () => { expect(getMeasurementLabel('m')).toBe('$\\lambda$'); expect(getMeasurementLabel('erg/s/cm^2/Hz')).toBe('$F_{\\nu}$'); expect(getMeasurementLabel('Jy')).toBe('$F_{\\nu}$'); + expect(getMeasurementLabel('uJy')).toBe('$F_{\\nu}$'); expect(getMeasurementLabel('erg/s/cm^2/A')).toBe('$F_{\\lambda}$'); expect(getMeasurementLabel('erg/s/cm^2')).toBe('$\\nu \\cdot F_{\\nu}$'); }); diff --git a/src/firefly/js/charts/dataTypes/SpectrumUnitConversion.js b/src/firefly/js/charts/dataTypes/SpectrumUnitConversion.js index c899f064cc..42654a996c 100644 --- a/src/firefly/js/charts/dataTypes/SpectrumUnitConversion.js +++ b/src/firefly/js/charts/dataTypes/SpectrumUnitConversion.js @@ -284,16 +284,25 @@ const UnitXref = { 'W/m^2/Hz' : '%s', 'erg/s/cm^2/Hz': '%s * 1.0E+3', Jy : '%s * 1.0E+26', + uJy : '%s * 1.0E+32', }, 'erg/s/cm^2/Hz' : { 'W/m^2/Hz': '%s / 1.0E+3', 'erg/s/cm^2/Hz' : '%s', Jy : '%s * 1.0E+23', + uJy : '%s * 1.0E+29', }, Jy : { 'W/m^2/Hz' : '%s / 1.0E+26', //SI units 'erg/s/cm^2/Hz': '%s / 1.0E+23', //CGS units Jy : '%s', + uJy : '%s * 1.0E+6', + }, + uJy : { + 'W/m^2/Hz' : '%s / 1.0E+32', + 'erg/s/cm^2/Hz': '%s / 1.0E+29', + Jy : '%s / 1.0E+6', + uJy : '%s', }, // flux density in wavelength space ------------- 'erg/s/cm^2/A' : { @@ -407,6 +416,10 @@ const UnitMetadata = { Jy : { type: Measurement.F_NU.key, }, + uJy : { + type: Measurement.F_NU.key, + label: '\\mu Jy', + }, // flux density in wavelength space ------------- 'erg/s/cm^2/A' : { type: Measurement.F_LAMBDA.key, diff --git a/src/firefly/js/charts/ui/ChartSelectPanel.jsx b/src/firefly/js/charts/ui/ChartSelectPanel.jsx index f9f3c00639..1c275847da 100644 --- a/src/firefly/js/charts/ui/ChartSelectPanel.jsx +++ b/src/firefly/js/charts/ui/ChartSelectPanel.jsx @@ -135,7 +135,7 @@ export function ChartSelectPanel({tbl_id, chartId, chartAction, inputStyle={}, h {showActionOptions && !isGrouped && } {showActionOptions && !isGrouped && } - + From 96d1d1e1d1a8813eabaf4d7ec2884d2df2584815 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 11 Sep 2026 16:24:23 -0700 Subject: [PATCH 19/21] FIREFLY-2066: Replace placeholder line lists with actual ones --- config/app.config | 2 +- .../ipac/firefly/resources/hspot_lines.csv | 113 ++++++++++++++++++ .../ipac/firefly/resources/jwst_linelist.tbl | 42 ------- .../ipac/firefly/resources/luisa_linelist.csv | 27 ----- .../ipac/firefly/resources/pahfit_lines.csv | 45 +++++++ .../ipac/firefly/resources/spherex_lines.tbl | 53 ++++++++ .../server/query/SpectralLinesProcessor.java | 7 +- 7 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/hspot_lines.csv delete mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl delete mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv create mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/pahfit_lines.csv create mode 100644 src/firefly/java/edu/caltech/ipac/firefly/resources/spherex_lines.tbl diff --git a/config/app.config b/config/app.config index b1541f70b9..1f3cefbd75 100644 --- a/config/app.config +++ b/config/app.config @@ -39,7 +39,7 @@ ehcache.multicast.port = "7015" // Recommended spectral line lists in the Spectral Lines panel: a JSON array of {label, src} objects, in order. // Omit "src" to use one of Firefly's bundled lists - any other label with no src is dropped (logged as an error). // Set to "[]" to offer no spectral line lists at startup. -charts.spectrum.linelists = "[{\"label\": \"Luisa\"}, {\"label\": \"JWST\"}, {\"label\": \"JWST remote\", \"src\": \"https://gist.githubusercontent.com/jaladh-singhal/2b4230e2fc64586fbe7b51519d26ad3f/raw/21f503d13bc0e859d269d8acc782083f7fa84c7e/jwst_linelist.tbl\"}]" +charts.spectrum.linelists = "[{\"label\": \"SPHEREx line list\"}, {\"label\": \"Spitzer PAHFIT line list\"}, {\"label\": \"Herschel HSPOT line list\"}, {\"label\": \"JWST line list (remote)\", \"src\": \"https://gist.githubusercontent.com/jaladh-singhal/2b4230e2fc64586fbe7b51519d26ad3f/raw/21f503d13bc0e859d269d8acc782083f7fa84c7e/jwst_linelist.tbl\"}]" /* ------------------------ IRSA services --------------------------------- */ GatorHost = "https://irsa.ipac.caltech.edu" diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/hspot_lines.csv b/src/firefly/java/edu/caltech/ipac/firefly/resources/hspot_lines.csv new file mode 100644 index 0000000000..1b127cd499 --- /dev/null +++ b/src/firefly/java/edu/caltech/ipac/firefly/resources/hspot_lines.csv @@ -0,0 +1,113 @@ +wavelength,label,description +520.227,CO,5-4 +433.553,CO,6-5 +371.647,CO,7-6 +325.222,CO,8-7 +289.118,CO,9-8 +260.238,CO,10-9 +236.611,CO,11-10 +200.271,CO,13-12 +185.998,CO,14-13 +173.63,CO,15-14 +162.81,CO,16-15 +612.127,CS,10-9 +556.518,CS,11-10 +510.18,CS,12-11 +470.974,CS,13-12 +437.371,CS,14-13 +408.252,CS,15-14 +382.775,CS,16-15 +360.298,CS,17-16 +340.32,CS,18-17 +322.448,CS,19-18 +306.364,CS,20-19 +291.815,CS,21-20 +278.59,CS,22-21 +266.516,CS,23-22 +255.451,CS,24-23 +245.28,CS,25-24 +235.887,CS,26-25 +211.591,CS,29-28 +204.578,CS,30-29 +198.018,CS,31-30 +191.87,CS,32-31 +186.095,CS,33-32 +180.662,CS,34-33 +175.54,CS,35-34 +170.703,CS,36-35 +166.129,CS,37-36 +161.797,CS,38-37 +157.689,CS,39-38 +613.706,H20,624-717 +538.284,H20,110-101 +482.986,H20,532-441 +398.639,H20,211-202 +327.22,H20,422-331 +308.962,H20,524-431 +303.454,H20,202-111 +273.191,H20,312-303 +269.27,H20,111-000 +259.98,H20,312-221 +258.814,H20,634-541 +257.793,H20,321-312 +255.679,H20,744-651 +248.245,H20,422-413 +243.972,H20,220-211 +212.524,H20,523-514 +208.075,H20,726-633 +194.421,H20,633-542 +190.436,H20,643-716 +187.109,H20,413-404 +180.487,H20,221-212 +179.525,H20,212-101 +174.919,H20,432-505 +174.624,H20,303-212 +174.605,H20,533-606 +170.138,H20,633-624 +169.737,H20,735-642 +167.034,H20,624-615 +166.813,H20,734-725 +160.509,H20,532-523 +159.399,H20,634-707 +158.31,H20,331-404 +563.816,HCN,6-5 +483.295,HCN,7-6 +422.908,HCN,8-7 +375.944,HCN,9-8 +338.375,HCN,10-9 +307.639,HCN,11-10 +282.028,HCN,12-11 +260.359,HCN,13-12 +241.788,HCN,14-13 +199.192,HCN,17-16 +188.152,HCN,18-17 +178.275,HCN,19-18 +169.387,HCN,20-21 +161.347,HCN,21-20 +358.972,CH+,J=1-0 +644.383,CH3D,JK=21-11_E +478.96,HCl,J=1-0 +644.814,HDO,JKaKc=101-000 +335.471,HDO,JkaKc=111-000 +243.242,HF,J=1-0 +307.642,NH,3?-N=1-0 +637.483,NH2D,JKaKc=110-000 +523.652,NH3,JK=10-00 +181.051,o-H3O+,JK=11+-11- +318.373,OD,2pi1/2J=3/2-1/2 +215.58,OD,2pi3/2J=5/2-3/2 +163.123,OH,2pi1/2J=3/2-1/2 +304.449,p-H3O+,JK=10-00 +216.782,SH,2pi3/2J=5/2-3/2 +169.41,Halpha,H15alpha +204.41,Halpha,H16alpha +243.923,Halpha,H17alpha +288.223,Halpha,H18alpha +337.583,Halpha,H19alpha +392.277,Halpha,H20alpha +452.579,Halpha,H21alpha +518.761,Halpha,H22alpha +591.097,Halpha,H23alpha +609.13,CI,1-0 +370.412,CI,2-1 +157.74,CII,C+ diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl b/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl deleted file mode 100644 index 46a54d6e65..0000000000 --- a/src/firefly/java/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl +++ /dev/null @@ -1,42 +0,0 @@ -\ Curated subset (2.6-5.0 um): gas-phase transitions plus PAH bands. -\ Wavelengths are exact vacuum values from source; H I labels via Rydberg. -\ Column wavelength [micron]: rest-frame vacuum wavelength -\ Column label: species / ion (bracketed = forbidden transition) -\ Column description: transition identification; phase (gas/PAH) -\title='JWST-SpecTool derived line list (2.6-5.0 um)' -\source='Lai JWST-SpecTool line_list_gt3um.csv' -\wavelength_frame='rest-frame, vacuum' -\n_lines=29 -|wavelength| label| description| -| double| char| char| -| micron| | | -| null| null| null| - 3.0039 H2 v=1-0 O(4); gas - 3.0392 H I Pf epsilon (10-5); gas - 3.0984 O I 3P-3Do 1 - 1; gas - 3.2890 PAH 3.3um C-H aromatic; PAH - 3.2970 H I Pf delta (9-5); gas - 3.4000 PAH 3.4um aliphatic C-H; PAH - 3.4600 PAH C-H band; PAH - 3.5100 PAH C-H band; PAH - 3.6146 CH+ v=1-0 R(0); gas - 3.6876 CH+ v=1-0 P(1); gas - 3.7035 He I 3Po-3D 2 - 1; gas - 3.7406 H I Pf gamma (8-5); gas - 3.8461 H2 v=0-0 S(13); gas - 4.0523 H I Br alpha (5-4); gas - 4.0763 [Fe II] a6D-a4F 7/2 - 5/2; gas - 4.0820 [Fe II] a6D-a4F 5/2 - 3/2; gas - 4.1150 [Fe II] a6D-a4F 9/2 - 7/2; gas - 4.1811 H2 v=0-0 S(11); gas - 4.2954 He I 3S-3Po 1 - 0; gas - 4.3765 H I Hu (12-6); gas - 4.4098 H2 v=0-0 S(10); gas - 4.6077 [Fe II] a6D-a4F 5/2 - 5/2; gas - 4.6493 CO v=1-0 R(1); gas - 4.6538 H I Pf beta (7-5); gas - 4.6742 CO v=1-0 P(1); gas - 4.6946 H2 v=0-0 S(9); gas - 4.7326 CO v=2-1 P(1); gas - 4.8891 [Fe II] a6D-a4F 7/2 - 7/2; gas - 4.9908 CO v=1-0 P(32); gas diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv b/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv deleted file mode 100644 index 32d205c7b1..0000000000 --- a/src/firefly/java/edu/caltech/ipac/firefly/resources/luisa_linelist.csv +++ /dev/null @@ -1,27 +0,0 @@ -wavelength,label,description -2.6259,H I,Br beta (6-4); gas -2.8730,H I,Pf 11 (11-5); gas -2.9600,NH3,N-H stretch (nu2); ice -3.0392,H I,Pf epsilon (10-5); gas -3.0500,H2O,O-H stretch; ice -3.2890,PAH,3.3um C-H aromatic; PAH -3.4000,PAH,3.4um aliphatic C-H; PAH -3.4600,PAH,C-H band; PAH -3.4700,-CH2-/-CH3-,C-H stretch (aliphatic); ice -3.5300,CH3OH,C-H stretch; ice -3.7406,H I,Pf gamma (8-5); gas -3.9500,CH3OH/H2S,C-H / S-H stretch; ice -4.0523,H I,Br alpha (5-4); gas -4.1811,H2,v=0-0 S(11); gas -4.2700,CO2,C-O stretch (nu3); ice -4.2954,He I,3S-3Po 1 - 0; gas -4.3800,13CO2,13C-O stretch; ice -4.4098,H2,v=0-0 S(10); gas -4.5000,H2O,combination mode; ice -4.6200,XCN (OCN-),C=N stretch; ice -4.6538,H I,Pf beta (7-5); gas -4.6700,CO,12C-O stretch (solid); ice -4.6946,H2,v=0-0 S(9); gas -4.7200,Dust continuum,cloud-depth indicator; continuum -4.7800,13CO,13C-O stretch; ice -4.9100,OCS,C-S stretch; ice diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/pahfit_lines.csv b/src/firefly/java/edu/caltech/ipac/firefly/resources/pahfit_lines.csv new file mode 100644 index 0000000000..a46c5523ab --- /dev/null +++ b/src/firefly/java/edu/caltech/ipac/firefly/resources/pahfit_lines.csv @@ -0,0 +1,45 @@ +wavelength,label +5.5115,H2_S(7) +6.1088,H2_S(6) +6.9091,H2_S(5) +8.0258,H2_S(4) +9.6649,H2_S(3) +12.2785,H2_S(2) +17.0346,H2_S(1) +28.2207,H2_S(0) +6.985274,[ArII] +8.99138,[ArIII] +10.5105,[SIV] +12.813,[NeII] +14.3217,[NeV] +15.555,[NeIII] +18.713,[SIII] +25.91,[OIV] +25.989,[FeII] +33.480,[SIII] +34.8152,[SiII] +35.349,[FeII] +5.27,PAH_5.3 +5.7,PAH_5.7 +6.22,PAH_6.2 +6.69,PAH_6.7 +7.42,PAH_7.7a +7.6,PAH_7.7b +7.85,PAH_7.7c +8.33,PAH_8.3 +8.61,PAH_8.6 +10.68,PAH_10.7 +11.23,PAH_11.3a +11.33,PAH_11.3b +11.99,PAH_12 +12.62,PAH_12.6a +12.69,PAH_12.6b +13.48,PAH_13.48 +14.04,PAH_14.04 +14.19,PAH_14.19 +15.9,PAH_15.9 +16.45,PAH_17a +17.04,PAH_17b +17.37,PAH_17c +17.87,PAH_17d +33.1,PAH_33.1 diff --git a/src/firefly/java/edu/caltech/ipac/firefly/resources/spherex_lines.tbl b/src/firefly/java/edu/caltech/ipac/firefly/resources/spherex_lines.tbl new file mode 100644 index 0000000000..5c1ac14a94 --- /dev/null +++ b/src/firefly/java/edu/caltech/ipac/firefly/resources/spherex_lines.tbl @@ -0,0 +1,53 @@ +\ +|label |wavelength | +|char |double | +| |micron | +| | | + Lyman Break 0.0912 + Ly alpha 0.1216 + Balmer Break 0.3646 + [O II] 0.3727 + H Beta 0.4861 + [O III] 0.5007 + H alpha 0.6563 + Paschen ionize 0.8206 + Paschen delta 1.0052 + Paschen gamma 1.0941 + Paschen beta 1.2822 + Bracket ionize 1.4588 + 1.6 um bump 1.6 + Paschen alpha 1.8756 + Bracket delta 1.9451 + Bracket gamma 2.1661 + Pfund ionize 2.2794 + Bracket beta 2.6258 + NH3 2.96 + H2O 3.05 + H2 nu1-0 O(5) 3.235 + Humphreys ionize 3.2823 + PAH 3.3 um 3.29 + Pfund delta 3.297 + -CH2,-CH3 3.47 + H2 nu1-0 O(6) 3.5008 + CH3OH 3.53 + Pfund gamma 3.7405 + H2 nu1-0 O(7) 3.8074 + H2 nu0-0 S(13) 3.8472 + CH3OH 3.95 + H2S 3.95 + H2 nu0-0 S(12) 3.9969 + Bracket alpha 4.0522 + H2 nu0-0 S(11) 4.1815 + CO2 4.27 + 13CO2 4.38 + H2 nu0-0 S(10) 4.41 + H2O 4.5 + CO nu-1-0 4.6 + XCN 4.62 + Pfund beta 4.6537 + CO 4.67 + H2 nu0-0 S(9) 4.6947 + 13CO 4.78 + OCS 4.91 + H2 nu0-0 S(8) 5.0531 + Humphreys delta 5.1286 diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java index 4c3a2de41f..0d325ffa6f 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/SpectralLinesProcessor.java @@ -39,9 +39,10 @@ public class SpectralLinesProcessor extends EmbeddedDbProcessor { private static final Logger.LoggerImpl LOGGER = Logger.getLogger(); private static final Map BUNDLED_RESOURCES = Map.of( - "Luisa", "/edu/caltech/ipac/firefly/resources/luisa_linelist.csv", - "JWST", "/edu/caltech/ipac/firefly/resources/jwst_linelist.tbl" - ); + "SPHEREx line list", "/edu/caltech/ipac/firefly/resources/spherex_lines.tbl", + "Spitzer PAHFIT line list", "/edu/caltech/ipac/firefly/resources/pahfit_lines.csv", + "Herschel HSPOT line list", "/edu/caltech/ipac/firefly/resources/hspot_lines.csv" + ); private static final String WAVELENGTH_COL = "wavelength"; // must match SpectralLines.jsx's WAVELENGTH_COL public record LineListInfo(String listId, String listLabel, String src) {} From 3f540423931f3654730632d6f4f2bcc211735a01 Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 11 Sep 2026 16:58:24 -0700 Subject: [PATCH 20/21] FIREFLY-2066: Fix stale data issues in merged lines table --- src/firefly/js/charts/ui/options/SpectralLines.jsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index ad946aa27f..d20f730c5f 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,5 +1,5 @@ import React, {useEffect, useState} from 'react'; -import {isEqual} from 'lodash'; +import {cloneDeep, isEqual} from 'lodash'; import {Button, Divider, Stack, Typography} from '@mui/joy'; import {Insights} from '@mui/icons-material'; import {CheckboxGroupInputField} from 'firefly/ui/CheckboxGroupInputField'; @@ -288,7 +288,8 @@ async function buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, wavel // store what this table was built from in meta, so the panel can tell when the checked lists/upload have since diverged const tableMeta = {sourceOptions, uploadSignature: uploadSignature(uploadInfo, wavelengthCol, labelCol, descriptionCol)}; - const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns: LINES_TBL_COLUMNS, data}, tableMeta}; + const columns = cloneDeep(LINES_TBL_COLUMNS); // fresh column objects are needed to prevent enum val staleness + const table = {tbl_id: LINES_TBL_ID, title: 'Spectral Lines', tableData: {columns, data}, tableMeta}; if (data.length === 0) table.status = {code: 204, message: 'No lines to display yet'}; // to replace default "No Data Found" status table.selectInfo = SelectInfo.newInstance({selectAll: true, rowCount: data.length}).data; dispatchTableAddLocal(table, undefined, false); @@ -368,7 +369,7 @@ export function SpectralLinesPanel() { void fetchLineLists().then((lists) => { setLineLists(lists); // build only if it doesn't exist yet - once built, row selection is user-owned and must survive - // the dialog being closed/reopened; only the "Update Lines" button rebuilds after this point + // the dialog being closed/reopened; only the "Load Lines" button rebuilds after this point if (!getTblById(LINES_TBL_ID)) { void buildMergedLinesTable(sourceOptions, lists, uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol); } @@ -378,8 +379,9 @@ export function SpectralLinesPanel() { const {selectedCount, groupsCount, linesCount, loadedSourceOptions, loadedUploadSignature} = useStoreConnector(() => { const tbl = getTblById(LINES_TBL_ID); - const linesCount = tbl?.totalRows ?? 0; - const groupsCount = linesCount ? new Set(getColumnValues(tbl, GROUP_COL)).size : 0; + const fullTbl = tbl?.origTableModel ?? tbl; // origTableModel stores the unfiltered table after any filter is applied + const linesCount = fullTbl?.totalRows ?? 0; + const groupsCount = linesCount ? new Set(getColumnValues(fullTbl, GROUP_COL)).size : 0; const selectedCount = SelectInfo.newInstance(tbl?.selectInfo).getSelectedCount(); const loadedSourceOptions = tbl?.tableMeta?.sourceOptions ?? ''; const loadedUploadSignature = tbl?.tableMeta?.uploadSignature ?? ''; From 129765d653dfc2c6846e14dca8d7828c683c7bfc Mon Sep 17 00:00:00 2001 From: Jaladh Singhal Date: Fri, 11 Sep 2026 20:52:59 -0700 Subject: [PATCH 21/21] FIREFLY-2066: Fix empty checkbox group rendering with just label --- src/firefly/js/charts/ui/options/SpectralLines.jsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/firefly/js/charts/ui/options/SpectralLines.jsx b/src/firefly/js/charts/ui/options/SpectralLines.jsx index d20f730c5f..af73803da4 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -434,12 +434,13 @@ export function SpectralLinesPanel() { )} isOpen={true}> - - ({label: listLabel, value: listId}))}/> + {lineLists.length > 0 && + + ({label: listLabel, value: listId}))}/>} Upload your own line list: