diff --git a/config/app.config b/config/app.config index 7c4a84d16e..f08d6a47d7 100644 --- a/config/app.config +++ b/config/app.config @@ -36,6 +36,11 @@ visualize.fits.Security= true // ehcahe replication port; suggest 4015-local, 5015-dev, 6015-test, 7015-ops, 7515-ops_int ehcache.multicast.port = "7015" +// Available line lists in the Spectral Lines panel: a JSON array of {id, label?, src?} objects, in order. "id" is +// required; "label" and "src" each independently fall back to a bundled entry ("spherex-v1", "pahfit", "hspot") of the +// same id when omitted ("label" falling back further to id itself). An entry with neither "src" nor a bundled +// match is dropped, logged as an error. Leave unset or blank to offer all bundled lists; set to "[]" to offer none. +charts.spectrum.linelists = "[{\"id\": \"spherex-v1\"}, {\"id\": \"pahfit\"}, {\"id\": \"hspot\", \"label\": \"Herschel HSPOT line list (custom label)\"}, {\"id\": \"jwst-remote\", \"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/config/common.prop b/config/common.prop index fbe44468e8..01d95ad909 100644 --- a/config/common.prop +++ b/config/common.prop @@ -64,4 +64,5 @@ tap.maxrec.maxval=@__$tap.maxrec.hardlimit@ visualize.fits.MaxSizeInBytes= @visualize.fits.MaxSizeInBytes@ +charts.spectrum.linelists=@charts.spectrum.linelists@ diff --git a/src/firefly/config/app.prop b/src/firefly/config/app.prop index 8888531480..649306143a 100644 --- a/src/firefly/config/app.prop +++ b/src/firefly/config/app.prop @@ -19,7 +19,6 @@ visualize.fits.Security= @visualize.fits.Security@ python.exe= @python.exe@ - # IRSA Periodogram API irsa.gator.service.periodogram.url=@irsa.gator.service.periodogram.url@ 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/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/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 c26220e211..81a8266487 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 @@ -3,34 +3,138 @@ */ package edu.caltech.ipac.firefly.server.query; +import edu.caltech.ipac.firefly.data.FileInfo; import edu.caltech.ipac.firefly.data.TableServerRequest; +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.StringUtils; +import edu.caltech.ipac.util.download.FailedRequestException; +import edu.caltech.ipac.util.download.URLDownload; +import edu.caltech.ipac.util.serialization.Serializer; 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 the recommended spectral line list (a fixed resource dataset) as a table. + * 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 + * {id, label?, src?} objects - id is always required; label and src each independently fall back to a + * BUNDLED_RESOURCES entry of the same id when omitted (label falling back further to id itself). An entry with + * neither a src nor a bundled match is dropped and logged as an error. The resolved src is fetched as a URL if it + * starts with http/https, otherwise as a classpath resource. */ @SearchProcessorImpl(id = "spectralLines") public class SpectralLinesProcessor extends EmbeddedDbProcessor { - private static final String RESOURCE = "/edu/caltech/ipac/firefly/resources/linelist_combined.csv"; + private static final Logger.LoggerImpl LOGGER = Logger.getLogger(); + + private static final List BUNDLED_RESOURCES = List.of( + new LineListInfo("spherex-v1", "SPHEREx line list", "/edu/caltech/ipac/firefly/resources/spherex_lines.tbl"), + new LineListInfo("pahfit", "Spitzer PAHFIT line list", "/edu/caltech/ipac/firefly/resources/pahfit_lines.csv"), + new LineListInfo("hspot", "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 + private static final String LINE_LISTS_PROP = "charts.spectrum.linelists"; + + public record LineListInfo(String listId, String listLabel, String src) {} + + public static final List LINE_LISTS = parseLineListsConfig(); + + private static LineListInfo findBundled(String id) { + return BUNDLED_RESOURCES.stream().filter(b -> b.listId().equals(id)).findFirst().orElse(null); + } + + private static List parseLineListsConfig() { + List lists = new ArrayList<>(); + try { + String lineListsJson = AppProperties.getProperty(LINE_LISTS_PROP); + if (StringUtils.isEmpty(lineListsJson)) { + // default to every bundled line list when not defined or blank (different from explicit "[]") + lists.addAll(BUNDLED_RESOURCES); + return lists; + } + var configEntries = Serializer.fromJson(lineListsJson, Map[].class); + for (Map configEntry : configEntries) { + String id = (String) configEntry.get("id"); + if (id == null) { + LOGGER.error("%s: entry missing required \"id\" - dropping from spectral lines list".formatted(LINE_LISTS_PROP)); + continue; + } + LineListInfo bundledEntry = findBundled(id); + String label = (String) configEntry.get("label"); + if (label == null) label = bundledEntry != null ? bundledEntry.listLabel() : id; + String src = (String) configEntry.get("src"); + if (src == null) src = bundledEntry != null ? bundledEntry.src() : null; + if (src == null) { + LOGGER.error("%s: entry \"%s\" has no bundled match and is missing \"src\" - dropping from spectral lines list".formatted(LINE_LISTS_PROP, id)); + continue; + } + lists.add(new LineListInfo(id, label, src)); + } + } catch (Exception e) { + LOGGER.error(e, "%s: failed to parse config - no spectral line lists will be available".formatted(LINE_LISTS_PROP)); + } + return lists; + } public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessException { - try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(RESOURCE)) { - if (is == null) throw new IOException("Resource not found: " + RESOURCE); + if (req.getBooleanParam("metaOnly")) return lineListsMetaDataGroup(); - // readAnyFormat needs a File; copy the classpath resource to a temp file first - String ext = RESOURCE.substring(RESOURCE.lastIndexOf('.')); - File tempFile = createTempFile(req, ext); - FileUtil.writeToFile(is, tempFile, null); + String listId = req.getParam("listId"); + LineListInfo info = LINE_LISTS.stream().filter(l -> l.listId().equals(listId)).findFirst() + .orElseThrow(() -> new DataAccessException("Unknown or missing spectral lines listId: %s".formatted(listId))); - return TableUtil.readAnyFormat(tempFile, 0, req); - } catch (IOException e) { - throw new DataAccessException("Unable to read spectral lines resource", e); + boolean isUrl = info.src().toLowerCase().startsWith("http"); + try { + File tempFile = createTempFile(req, isUrl ? null : info.src().substring(info.src().lastIndexOf('.'))); + if (isUrl) { + FileInfo fi = URLDownload.getDataToFile(new URI(info.src()).toURL(), tempFile); + if (!fi.isOK()) throw new DataAccessException(fi.getResponseCodeMsg()); + } else { + try (InputStream is = SpectralLinesProcessor.class.getResourceAsStream(info.src())) { + if (is == null) throw new IOException("Resource not found: %s".formatted(info.src())); + FileUtil.writeToFile(is, tempFile, null); + } + } + DataGroup dg = TableUtil.readAnyFormat(tempFile, 0, req); + DataType wlCol = dg.getDataDefintion(WAVELENGTH_COL); + if (wlCol == null) { + LOGGER.warn("Spectral line list \"%s\" from %s: \"%s\" column is missing - no lines will be loaded from this list." + .formatted(info.listLabel(), info.src(), WAVELENGTH_COL)); + } else if (StringUtils.isEmpty(wlCol.getUnits())) { + LOGGER.warn("Spectral line list \"%s\" from %s: \"%s\" column has no units metadata - client will assume microns." + .formatted(info.listLabel(), info.src(), WAVELENGTH_COL)); + } + return dg; + } catch (Exception e) { + String msg = "Unable to load spectral line list \"%s\" from %s".formatted(info.listLabel(), info.src()); + if (e instanceof FailedRequestException fre && fre.getResponseCode() > -1) { + msg = "%s (response code: %d)".formatted(msg, fre.getResponseCode()); + } + LOGGER.error(e, msg); + throw new DataAccessException("Unable to read spectral lines resource for %s".formatted(info.listLabel()), e); } } + + private static DataGroup lineListsMetaDataGroup() { + DataGroup dg = new DataGroup("Spectral Line Lists", new DataType[0]); + // src deliberately excluded - it's admin-configured and may be an internal/credentialed URL, not for client eyes + List> lineLists = LINE_LISTS.stream() + .map(info -> Map.of("listId", info.listId(), "listLabel", info.listLabel())) + .toList(); + String lineListsJson = Serializer.toJsonString(lineLists); + dg.getTableMeta().setAttribute("lineLists", lineListsJson); + return dg; + } } 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/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/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 && } - + 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 ( showSpectralLinesDialog()}/> + ); +} + export function AddBtn() { return ( , title: 'Filters', modal: true, show: true }); -} \ No newline at end of file +} + + +function showSpectralLinesDialog() { + 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..caa552a414 100644 --- a/src/firefly/js/charts/ui/options/SpectralLines.jsx +++ b/src/firefly/js/charts/ui/options/SpectralLines.jsx @@ -1,81 +1,86 @@ -import React, {useEffect} from 'react'; -import {get} from 'lodash'; -import {Stack} from '@mui/joy'; -import {SwitchInputField} from 'firefly/ui/SwitchInputField'; +import React, {useEffect, useState} from 'react'; +import {cloneDeep, isEqual} from 'lodash'; +import {Button, Divider, Stack, Typography} from '@mui/joy'; +import SettingsIco from '@mui/icons-material/SettingsOutlined'; 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 {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'; 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, dispatchTableAddLocal, TABLE_SELECT, TABLE_LOADED} from 'firefly/tables/TablesCntlr'; +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'; +import {dispatchComponentStateChange} from 'firefly/core/ComponentCntlr'; +import {MISSING_COLS_HEADER_MSG, UploadTableSelector} from 'firefly/ui/UploadTableSelector'; +import {getEffectiveSpectralFrameOption, SF_OPTION, USER_SPECIFIED_REDSHIFT} from './SpectrumOptions.jsx'; -const RECOMMENDED_LINES_TBL_ID = 'recommended-spectral-lines'; -const RECOMMENDED_LINES_TBL_UI_ID = `${RECOMMENDED_LINES_TBL_ID}-ui`; -const WAVELENGTH_COL = 'wavelength_um'; -const LABEL_COL = 'species'; -const TRANSITION_COL = 'transition'; -const PHASE_COL = 'phase'; -const WAVELENGTH_COL_UNIT = 'um'; // unit of WAVELENGTH_COL's values; TODO: source it from table metadata if present +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`; +const WAVELENGTH_COL = 'wavelength'; +const LABEL_COL = 'label'; +const DESCRIPTION_COL = 'description'; +const GROUP_COL = 'list'; +const WAVELENGTH_COL_UNIT = 'um'; // canonical unit of WAVELENGTH_COL's values +const LINES_TBL_COLUMNS = [ + {name: WAVELENGTH_COL, label: 'wavelength*', units: WAVELENGTH_COL_UNIT, type: 'double'}, + {name: LABEL_COL, type: 'char'}, + {name: DESCRIPTION_COL, type: 'char'}, + {name: GROUP_COL, type: 'char'}, +]; const SPECTRAL_LINE_COLOR = 'gray'; const SPECTRAL_LINE_FONT_FAMILY = "'SF Mono', ui-monospace, monospace"; export const SPECTRAL_LINES_GROUP = 'lines'; -// Field keys and option values --- -const ENABLED_KEY = 'spectralLines.enabled'; +const SPECTRAL_LINES_FG_KEY = 'spectralLinesPanel'; +const SOURCES_COLLAPSIBLE_KEY = 'spectralLinesSources'; // panel is shared app-wide, not chart-specific - one fixed key 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; -} +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' +}; -/** - * 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); 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, @@ -84,101 +89,418 @@ export function makeSpectralLineShapes(xUnit, linesTblId, redshift=0) { label: { text: label, textposition: 'end', + xanchor: 'right', yanchor: 'bottom', - font: {size: 9.5, color: SPECTRAL_LINE_COLOR, family: SPECTRAL_LINE_FONT_FAMILY}, - padding: 2, + font: { size: 9, color: SPECTRAL_LINE_COLOR, family: SPECTRAL_LINE_FONT_FAMILY }, + padding: 0, // not pixel-perfect across chrome/firefox/safari - each measures the line's own SVG geometry differently (inspect shape, not a font/text issue) }, - 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', })); } -/* 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; +/** + * 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|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; - const request = makeTblRequest( - 'spectralLines', 'Spectral Lines', {}, - {tbl_id: RECOMMENDED_LINES_TBL_ID} - ); + // 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 !== SF_OPTION.OBSERVED) return 0; + const redshift = redshiftOption === USER_SPECIFIED_REDSHIFT ? userSpecified : redshiftOption; + return Number(redshift) || 0; +} + +/** + * 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. When lines 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 xUnit = fireflyData[activeTrace]?.xUnit; + const redshift = resolveSpectralLinesRedshift(fireflyData, activeTrace); + + const otherShapes = (layout?.shapes ?? []).filter((s) => s.legendgroup !== SPECTRAL_LINES_GROUP); + const spectralLineShapes = redshift === undefined ? [] : makeSpectralLineShapes(xUnit, LINES_TBL_ID, redshift); + const changes = { + 'layout.shapes': [...otherShapes, ...spectralLineShapes], + 'layout.showlegend': data.length > 1 || spectralLineShapes.length > 0, + }; + + // 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 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 + */ +export function useSpectralLinesSync(chartId) { + useEffect(() => { + if (!isSpectrum(chartId)) return; + + resyncChartLines(chartId); // reflect current state immediately + + const resync = () => resyncChartLines(chartId); + const cancels = [ + // 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 + 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]); +} + +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}); dispatchTableFetch(request); // headless: doesn't render in results UI + await onTableLoaded(tbl_id); +} - // 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}); +/** + * 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. + * @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); + return makeLinesRows(getTblById(uploadInfo.tbl_id), wavelengthCol, labelCol, descriptionCol, uploadInfo.fileName); } -export function SpectralLinesOptions({activeTrace, chartId}) { +/** + * 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 recommendedLinesRows(sourceOptions, lineLists) { + const checked = splitVals(sourceOptions); + const checkedLists = lineLists.filter(({listId}) => checked.includes(listId)); + await Promise.all(checkedLists.map(({listId}) => ensureRecommendedList(listId))); + + return checkedLists.flatMap(({listId, listLabel}) => + makeLinesRows(getTblById(recLinesTblId(listId)), WAVELENGTH_COL, LABEL_COL, DESCRIPTION_COL, listLabel)); +} + +/** + * 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 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; + dispatchTableUiUpdate({tbl_ui_id: LINES_TBL_UI_ID, columnWidths: undefined}); // so that columnWidths get recomputed with current table data + dispatchTableAddLocal(table, undefined, false); +} + +const uploadColumnFields = () => [ + { + 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)'}, +]; + +// 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 + * FieldGroup so selections survive the dialog being closed/reopened. + */ +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 [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 [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([]); + 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}); + 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 "Load Lines" button rebuilds after this point + 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 {enabled: initialEnabled = false, source: initialTblId = RECOMMENDED_LINES_TBL_ID} = - useStoreConnector(() => get(getChartData(chartId), 'fireflyLayout.spectralLines')) ?? {}; - const initialSourceOptions = tblIdToSourceOption(initialTblId); + const {selectedCount, groupsCount, linesCount, loadedSourceOptions, loadedUploadSignature} = useStoreConnector(() => { + const tbl = getTblById(LINES_TBL_ID); + 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 ?? ''; + return {selectedCount, groupsCount, linesCount, loadedSourceOptions, loadedUploadSignature}; + }, []); - // 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]); + // 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; - // 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 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); - // TODO: combine different source tables to a client-side table - const sourceOptions = useFieldValueOnly(SOURCE_OPTIONS_KEY, initialSourceOptions); - const activeTblId = sourceOptionToTblId(sourceOptions); - const activeTblUiId = activeTblId && `${activeTblId}-ui`; + const onLoadLines = () => { + void buildMergedLinesTable(sourceOptions, lineLists, uploadInfo, uploadWavelengthCol, uploadLabelCol, uploadDescriptionCol); + dispatchComponentStateChange(SOURCES_COLLAPSIBLE_KEY, {isOpen: false}); // collapse to reveal the table below + }; - if (!hasSpectralFrame) return false; + // 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 ( - - - - - {isEnabled && ( - - - {/* TODO: "Upload mine" — file upload + column mapper */} - {activeTblId && ( - - - - + + + + + ( + + Select line lists to load + {!isOpen && + + {linesCount === 0 + ? 'No lines loaded' + : `${groupsCount} list${groupsCount === 1 ? '' : 's'}, ${linesCount} line${linesCount === 1 ? '' : 's'} loaded`} + } + + )} + isOpen={true}> + + {lineLists.length > 0 && + + ({label: listLabel, value: listId}))}/>} + + Upload your own line list: + + + + + + + + - {/* TODO: add "Add row" button with comma-delimited input to append custom lines */} - - - )} + {hasPendingChanges && + + changes above not yet loaded in table below + } + + + + + Select lines to plot: + {plotHelperText && + {plotHelperText}} + + undefined} + /> + + + * Rest-frame wavelengths - a spectral chart in 'Observed Frame' redshifts these lines when plotting; + see its Chart options () for the redshift applied. + + - )} - + + + + {selectedCount === 0 + ? '0 lines selected - nothing plotted on spectral chart(s) ↘' + : <> + {selectedCount} lines +  selected - plotted live on spectral chart(s) ↘ + } + + + + ); } diff --git a/src/firefly/js/charts/ui/options/SpectrumOptions.jsx b/src/firefly/js/charts/ui/options/SpectrumOptions.jsx index bb76eb1086..15759c5f44 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,8 +32,11 @@ 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'; +// 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}) { @@ -44,7 +47,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 +86,6 @@ export function SpectrumOptions ({activeTrace:pActiveTrace, tbl_id:ptbl_id, char {xMin && } - @@ -150,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; }; @@ -177,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}; }; @@ -282,7 +284,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, @@ -340,45 +342,24 @@ 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}); } -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', + 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 ?? USER_SPECIFIED_REDSHIFT, userSpecified: spectralFrameOption.userSpecified ?? '0' }; } @@ -424,11 +405,9 @@ 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]), - SpectralLines: useCallback((props={}) => - , [activeTrace, chartId]), }; }; @@ -437,8 +416,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) { @@ -460,7 +439,7 @@ function getRedshiftOptions({target, derivedRedshift, spectralFrame}){ //TODO: m options.push({ label: 'Enter Redshift: ', - value: 'userSpecified' + value: USER_SPECIFIED_REDSHIFT }); if (refPos === REF_POS.CUSTOM) { @@ -473,23 +452,23 @@ function getRedshiftOptions({target, derivedRedshift, spectralFrame}){ //TODO: m 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'/> 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; }; diff --git a/src/firefly/js/ui/UploadTableChooser.js b/src/firefly/js/ui/UploadTableChooser.js index 1bbbdb54fa..52de1de44e 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,20 +266,21 @@ 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 ( - + 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..e9a94a1269 100644 --- a/src/firefly/js/ui/UploadTableSelector.jsx +++ b/src/firefly/js/ui/UploadTableSelector.jsx @@ -1,5 +1,5 @@ import React, {useContext, useEffect, useState} from 'react'; -import {Box, Chip, Stack, Tooltip, Typography} from '@mui/joy'; +import {Box, Chip, FormHelperText, Stack, Tooltip, Typography} from '@mui/joy'; import PropTypes from 'prop-types'; import {defaultsDeep, omit} from 'lodash'; @@ -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 && @@ -173,12 +185,14 @@ export function UploadTableSelector({uploadInfo, setUploadInfo, columnFields=[], * @prop {string} fieldKey * @prop {string} name * @prop {function(columns):string} guessValue + * @prop {function(value, columns):Node} [getFeedback] - feedback text to render as help below ColumnFld * @prop {*} [additionalProps] - Any additional key that can be passed to ColumnFld as prop. */ const columnFieldsType = PropTypes.arrayOf(PropTypes.shape({ ...omit({...ColumnFld.propTypes}, ['cols']), //because cols come from uploadInfo - guessValue: PropTypes.func //(columns) => string + guessValue: PropTypes.func, //(columns) => string + getFeedback: PropTypes.func //(value, columns) => node })); UploadTableSelector.propTypes = { @@ -191,6 +205,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({ @@ -254,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} 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}}/> );