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:
+
+
+
+
+
+
+ Load Lines
+ Clear All
- {/* 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}}/>
);