From 22e1c62c105586c173bf886c04ec8a7954f517cf Mon Sep 17 00:00:00 2001 From: roby Date: Mon, 19 Oct 2026 14:43:28 -0600 Subject: [PATCH 1/2] Firefly-1960: Hips Extraction - add cube (drill), line, and point extract - add cube extract - added PlotViewUtil.js and some ExtracxtionUIUtil.js test - fixed a bug in rotation checks --- .../ipac/firefly/data/ServerParams.java | 2 +- .../ipac/firefly/data/table/MetaConst.java | 6 + .../server/query/ExtractFromImage.java | 98 +- .../firefly/server/rpc/VisServerCommands.java | 32 +- .../firefly/server/servlets/HiPSRetrieve.java | 10 + .../firefly/server/visualize/CtxControl.java | 25 + .../firefly/server/visualize/FitsCacher.java | 4 +- .../server/visualize/VisServerOps.java | 51 +- .../firefly/visualize/DirectFileAccess.java | 2 +- .../ipac/table/io/FITSExtractToTable.java | 136 +- .../visualize/plot/plotdata/FitsExtract.java | 40 +- .../visualize/plot/plotdata/FitsRead.java | 5 +- .../visualize/plot/plotdata/FitsReadUtil.java | 17 + src/firefly/js/data/MetaConst.js | 6 + src/firefly/js/data/ServerParams.js | 2 +- .../js/metaConvert/DataProductsWatcher.js | 2 +- .../js/metaConvert/ImageDataProductsUtil.js | 2 +- src/firefly/js/rpc/PlotServicesJson.js | 57 +- src/firefly/js/visualize/HiPSMocUtil.js | 2 +- src/firefly/js/visualize/HiPSUtil.js | 22 +- src/firefly/js/visualize/PlotViewUtil.js | 113 +- .../visualize/__tests__/PlotViewUtil-test.js | 1382 ++++++++++++++++- .../js/visualize/reducer/HandlePlotChange.js | 14 +- .../js/visualize/saga/MouseReadoutWatch.js | 10 +- .../js/visualize/ui/VisMiniToolbar.jsx | 14 +- .../ui/extraction/ExtractionChart.jsx | 42 +- .../ui/extraction/ExtractionDialog.jsx | 231 ++- .../ui/extraction/ExtractionTable.jsx | 105 +- .../ui/extraction/ExtractionUIUtil.js | 140 +- .../ui/{ => extraction}/ExtractionWatchers.js | 61 +- .../__tests__/ExtractionUIUtil-test.js | 193 +++ 31 files changed, 2560 insertions(+), 266 deletions(-) rename src/firefly/js/visualize/ui/{ => extraction}/ExtractionWatchers.js (69%) create mode 100644 src/firefly/js/visualize/ui/extraction/__tests__/ExtractionUIUtil-test.js diff --git a/src/firefly/java/edu/caltech/ipac/firefly/data/ServerParams.java b/src/firefly/java/edu/caltech/ipac/firefly/data/ServerParams.java index 63d5d68e68..2b04ebf4f6 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/data/ServerParams.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/data/ServerParams.java @@ -34,7 +34,6 @@ public class ServerParams { public static final String PTARY = "ptAry"; public static final String WPT_ARY = "wptAry"; public static final String WL_ARY = "wlAry"; - public static final String FLUX_UNIT= "fluxUnit"; public static final String WL_UNIT= "wlUnit"; public static final String PLANE = "plane"; public static final String CRO_MULTI_ALL = "cropMultiAll"; @@ -100,6 +99,7 @@ public class ServerParams { public static final String ALL = "all"; public static final String CDS = "cds"; public static final String IS_HIPS_TILE = "isHipsTile"; + public static final String HIPS_TILE_URL_ARY = "hipsTileUrlAry"; public static final String HIPS_SOURCES = "hipsSources"; public static final String HIPS_LIST_SOURCE= "hipsListSource"; public static final String HIPS_LIST_SOURCE_NAME= "hipsListSourceName"; diff --git a/src/firefly/java/edu/caltech/ipac/firefly/data/table/MetaConst.java b/src/firefly/java/edu/caltech/ipac/firefly/data/table/MetaConst.java index c1c82b7a9a..881c249e5e 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/data/table/MetaConst.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/data/table/MetaConst.java @@ -88,6 +88,12 @@ public class MetaConst { */ public static final String FITS_FILE_PATH= "FitsFilePath"; + /** + * HiPS FITS tile url, the url of the HiPS FITS tile that is associated with this table. + * It is used in extraction from a HiPS. The HiPS root of the url will match the hipsUrlRoot of a HiPS WebPlot + */ + public static final String HIPS_TILE_URL= "HiPSTileUrl"; + /** * Fits Image HDU, The HDU number of a FITS file that is associated with this table */ diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/query/ExtractFromImage.java b/src/firefly/java/edu/caltech/ipac/firefly/server/query/ExtractFromImage.java index 2ee1c34189..24e3cdeb28 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/query/ExtractFromImage.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/query/ExtractFromImage.java @@ -2,7 +2,10 @@ import edu.caltech.ipac.firefly.data.ServerParams; import edu.caltech.ipac.firefly.data.TableServerRequest; +import edu.caltech.ipac.firefly.data.table.MetaConst; +import edu.caltech.ipac.firefly.server.ServerContext; import edu.caltech.ipac.firefly.server.SrvParam; +import edu.caltech.ipac.firefly.server.servlets.HiPSRetrieve; import edu.caltech.ipac.table.DataGroup; import edu.caltech.ipac.table.io.FITSExtractToTable; import edu.caltech.ipac.visualize.plot.ImagePt; @@ -10,15 +13,14 @@ import edu.caltech.ipac.visualize.plot.plotdata.FitsExtract; import nom.tam.fits.FitsException; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import static edu.caltech.ipac.firefly.core.Util.Try; - -; +import static edu.caltech.ipac.util.StringUtils.isEmpty; /** * Extract data from an image and make a table @@ -32,8 +34,9 @@ @ParamDoc(name="wptAry", desc="for point selection, added to the created table"), @ParamDoc(name="wlAry[i]", desc="an array of wavelength to add to the table, added to the created table"), @ParamDoc(name="wlUnit[i]", desc="wavelength units. if defined"), - @ParamDoc(name="fluxUnit[i]", desc="flux units. if defined"), @ParamDoc(name="filename[i]", desc="filename on the server"), + @ParamDoc(name="hipsTileUrl", desc="z-axis only, url of a HiPS FITS cube tile, used instead of filename"), + @ParamDoc(name=ServerParams.HIPS_TILE_URL_ARY, desc="points or line, url of the HiPS FITS tile of each point, used with ptAry, wptAry and plane instead of the indexed parameters"), @ParamDoc(name="refHDUNum[i]", desc="hdu number to extract"), @ParamDoc(name="extractionSizeX", desc="number of the x size of the extract"), @ParamDoc(name="extractionSizeY", desc="number of the y size of the extract"), @@ -46,6 +49,7 @@ public class ExtractFromImage extends EmbeddedDbProcessor { public static final String ID = "ExtractFromImage"; public static final String EXTRACTION_TYPE = "extractionType"; public static final String FILENAME = "filename"; + public static final String HIPS_TILE_URL = "hipsTileUrl"; public static final String REF_HDU_NUM = "refHDUNum"; public static final String EXTRACTION_SIZE_X = "extractionSizeX"; public static final String EXTRACTION_SIZE_Y = "extractionSizeY"; @@ -54,30 +58,48 @@ public class ExtractFromImage extends EmbeddedDbProcessor { @Override public DataGroup fetchDataGroup(TableServerRequest req) throws DataAccessException { - String extType = req.getParam(EXTRACTION_TYPE); + String extType = req.getParam(EXTRACTION_TYPE, ""); int ptSizeX = req.getIntParam(EXTRACTION_SIZE_X, 1); int ptSizeY = req.getIntParam(EXTRACTION_SIZE_Y, 1); FitsExtract.CombineType ct= Enum.valueOf(FitsExtract.CombineType.class,req.getParam(ServerParams.COMBINE_OP,"AVG")); FitsExtract.SecondaryHduCombine secondCombine= Enum.valueOf(FitsExtract.SecondaryHduCombine.class,req.getParam("secondaryCombine","AUTO")); boolean allMatchingHDUs = req.getBooleanParam(ALL_MATCHING_HDUS, true); + var isLine= extType.equals("line"); + var ptOrLine= extType.equals("points") || isLine; + var hipsTiles= req.getParam(ServerParams.HIPS_TILE_URL_ARY)!=null; + WorldPt[] wptAry = ptOrLine ? SrvParam.getWorldPtAryFromJson(req.getParam(ServerParams.WPT_ARY)) : null; try { - if (extType == null || extType.equals("z-axis")) { - String filename = req.getParam(FILENAME); - int refHduNum = req.getIntParam(REF_HDU_NUM, -1); + if (extType.equals("z-axis") || isEmpty(extType)) { + String hipsTileUrl = req.getParam(HIPS_TILE_URL); + File fitsFile = hipsTileUrl!=null + ? HiPSRetrieve.getHiPSFitsTileFile(hipsTileUrl) + : ServerContext.convertToFile(req.getParam(FILENAME)); + int refHduNum = hipsTileUrl!=null ? FitsExtract.FIRST_IMAGE_HDU : req.getIntParam(REF_HDU_NUM, FitsExtract.NOT_SPECIFIED); ImagePt pt = ImagePt.parse(req.getParam(ServerParams.PT)); WorldPt wpt = WorldPt.parse(req.getParam(ServerParams.WPT)); String wlUnit= req.getParam(ServerParams.WL_UNIT); double[] wlAry= SrvParam.getDoubleAryFromJson(req.getParam(ServerParams.WL_ARY)); - checkZAxisParams(pt, filename, refHduNum); - Map fluxUnit= makeMapOfUnitsFromParam(req); - return FITSExtractToTable.getCubeZaxisAsTable(pt, wpt, filename, refHduNum, allMatchingHDUs, - ptSizeX, ct, secondCombine, wlAry,wlUnit,fluxUnit); + checkZAxisParams(pt, fitsFile, refHduNum); + DataGroup dg= FITSExtractToTable.getCubeZaxisAsTable(pt, wpt, fitsFile, refHduNum, allMatchingHDUs, + ptSizeX, ct, secondCombine, wlAry,wlUnit); + if (hipsTileUrl!=null) dg.getTableMeta().addKeyword(MetaConst.HIPS_TILE_URL, hipsTileUrl); + return dg; + } + else if (ptOrLine && hipsTiles) { + String[] tileUrlAry= SrvParam.getStringAryFromJson(req.getParam(ServerParams.HIPS_TILE_URL_ARY)); + return FITSExtractToTable.getHiPSPointsAsTable( + tileUrlAry, + getHiPSTileFiles(tileUrlAry), + SrvParam.getImagePtAryFromJson(req.getParam(ServerParams.PTARY)), + wptAry, + FitsExtract.FIRST_IMAGE_HDU, + req.getIntParam(ServerParams.PLANE, 0), + allMatchingHDUs, ptSizeX, ptSizeY, ct, secondCombine, isLine); } - else if (extType.equals("line") || extType.equals("points")) { + else if (ptOrLine) { var extractParamsList = getDataExtractParams(req); - WorldPt[] wptAry = SrvParam.getWorldPtAryFromJson(req.getParam(ServerParams.WPT_ARY)); - return FITSExtractToTable.getDataSelectAsTable(extractParamsList, wptAry, allMatchingHDUs, - ptSizeX, ptSizeY, ct, secondCombine, extType.equals("line")); + return FITSExtractToTable.getDataSelectAsTable(extractParamsList, wptAry, + allMatchingHDUs, ptSizeX, ptSizeY, ct, secondCombine, isLine); } } catch (IOException | FitsException e) { throw new IllegalArgumentException("Could not make a table from extracted data"); @@ -85,13 +107,28 @@ else if (extType.equals("line") || extType.equals("points")) { throw new IllegalArgumentException("extractionType must be z-axis, line, or points"); } + /** + * retrieve the HiPS FITS tiles, each url is retrieved once + * @param tileUrlAry the url of the tile for each point + * @return the file for each url, null for a tile that could not be retrieved + */ + private static File[] getHiPSTileFiles(String[] tileUrlAry) { + if (tileUrlAry==null) throw new IllegalArgumentException(ServerParams.HIPS_TILE_URL_ARY+" is required"); + Map fileByUrl= new HashMap<>(); + File[] fileAry= new File[tileUrlAry.length]; + for(int i=0; i getDataExtractParams(TableServerRequest req) { var eList = new ArrayList(); String[] titles= SrvParam.getStringAryFromJson( req.getParam("titleAry")); if (titles==null) titles= new String[] {""}; for (int i=0; i getDataExtractParams(T return eList; } - private static Map makeMapOfUnitsFromParam(TableServerRequest req) { - String[] sAry= SrvParam.getStringAryFromJson(req.getParam(ServerParams.FLUX_UNIT)); - Map fluxUnit= new HashMap<>(); - if (sAry==null) return fluxUnit; - for(String entry : sAry) { - String[] s= entry.split("="); - if (s.length==2) { - Try.it(() -> fluxUnit.put(Integer.parseInt(s[0]),s[1])); - } - } - return fluxUnit; - } - - private static void checkZAxisParams(ImagePt pt, String filename, int refHduNum) { + private static void checkZAxisParams(ImagePt pt, File fitsFile, int refHduNum) { if (pt==null) throw new IllegalArgumentException("Point is require for z-axis extraction"); - if (filename==null) throw new IllegalArgumentException("filename is require for z-axis extraction"); - if (refHduNum<0) throw new IllegalArgumentException("refHduNum is require for z-axis extraction"); + if (fitsFile==null) throw new IllegalArgumentException("filename or a retrievable hipsTileUrl is require for z-axis extraction"); + if (refHduNum==FitsExtract.NOT_SPECIFIED || (refHduNum<0 && refHduNum!=FitsExtract.FIRST_IMAGE_HDU)) { + throw new IllegalArgumentException("refHduNum is require for z-axis extraction"); + } } private static void checkPointParams(ImagePt[] ptAry, int plane, String filename, int refHduNum) { if (ptAry==null || ptAry.length==0) throw new IllegalArgumentException("ptAry is require for points extraction"); if (filename==null) throw new IllegalArgumentException("filename is require for points extraction"); - if (refHduNum<0) throw new IllegalArgumentException("refHduNum is require for points extraction"); - if (plane<0) throw new IllegalArgumentException("refHduNum is require for points extraction"); + if (refHduNum==FitsExtract.NOT_SPECIFIED || refHduNum<0) { + throw new IllegalArgumentException("refHduNum is require for points extraction"); + } + if (plane<0) throw new IllegalArgumentException("plane is require for points extraction"); } } \ No newline at end of file diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/rpc/VisServerCommands.java b/src/firefly/java/edu/caltech/ipac/firefly/server/rpc/VisServerCommands.java index 39b858cf33..624573fdcc 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/rpc/VisServerCommands.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/rpc/VisServerCommands.java @@ -8,7 +8,9 @@ import edu.caltech.ipac.firefly.server.ServCommand; import edu.caltech.ipac.firefly.server.ServerCommandAccess; import edu.caltech.ipac.firefly.server.SrvParam; +import edu.caltech.ipac.firefly.server.servlets.HiPSRetrieve; import edu.caltech.ipac.firefly.server.util.Logger; +import edu.caltech.ipac.firefly.server.visualize.CtxControl; import edu.caltech.ipac.firefly.server.visualize.DirectStretchUtils.CompressType; import edu.caltech.ipac.firefly.server.visualize.FluxValueUtil; import edu.caltech.ipac.firefly.server.visualize.VisJsonSerializer; @@ -26,6 +28,7 @@ import org.json.simple.JSONArray; import org.json.simple.JSONObject; +import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -201,30 +204,36 @@ private static CompressType getCompressType(SrvParam sp) { public static class ExtractionCmd extends ServerCommandAccess.HttpCommand { public void processRequest(HttpServletRequest req, HttpServletResponse res, SrvParam sp) throws Exception { - PlotState state= sp.getState(); + boolean isHiPSTile= sp.getOptionalBoolean(ServerParams.IS_HIPS_TILE, false); String exType= sp.getRequired(ServerParams.EXTRACTION_TYPE); boolean useFloat = sp.getOptionalInt(ServerParams.EXTRACTION_FLOAT_SIZE,64)==32; int ptSize= sp.getOptionalInt(ServerParams.POINT_SIZE,1); int ptSizeX= sp.getOptionalInt(ServerParams.POINT_SIZE_X,1); int ptSizeY= sp.getOptionalInt(ServerParams.POINT_SIZE_Y,1); - int hduNum= sp.getRequiredInt(ServerParams.HDU_NUM); + int hduNum= isHiPSTile ? FitsExtract.FIRST_IMAGE_HDU : sp.getRequiredInt(ServerParams.HDU_NUM); FitsExtract.CombineType ct= Enum.valueOf(FitsExtract.CombineType.class,sp.getOptional(ServerParams.COMBINE_OP,"AVG")); List extractList= switch (exType) { case "z-axis" -> - VisServerOps.getZAxisAry(state, + VisServerOps.getZAxisAry(getFitsFile(sp,isHiPSTile), sp.getRequiredImagePt(ServerParams.PT), hduNum, ptSize, ct); case "line" -> - VisServerOps.getLineDataAry(state, + VisServerOps.getLineDataAry(getFitsFile(sp,isHiPSTile), sp.getRequiredImagePt(ServerParams.PT), sp.getRequiredImagePt(ServerParams.PT2), sp.getRequiredInt(ServerParams.PLANE), hduNum, ptSize, ct); - case "points" -> - VisServerOps.getPointDataAry(state, + case "points" -> isHiPSTile + ? VisServerOps.getHiPSPointDataAry( + SrvParam.getStringAryFromJson(sp.getRequired(ServerParams.HIPS_TILE_URL_ARY)), + sp.getRequiredImagePtAry(ServerParams.PTARY), + hduNum, + sp.getRequiredInt(ServerParams.PLANE), + ptSizeX, ptSizeY, ct) + : VisServerOps.getPointDataAry(getFitsFile(sp,isHiPSTile), sp.getRequiredImagePtAry(ServerParams.PTARY), sp.getRequiredInt(ServerParams.PLANE), hduNum, ptSizeX, ptSizeY, ct); @@ -253,6 +262,17 @@ public void processRequest(HttpServletRequest req, HttpServletResponse res, SrvP } } + /** + * @param sp the parameters + * @param isHiPSTile if true use the URL parameter of a HiPS FITS tile, otherwise the plot state + * @return the FITS file to extract from or null if it is not available + */ + private static File getFitsFile(SrvParam sp, boolean isHiPSTile) { + return isHiPSTile + ? HiPSRetrieve.getHiPSFitsTileFile(sp.getRequired(ServerParams.URL)) + : CtxControl.getConfirmedWorkingFitsFile(sp.getState()); + } + public static class Crop extends ServCommand { public String doCommand(SrvParam sp) throws IllegalArgumentException { diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/HiPSRetrieve.java b/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/HiPSRetrieve.java index bff8cae339..b1b022070d 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/HiPSRetrieve.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/HiPSRetrieve.java @@ -61,7 +61,17 @@ private record DeadEntry(long time, int status) {}; public static FileInfo retrieveHiPSData(String urlStr, boolean alwaysUseCached) { return retrieveHiPSData(urlStr,null, alwaysUseCached); + } + /** + * Get a HiPS FITS tile, retrieving it if it is not in the cache. This call will block on the retrieve. + * @param fitsTileUrl the url of the HiPS FITS tile + * @return the cached FITS file or null if it could not be retrieved + */ + public static File getHiPSFitsTileFile(String fitsTileUrl) { + if (fitsTileUrl==null || isDeadFitsUrl(fitsTileUrl)) return null; + FileInfo fi= retrieveHiPSData(fitsTileUrl, true); + return fi.isSuccess() ? fi.getFile() : null; } public static FileInfo retrieveHiPSData(String urlStr, String fileName, boolean alwaysUseCached) { diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/CtxControl.java b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/CtxControl.java index c05d5171ee..a4c5b2d461 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/CtxControl.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/CtxControl.java @@ -32,6 +32,31 @@ public static void confirmFiles(PlotState state) throws FailedRequestException { if (isFitsFilesMissing(state)) revalidatePlot(state,true); //most of the time the files are there so nothing happens } + /** + * Get the NO_BAND working FITS file of a standard (non-HiPS) plot + * @param state the plot state + * @return the FITS file or null if it could not be confirmed + */ + public static File getConfirmedWorkingFitsFile(PlotState state) { + return getConfirmedWorkingFitsFile(state,Band.NO_BAND); + } + + /** + * Get the working FITS file of a standard (non-HiPS) plot + * @param state the plot state + * @param band the band of the file + * @return the FITS file or null if it could not be confirmed + */ + public static File getConfirmedWorkingFitsFile(PlotState state, Band band) { + try { + confirmFiles(state); + return ServerContext.convertToFile(state.getWorkingFitsFileStr(band)); + } catch (FailedRequestException e) { + Logger.getLogger().warn(e, "could not confirm working FITS file: " + e.getMessage()); + return null; + } + } + private static boolean isFitsFilesMissing(PlotState state) { return state.isThreeColor() ? Arrays.stream(state.getBands()) diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/FitsCacher.java b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/FitsCacher.java index e82dfe3f10..50b7ce8716 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/FitsCacher.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/FitsCacher.java @@ -249,7 +249,9 @@ private static DirectFileAccess makeDirectFileAccessData(FitsRead fr) { var blankVal= FitsReadUtil.getBlankValue(h); var blankValStr= Double.isNaN(blankVal) ? "" : blankVal+""; return new DirectFileAccess( - fr.getHduNumber(), fr.isCube(), FitsReadUtil.getNaxis3(h), -1, dataOffset, h.getIntValue(SPOT_BP), + FitsReadUtil.getExtNameOrType(h), + fr.getHduNumber(), fr.isCube(), FitsReadUtil.getNaxis3(h), FitsReadUtil.getNaxis(h), + dataOffset, h.getIntValue(SPOT_BP), FitsReadUtil.getNaxis1(h), FitsReadUtil.getNaxis2(h), FitsReadUtil.getNaxis3(h), FitsReadUtil.getCdelt2(h), FitsReadUtil.getBUnit(h,"---"), FitsReadUtil.getBscale(h), FitsReadUtil.getBzero(h), diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/VisServerOps.java b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/VisServerOps.java index 8ccd340569..65d46f3a5a 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/VisServerOps.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/visualize/VisServerOps.java @@ -6,6 +6,7 @@ import edu.caltech.ipac.firefly.data.BandInfo; import edu.caltech.ipac.firefly.server.Counters; import edu.caltech.ipac.firefly.server.ServerContext; +import edu.caltech.ipac.firefly.server.servlets.HiPSRetrieve; import edu.caltech.ipac.firefly.server.util.Logger; import edu.caltech.ipac.firefly.server.util.multipart.UploadFileInfo; import edu.caltech.ipac.firefly.server.visualize.DirectStretchUtils.CompressType; @@ -47,7 +48,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -133,35 +136,63 @@ public static WebPlotResult createPlot(WebPlotRequest request) { private interface Extractor { List getData(File fitsFile) throws Exception; } - private static List getDataAry(String desc, PlotState state, Extractor extractor) { + private static List getDataAry(String desc, File fitsFile, Extractor extractor) { + if (fitsFile==null) return Collections.emptyList(); try { - CtxControl.confirmFiles(state); PlotServUtils.statsLog(desc); - File fitsFile= ServerContext.convertToFile(state.getWorkingFitsFileStr(NO_BAND)); return extractor.getData(fitsFile); } catch (Exception e) { return Collections.emptyList(); } - } - public static List getZAxisAry(PlotState state, ImagePt pt, int hduNum, + + public static List getZAxisAry(File fitsFile, ImagePt pt, int hduNum, int ptSize, FitsExtract.CombineType ct) { - return getDataAry("z-axis drilldown", state, + return getDataAry("z-axis drilldown", fitsFile, (f) -> FitsExtract.getZAxisAryFromCube(pt, f, hduNum, ptSize, ct)); } - public static List getLineDataAry(PlotState state, ImagePt pt, ImagePt pt2, int plane, int hduNum, + public static List getLineDataAry(File fitsFile, ImagePt pt, ImagePt pt2, int plane, int hduNum, int drillSize, FitsExtract.CombineType ct) { - return getDataAry("point data", state, + return getDataAry("line data", fitsFile, (f) -> FitsExtract.getLineDataAryFromFile(pt, pt2, plane, f, hduNum, drillSize, ct)); } - public static List getPointDataAry(PlotState state, ImagePt[] ptAry, int plane, int hduNum, + public static List getPointDataAry(File fitsFile, ImagePt[] ptAry, int plane, int hduNum, int ptSizeX, int ptSizeY, FitsExtract.CombineType ct) { - return getDataAry("line data", state, + return getDataAry("point data", fitsFile, (f) -> FitsExtract.getPointDataAryFromFile(ptAry, plane, f, hduNum, ptSizeX, ptSizeY, ct)); } + /** + * Get the values at points from HiPS FITS tiles, each point can be in a different tile. + * Each tile is retrieved once, and the values are returned in the same order as the points. + * Each tile is read on its own, so an aperture near a tile edge is clipped to the pixels inside that tile. + * @param tileUrlAry the url of the HiPS FITS tile for each point, same length as ptAry + * @param ptAry the pixel in the tile for each point + * @param hduNum the HDU of the tile to extract from, FitsExtract.FIRST_IMAGE_HDU for the first image HDU + * @param plane the cube plane, ignored if the tile is not a cube + * @param ptSizeX aperture width + * @param ptSizeY aperture height + * @param ct how to combine the values in the aperture + * @return a value for each point, NaN for a point whose tile could not be read + */ + public static List getHiPSPointDataAry(String[] tileUrlAry, ImagePt[] ptAry, int hduNum, int plane, + int ptSizeX, int ptSizeY, FitsExtract.CombineType ct) { + if (tileUrlAry==null || ptAry==null || tileUrlAry.length!=ptAry.length) return Collections.emptyList(); + Number[] retAry= new Number[ptAry.length]; + Arrays.fill(retAry, Double.NaN); + Map> ptIdxByUrl= new LinkedHashMap<>(); + for(int i=0; i new ArrayList<>()).add(i); + ptIdxByUrl.forEach( (url,idxList) -> { + ImagePt[] tilePtAry= idxList.stream().map( (i) -> ptAry[i]).toArray(ImagePt[]::new); + List values= getPointDataAry(HiPSRetrieve.getHiPSFitsTileFile(url), tilePtAry, plane, hduNum, + ptSizeX, ptSizeY, ct); + for(int j=0; j cache= CacheManager.getSessionCache(); diff --git a/src/firefly/java/edu/caltech/ipac/firefly/visualize/DirectFileAccess.java b/src/firefly/java/edu/caltech/ipac/firefly/visualize/DirectFileAccess.java index b8a8b9d35e..7d133a6d82 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/visualize/DirectFileAccess.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/visualize/DirectFileAccess.java @@ -7,7 +7,7 @@ * */ public record DirectFileAccess ( - int hduNumber, boolean cube, int cubeLength, int planeNumber, + String nameOrType, int hduNumber, boolean cube, int cubeLength, int naxis, long dataOffset, int bitpix, int naxis1, int naxis2, int naxis3, double cdelt2, String bunit, double bscale, double bzero, String blankValue, String origin, PalomarDirectMod palomar ) implements Serializable { diff --git a/src/firefly/java/edu/caltech/ipac/table/io/FITSExtractToTable.java b/src/firefly/java/edu/caltech/ipac/table/io/FITSExtractToTable.java index f9e1d98da0..ff55d5e1a8 100644 --- a/src/firefly/java/edu/caltech/ipac/table/io/FITSExtractToTable.java +++ b/src/firefly/java/edu/caltech/ipac/table/io/FITSExtractToTable.java @@ -22,6 +22,7 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -116,13 +117,12 @@ private static void insertZaxisGenericChartMeta(DataGroup dataGroup, meta.addKeyword(MetaConst.DEFAULT_CHART_Y_COL, defYCol); } - public static DataGroup getCubeZaxisAsTable(ImagePt pt, WorldPt wpt, String filename, int refHduNum, + public static DataGroup getCubeZaxisAsTable(ImagePt pt, WorldPt wpt, File f, int refHduNum, boolean allMatchingHDUs, int ptSize, FitsExtract.CombineType ct, FitsExtract.SecondaryHduCombine secondaryCombine, - double[] wlAry, String wlUnit, Map fluxUnit) + double[] wlAry, String wlUnit) throws IOException, FitsException { - File f= ServerContext.convertToFile(filename); List results= FitsExtract.getAllZAxisAryFromRelatedCubes( pt, f, refHduNum, allMatchingHDUs, ptSize, ct, secondaryCombine); ArrayList dataTypes = new ArrayList<>(); @@ -137,9 +137,8 @@ public static DataGroup getCubeZaxisAsTable(ImagePt pt, WorldPt wpt, String file String desc= result.extName()!=null ? result.extName() : "HDU# "+result.hduNum(); desc= appendCombineType(desc,ptSize,ptSize,result.ct()); String key= makeKeyByHDU(result); - String u= fluxUnit.get(result.hduNum()); Class dataType= getDataType(result.aryData()); - DataType dt = new DataType(key,dataType, desc, u, null, null); + DataType dt = new DataType(key,dataType, desc, result.fluxUnits(), null, null); if (result.refHDU()) refKey= key; dataTypes.add(dt); @@ -295,6 +294,133 @@ public static DataGroup getDataSelectAsTable(List extractPara } + /** + * Make a table of the values at points or along a line from HiPS FITS tiles, each point can be in a different tile. + * Each tile is read once, and on its own, so an aperture near a tile edge is clipped to the pixels inside that tile. + * Like a FITS image, there is a column for every HDU in the tile that matches the + * reference HDU, the column data type matches the HDU data. A point whose tile can not be read, or that is a blank + * pixel, has a NaN value, or no value if the HDU data is an integer type. + * @param tileUrlAry the url of the FITS tile for each point, the file name is shown in the Tile column + * @param tileFileAry the FITS tile file for each point, null if the tile could not be retrieved + * @param tilePtAry the pixel in the tile for each point + * @param wptAry the world point of each point + * @param refHduNum the HDU of the tile to extract from, FitsExtract.FIRST_IMAGE_HDU for the first image HDU + * @param plane the cube plane, ignored if the tile is not a cube + * @param allMatchingHDUs if true, extract from every HDU that matches the reference HDU, otherwise just the reference HDU + * @param ptSizeX aperture width + * @param ptSizeY aperture height + * @param ct how to combine the values in the aperture + * @param secondaryCombine how to combine the values in the aperture for the HDUs that are not the reference HDU + * @param isLine if true the points are along a line, an offset column is added and used for the chart + * @return the table with the columns offset (line only), ra, dec, Tile, Tile X, Tile Y, and a column for each HDU + */ + public static DataGroup getHiPSPointsAsTable(String[] tileUrlAry, File[] tileFileAry, ImagePt[] tilePtAry, WorldPt[] wptAry, + int refHduNum, int plane, boolean allMatchingHDUs, + int ptSizeX, int ptSizeY, FitsExtract.CombineType ct, + FitsExtract.SecondaryHduCombine secondaryCombine, boolean isLine) { + int len= tilePtAry.length; + if (tileUrlAry.length!=len || tileFileAry.length!=len || wptAry.length!=len) { + throw new IllegalArgumentException("tileUrlAry, tileFileAry, tilePtAry, and wptAry must be the same length"); + } + Map> ptIdxByFile= new LinkedHashMap<>(); + for(int i=0; i new ArrayList<>()).add(i); + } + + List hduResults= null; // the HDUs of the first tile that is read, used for the columns + Map valuesByHdu= new LinkedHashMap<>(); + for(var entry : ptIdxByFile.entrySet()) { + List idxList= entry.getValue(); + ImagePt[] pts= idxList.stream().map( (i) -> tilePtAry[i]).toArray(ImagePt[]::new); + try { + var results= FitsExtract.getAllPointsFromRelatedHDUs(pts, entry.getKey(), refHduNum, plane, + allMatchingHDUs, ptSizeX, ptSizeY, ct, secondaryCombine); + if (hduResults==null) hduResults= results; + for(var result : results) { + Number[] values= valuesByHdu.computeIfAbsent(result.hduNum(), (k) -> new Number[len]); + for(int j=0; j dataTypes= new ArrayList<>(); + if (isLine) dataTypes.add(new DataType("offset", Double.class, "offset", "arcsec", null, null)); + dataTypes.add(new DataType("ra",Double.class, "ra", "deg", null, null)); + dataTypes.add(new DataType("dec",Double.class, "dec","deg", null, null )); + dataTypes.add(new DataType("tile", String.class, "Tile", null, null, null)); + dataTypes.add(new DataType("tileX", Integer.class, "Tile X", "pixel", null, null)); + dataTypes.add(new DataType("tileY", Integer.class, "Tile Y", "pixel", null, null)); + String refKey= null; + for(var result : hduResults) { + String key= makeKeyByHDU(result,null,plane); + String planeStr= FitsReadUtil.getNaxis3(result.header())>1 ? "/"+(plane+1) : ""; + String desc= result.extName()!=null ? result.extName()+planeStr : "HDU# "+result.hduNum()+planeStr; + desc= appendCombineType(desc,ptSizeX,ptSizeY,result.ct()); + dataTypes.add(new DataType(key, getDataType(result.aryData()), desc, result.fluxUnits(), null, null)); + if (result.refHDU()) refKey= key; + } + + DataGroup dataGroup = new DataGroup("HiPS points extract", dataTypes); + for (int i = 0; (i < len); i++) { + DataObject aRow = new DataObject(dataGroup); + if (isLine) aRow.setDataElement("offset",rnd(VisUtil.computeDistance(wptAry[0],wptAry[i])*3600,3)); + aRow.setDataElement("ra",rnd(wptAry[i].getX(),7)); + aRow.setDataElement("dec",rnd(wptAry[i].getY(),7)); + aRow.setDataElement("tile",getTileFileName(tileUrlAry[i])); + aRow.setDataElement("tileX",(int)tilePtAry[i].getX()); + aRow.setDataElement("tileY",(int)tilePtAry[i].getY()); + for(var result : hduResults) { + DataType dt= dataGroup.getDataDefintion(makeKeyByHDU(result,null,plane)); + Class type= dt.getDataType(); + Number value= valuesByHdu.get(result.hduNum())[i]; + boolean hasValue= value!=null && !Double.isNaN(value.doubleValue()); + if (hasValue) aRow.setDataElement(dt, toColumnType(value, type)); + else if (type==Float.class) aRow.setDataElement(dt, Float.NaN); + else if (type==Double.class) aRow.setDataElement(dt, Double.NaN); + // an integer column has no value for a point whose tile could not be read or that is a blank pixel + } + dataGroup.add(aRow); + } + + TableMeta meta= dataGroup.getTableMeta(); + meta.addKeyword(MetaConst.FITS_IMAGE_HDU, makeMetaEntryForHDUs(hduResults)); + if (plane>0) meta.addKeyword(MetaConst.FITS_IMAGE_HDU_CUBE_PLANE, plane+""); + meta.addKeyword(MetaConst.CENTER_COLUMN, "ra;dec;J2000"); + meta.addKeyword(MetaConst.FITS_EXTRACTION_TYPE, isLine ? "line" : "points"); + meta.addKeyword(MetaConst.DEFAULT_CHART_X_COL, isLine ? "offset" : "ra"); + if (refKey!=null) meta.addKeyword(MetaConst.DEFAULT_CHART_Y_COL, refKey); + meta.addKeyword(MetaConst.CATALOG_OVERLAY_TYPE, "TRUE"); + dataGroup.trimToSize(); + return dataGroup; + } + + /** + * @param value the value + * @param type the data type of the column + * @return the value converted to the data type of the column + */ + private static Object toColumnType(Number value, Class type) { + if (type==Integer.class) return value.intValue(); + if (type==Long.class) return value.longValue(); + if (type==Float.class) return value.floatValue(); + if (type==Double.class) return value.doubleValue(); + return value; + } + + /** + * @param tileUrl the url of a HiPS tile + * @return the file name part of the url, such as Npix1234.fits + */ + private static String getTileFileName(String tileUrl) { + if (tileUrl==null) return ""; + String path= tileUrl.replaceFirst("[?#].*$", ""); + return path.substring(path.lastIndexOf('/')+1); + } + + public static List initDataColumns(List allExtractions, boolean hasWpt, int ptSizeX, diff --git a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java index a32ab4e438..890957c8eb 100644 --- a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java +++ b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java @@ -31,6 +31,14 @@ public enum CombineType { } public enum SecondaryHduCombine {AUTO, SAME} + /** + * Use as an HDU number to extract from the first image HDU that has data. A HiPS FITS tile might have its image + * in the primary HDU or in an extension, the extension may not be HDU 1 if the file has a table first. + */ + public static final int FIRST_IMAGE_HDU= -1; + /** Use as a default when an HDU number parameter is missing, it is never a valid HDU number */ + public static final int NOT_SPECIFIED = Integer.MAX_VALUE; + public static final List uncertainty= List.of("UNCERTAINTY", "ERROR", "ERR", "UNC", "SIGMA", "NOISE", "UNCRT", "STDDEV"); public static final List variance= List.of("VARIANCE", "VAR", "SIGMA2", "VARMAP", "ERRSQR"); public static final List flags= List.of("FLAGS", "MASK", "MSK", "DQ", "FLAG", "BITMASK", "QUALITY", "BADPIX", "BADMAP", "PIXELMASK"); @@ -268,9 +276,11 @@ public static List extractFromRelatedHDUs(File fitsFile, int throws FitsException, IOException { try (Fits fits = new Fits(fitsFile)) { BasicHDU[] hdus = fits.read(); + refHduNum= resolveHduNum(hdus, refHduNum); BasicHDU hdu = hdus[refHduNum]; validateImageAtHDU(hdus, refHduNum); Header refHeader = hdu.getHeader(); + Header zeroHeader = hdus[0].getHeader(); int dims = FitsReadUtil.getNaxis(refHeader); int xLen = FitsReadUtil.getNaxis1(refHeader); int yLen = FitsReadUtil.getNaxis2(refHeader); @@ -283,12 +293,14 @@ public static List extractFromRelatedHDUs(File fitsFile, int CombineType newCt= getCombineType(h,i,ct,secondaryCombine,refHduNum); if (FitsReadUtil.getNaxis(h) == dims && FitsReadUtil.getNaxis1(h) == xLen && FitsReadUtil.getNaxis2(h) == yLen && FitsReadUtil.getNaxis3(h) == zLen) { var list = extractor.extractAry(hdus, i, newCt); - retList.add(new ExtractionResults(i, FitsReadUtil.getExtNameOrType(h), list, i == refHduNum, h,newCt)); + retList.add(new ExtractionResults(i, FitsReadUtil.getExtNameOrType(h), list, i == refHduNum, h,newCt, + FitsReadUtil.getFluxUnits(h,zeroHeader))); } } } else { var list = extractor.extractAry(hdus, refHduNum, ct); - retList.add(new ExtractionResults(refHduNum, FitsReadUtil.getExtNameOrType(refHeader), list,true, refHeader,ct)); + retList.add(new ExtractionResults(refHduNum, FitsReadUtil.getExtNameOrType(refHeader), list,true, refHeader,ct, + FitsReadUtil.getFluxUnits(refHeader,zeroHeader))); } return retList; } @@ -332,8 +344,27 @@ private static CombineType getCombineType(Header h, int hduNum, CombineType prim public static List extractFromHDU(File fitsFile, int hduNum, CombineType ct, Extractor extractor) throws FitsException, IOException { try (Fits fits= new Fits(fitsFile)) { - return extractor.extractAry(fits.read(), hduNum, ct); + BasicHDU[] hdus= fits.read(); + return extractor.extractAry(hdus, resolveHduNum(hdus,hduNum), ct); + } + } + + /** + * @param hdus the HDUs of a FITS file + * @param hduNum an HDU number or FIRST_IMAGE_HDU + * @return hduNum, if hduNum is FIRST_IMAGE_HDU then the index of the first image HDU that has data + * @throws FitsException if hduNum is FIRST_IMAGE_HDU and there is no image HDU with data + */ + static int resolveHduNum(BasicHDU[] hdus, int hduNum) throws FitsException { + if (hduNum!=FIRST_IMAGE_HDU) return hduNum; + for(int i=0; i=2 && FitsReadUtil.getNaxis1(h)>0 && FitsReadUtil.getNaxis2(h)>0) return i; + } } + throw new FitsException("no image HDU with data"); } @@ -420,5 +451,6 @@ private static void validateImageAtHDU(BasicHDU[] hdus, int hduNum) throws Fi public interface Extractor { List extractAry(BasicHDU[] hdus, int hduNum, CombineType ct) throws FitsException, IOException; } - public record ExtractionResults(int hduNum, String extName, List aryData, boolean refHDU, Header header, CombineType ct) { } + public record ExtractionResults(int hduNum, String extName, List aryData, boolean refHDU, Header header, + CombineType ct, String fluxUnits) { } } diff --git a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsRead.java b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsRead.java index 7dbb46ab5d..15fb647733 100644 --- a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsRead.java +++ b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsRead.java @@ -294,10 +294,7 @@ public double getFlux(ImagePt ipt) { } public String getFluxUnits() { - String bunit = getBUnit(); - if (bunit.startsWith("HITS")) return "frames"; - if (getOrigin().startsWith(ImageHeader.PALOMAR_ID)) return "mag"; - return bunit; + return FitsReadUtil.getFluxUnits(header, zeroHeader); } public boolean hasHdu() { return hdu!=null;} diff --git a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java index ac65deab98..c8dd28460a 100644 --- a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java +++ b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java @@ -444,6 +444,23 @@ public static double getBlankValue(Header h) { public static String getBUnit(Header h) { return getBUnit(h,""); } public static String getBUnit(Header h, String def) { return h.getStringValue(Standard.BUNIT, def); } + + /** + * Get the flux units of an image HDU. Use BUNIT, if not defined then use the BUNIT of the primary HDU + * @param h the header of the image HDU + * @param zeroHeader the header of the primary HDU, may be null + * @return the flux units, "---" if not defined + */ + public static String getFluxUnits(Header h, Header zeroHeader) { + String bunit= getBUnit(h,null); + if (bunit==null || bunit.equalsIgnoreCase("null")) { + bunit= zeroHeader!=null ? getBUnit(zeroHeader,"---") : "---"; + } + if (bunit.startsWith("HITS")) return "frames"; + if (h.getStringValue(ImageHeader.ORIGIN,"").startsWith(ImageHeader.PALOMAR_ID)) return "mag"; + return bunit; + } + public static String getExtName(Header h) { return h.getStringValue(Standard.EXTNAME); } public static String getExtType(Header h, String defVal) { return h.getStringValue(EXTTYPE,defVal); } public static String getUtype(Header h) { return h.getStringValue("UTYPE"); } diff --git a/src/firefly/js/data/MetaConst.js b/src/firefly/js/data/MetaConst.js index a7848bd1cc..14b6e95bfd 100644 --- a/src/firefly/js/data/MetaConst.js +++ b/src/firefly/js/data/MetaConst.js @@ -71,6 +71,12 @@ export const MetaConst = { */ FITS_FILE_PATH: 'FitsFilePath', + /** + * HiPS FITS tile url, the url of the HiPS FITS tile that is associated with this table. + * It is used in extraction from a HiPS. The HiPS root of the url will match the hipsUrlRoot of a HiPS WebPlot + */ + HIPS_TILE_URL: 'HiPSTileUrl', + /** * Fits Image HDU, The HDU number of a FITS file that is associated with this table */ diff --git a/src/firefly/js/data/ServerParams.js b/src/firefly/js/data/ServerParams.js index 7e35bd7dab..cea3d73f95 100644 --- a/src/firefly/js/data/ServerParams.js +++ b/src/firefly/js/data/ServerParams.js @@ -27,7 +27,6 @@ export const ServerParams = { PTARY: 'ptAry', WPT_ARY: 'wptAry', WL_ARY: 'wlAry', - FLUX_UNIT: 'fluxUnit', WL_UNIT: 'wlUnit', PLANE : 'plane', CRO_MULTI_ALL : 'cropMultiAll', @@ -91,6 +90,7 @@ export const ServerParams = { ALL : 'all', CDS : 'cds', IS_HIPS_TILE : 'isHipsTile', + HIPS_TILE_URL_ARY : 'hipsTileUrlAry', HIPS_SOURCES : 'hipsSources', HIPS_LIST_SOURCE: 'hipsListSource', HIPS_LIST_SOURCE_NAME: 'hipsListSourceName', diff --git a/src/firefly/js/metaConvert/DataProductsWatcher.js b/src/firefly/js/metaConvert/DataProductsWatcher.js index e80ee42004..1c43f0de19 100644 --- a/src/firefly/js/metaConvert/DataProductsWatcher.js +++ b/src/firefly/js/metaConvert/DataProductsWatcher.js @@ -180,7 +180,7 @@ function watchDataProductsTable(tbl_id, action, cancelSelf, params) { case ANY_REPLOT: if (!paused) { - const makeActive= !isDefaultCoverageActive(visRoot(),getMultiViewRoot()); + const makeActive= !isDefaultCoverageActive(getMultiViewRoot()); if (makeActive) resetImageFullGridActivePlot(tbl_id, action.payload.plotIdAry); } zoomOnNextViewSizeChange= false; diff --git a/src/firefly/js/metaConvert/ImageDataProductsUtil.js b/src/firefly/js/metaConvert/ImageDataProductsUtil.js index ec009b0adf..a3b6b713da 100644 --- a/src/firefly/js/metaConvert/ImageDataProductsUtil.js +++ b/src/firefly/js/metaConvert/ImageDataProductsUtil.js @@ -88,7 +88,7 @@ export function createSingleImageActivate(request, imageViewerId, tbl_id, highli } return () => { // const covViewer= ; - const makeActive= !isDefaultCoverageActive(visRoot(),getMultiViewRoot()); + const makeActive= !isDefaultCoverageActive(getMultiViewRoot()); replotImageDataProducts(request.getPlotId(), makeActive, imageViewerId, tbl_id, [request]); }; } diff --git a/src/firefly/js/rpc/PlotServicesJson.js b/src/firefly/js/rpc/PlotServicesJson.js index 4f2e6f61ac..90520a5e19 100644 --- a/src/firefly/js/rpc/PlotServicesJson.js +++ b/src/firefly/js/rpc/PlotServicesJson.js @@ -12,7 +12,8 @@ import {doJsonRequest} from '../core/JsonUtils.js'; import {SelectedShape} from '../drawingLayers/SelectedShape'; import {getCmdSrvSyncURL} from 'firefly/util/WebUtil.js'; import {fetchUrl} from 'firefly/api/ApiUtil.js'; -import {getBixPix, getNumberHeader, HdrConst} from 'firefly/visualize/FitsHeaderUtil.js'; +import {getBixPix} from 'firefly/visualize/FitsHeaderUtil.js'; +import {isHiPS} from 'firefly/visualize/WebPlot.js'; /** @@ -103,13 +104,29 @@ async function fetchExtraction(plot, inParams, cmd= ServerParams.FITS_EXTRACTION return use64Bit ? new Float64Array(arrayBuffer) : new Float32Array(arrayBuffer); } -export async function callGetCubeDrillDownAry(plot, hduNum, pt, ptSize, combineOp, relatedCubes=true) { +/** + * Get the z-axis values of a pixel from a cube + * @param {Object} p + * @param {WebPlot|undefined} p.plot + * @param {number} p.hduNum + * @param {ImagePt} p.ipt - for HiPS, the pixel in the FITS cube tile + * @param {number} p.pointSize + * @param {String} p.combineOp + * @param {String} [p.hipsTileUrl] - required for HiPS, url of the FITS cube tile + * @param {boolean} [p.relatedCubes] + * @return {Promise} + */ +export async function callGetCubeDrillDownAry({plot, hduNum, ipt, pointSize, combineOp, hipsTileUrl, relatedCubes=true}) { + if (!plot) return; + const fileParams= isHiPS(plot) + ? {[ServerParams.IS_HIPS_TILE]: true+'', [ServerParams.URL]: hipsTileUrl} + : {[ServerParams.STATE]: plot.plotState.toJson(false)}; return fetchExtraction(plot, { [ServerParams.EXTRACTION_TYPE]: 'z-axis', - [ServerParams.STATE]: plot.plotState.toJson(false), - [ServerParams.PT] : pt.toString(), - [ServerParams.POINT_SIZE] : ptSize+'', + ...fileParams, + [ServerParams.PT] : ipt.toString(), + [ServerParams.POINT_SIZE] : pointSize+'', [ServerParams.COMBINE_OP] : combineOp, [ServerParams.HDU_NUM] : hduNum+'', [ServerParams.RELATED_HDUS] : relatedCubes+'', @@ -131,12 +148,36 @@ export async function callGetLineExtractionAry(plot, hduNum, plane, pt, pt2, ptS }); } -export async function callGetPointExtractionAry(plot, hduNum, plane, ptAry, ptSizeX, ptSizeY, combineOp, relatedHDUS=true) { +/** + * Get the values at a list of points + * @param {Object} p + * @param {WebPlot|undefined} p.plot + * @param {number} p.hduNum + * @param {number} p.plane + * @param {Array.|Array.<{tileUrl:String, tilePt:ImagePt}>} p.ptAry - for an image, an array of image points, + * for HiPS, an array of the FITS tile location of each point (see getHiPSFitsTileLocation) + * @param {number} p.ptSizeX + * @param {number} p.ptSizeY + * @param {String} p.combineOp + * @param {boolean} [p.relatedHDUS] + * @return {Promise} + */ +export async function callGetPointExtractionAry({plot, hduNum, plane, ptAry, ptSizeX, ptSizeY, combineOp, relatedHDUS=true}) { + if (!plot) return; + const ptParams= isHiPS(plot) + ? { + [ServerParams.IS_HIPS_TILE]: true+'', + [ServerParams.HIPS_TILE_URL_ARY]: JSON.stringify(ptAry.map( ({tileUrl}) => tileUrl)), + [ServerParams.PTARY]: JSON.stringify(ptAry.map( ({tilePt}) => tilePt.toString())), + } + : { + [ServerParams.STATE]: plot.plotState.toJson(false), + [ServerParams.PTARY]: JSON.stringify(ptAry.map( (pt) => pt.toString())), + }; return fetchExtraction(plot, { [ServerParams.EXTRACTION_TYPE]: 'points', - [ServerParams.STATE]: plot.plotState.toJson(false), - [ServerParams.PTARY] : JSON.stringify(ptAry.map( (pt) => pt.toString())), + ...ptParams, [ServerParams.POINT_SIZE_X] : ptSizeX+'', [ServerParams.POINT_SIZE_Y] : ptSizeY+'', [ServerParams.COMBINE_OP] : combineOp, diff --git a/src/firefly/js/visualize/HiPSMocUtil.js b/src/firefly/js/visualize/HiPSMocUtil.js index f65b0e4766..6373301806 100644 --- a/src/firefly/js/visualize/HiPSMocUtil.js +++ b/src/firefly/js/visualize/HiPSMocUtil.js @@ -85,7 +85,7 @@ function doHeadersMatchMOCv2(entries, doTableValidation= true) { && entries.TTYPE1); if (doTableValidation) { - valid &&= entries.TFIELDS='1' && ['J','1J','K','1K'].includes(entries.TFORM1); + valid &&= (entries.TFIELDS==='1' && ['J','1J','K','1K'].includes(entries.TFORM1)); } const mocRetVal= valid && { uniqColName: entries.TTYPE1, mocOrder: entries.MOCORD_S+'' }; return {valid, [MOCInfo]: mocRetVal}; diff --git a/src/firefly/js/visualize/HiPSUtil.js b/src/firefly/js/visualize/HiPSUtil.js index fa12023b5f..9bb9847e2a 100644 --- a/src/firefly/js/visualize/HiPSUtil.js +++ b/src/firefly/js/visualize/HiPSUtil.js @@ -206,11 +206,31 @@ function makeHipsTilePath(plot,nOrder,tileNumber,ext) { export function makeHipsFitsTilePath(plot,nOrder,tileNumber) { const tileUrl= makeHipsTilePath(plot,nOrder,tileNumber,'fits'); - return plot.hasFitsCube && plot.cubeDepth>1 + return isHiPSFitsCube(plot) ? tileUrl.replace(/(_\d+)?\.fits$/,'_cube.fits') : tileUrl; } +/** + * @param {WebPlot|undefined} plot + * @return {boolean} true if this is a HiPS with FITS cube tiles (all planes of a tile in one FITS file) + */ +export const isHiPSFitsCube= (plot) => Boolean(isHiPS(plot) && plot.hasFitsCube && plot.cubeDepth>1); + +/** + * Find the HiPS FITS tile and the pixel in that tile that contains a world point + * @param {WebPlot|undefined} plot + * @param {WorldPt} wpt + * @param {number} norder - the tile norder + * @return {{norder:number, ipix:number, tileUrl:String, tilePt:ImagePt}} + */ +export function getHiPSFitsTileLocation(plot, wpt, norder) { + if (!plot || !wpt || !norder) return {}; + const {tilePixel, tileImagePt}= getHealpixPixelAtNorder(norder, wpt, plot.dataCoordSys) ?? {}; + if (!tileImagePt) return {}; + return {norder, ipix:tilePixel, tileUrl:makeHipsFitsTilePath(plot,norder,tilePixel), tilePt:tileImagePt}; +} + /** * @param urlRoot * @param exts diff --git a/src/firefly/js/visualize/PlotViewUtil.js b/src/firefly/js/visualize/PlotViewUtil.js index 6ff9fd0494..784f97852d 100644 --- a/src/firefly/js/visualize/PlotViewUtil.js +++ b/src/firefly/js/visualize/PlotViewUtil.js @@ -9,13 +9,13 @@ import { } from './VisConst'; import {has, isArray, isEmpty, isObject, isString, isUndefined} from 'lodash'; import shallowequal from 'shallowequal'; -import {memorizeLastCall} from '../util/WebUtil'; +import {memorizeLastCall, splitByWhiteSpace} from '../util/WebUtil'; import {Band} from './Band'; import CysConverter, {CCUtil} from './CsysConverter'; import {getNumberHeader, HdrConst} from './FitsHeaderUtil.js'; import {getViewer} from './MultiViewCntlr.js'; import { - getCenterPtOfPlot, getRotationAngle, isCsysDirMatching, isEastLeftOfNorth, isPlotNorth + getCenterPtOfPlot, getRotationAngle, isEastLeftOfNorth, isPlotNorth } from './WebPlotAnalysis'; import {getPlotGroupById} from './PlotGroup.js'; import {makeTransform} from './PlotTransformUtils.js'; @@ -43,7 +43,7 @@ export function getPlotViewAry(ref, plotGroupId= undefined) { pvAry= ref; } if (!plotGroupId) return pvAry ?? []; - return pvAry.filter( (pv) => pv.plotGroupId===plotGroupId); + return (pvAry ?? []).filter( (pv) => pv.plotGroupId===plotGroupId); } /** @@ -637,7 +637,7 @@ export function getCenterOfProjection(plot) { * Find the point in the plot that is at the center of the display. * The point returned is in ImagePt coordinates. * We return it in and ImagePt not screen because if the plot is zoomed the image point will still - * be what we want in the center. A alternate scrollX and scrollY may be passed otherwise the current + * be what we want in the center. An alternate scrollX and scrollY may be passed otherwise the current * scrollX and scrollY is used. * * @param {PlotView} plotView @@ -689,7 +689,7 @@ export function isPlotIdInPvNewPlotInfoAry(pvNewPlotInfoAry, plotId) { * @param {String} plotId * @return {number} */ -const getCanvasIdx= (c,start,plotId) => Number(c.id.substr(0, c.id.length - plotId.length - 1).substr(start)); +const getCanvasIdx= (c,start,plotId) => Number(c.id.substring(start.length, c.id.length - plotId.length - 1)); export function getCorners(plot) { if (!plot) return null; @@ -799,7 +799,7 @@ export const getHduPlotStartIndexes= (pv) => pv?.plotViewCtx.hduPlotStartIndexes * @param {PlotView} pv * @return {Number} */ -export const getHDUCount= (pv) => getHduPlotStartIndexes?.(pv).length ?? 0; +export const getHDUCount= (pv) => getHduPlotStartIndexes(pv)?.length ?? 0; /** @@ -951,7 +951,36 @@ export function convertImageIdxToHDU(pv, imageIdx) { * @return {boolean} */ export const hasWLInfo= (plot) => - Boolean(plot?.wlData?.hasPlainOnlyCoordInfo || plot?.wlData?.hasPixelLevelCoordInfo ); + isHiPS(plot) + ? Boolean(getHiPSCubeWavelengths(plot)) + : Boolean(plot?.wlData?.hasPlainOnlyCoordInfo || plot?.wlData?.hasPixelLevelCoordInfo ); + +const parseHiPSCubeWavelengths= memorizeLastCall( (crval3ArrStr, cubeDepth) => { + const wlAry= splitByWhiteSpace(crval3ArrStr).map( (s) => Number(s)); + return (wlAry.length===cubeDepth && wlAry.every( (v) => Number.isFinite(v))) ? wlAry : undefined; +}, 4); + +/** + * Get the wavelength of each plane of a HiPS cube from the data_cube_crval3_arr property + * @param {WebPlot|undefined} plot + * @return {Array.|undefined} the wavelengths, undefined if not a HiPS cube or no valid wavelength for every plane + */ +function getHiPSCubeWavelengths(plot) { + if (!isHiPS(plot) || !(plot.cubeDepth>1)) return; + const crval3ArrStr= plot.hipsProperties?.data_cube_crval3_arr; + if (!crval3ArrStr) return; + return parseHiPSCubeWavelengths(crval3ArrStr, plot.cubeDepth); +} + +/** + * Get the wavelength units of a HiPS cube from the data_cube_bunit3 property + * @param {WebPlot|undefined} plot + * @return {string} + */ +function getHiPSCubeWavelengthUnits(plot) { + const bunit3= plot?.hipsProperties?.data_cube_bunit3; + return (bunit3 && bunit3!=='null' && bunit3!=='nil' && bunit3!=='undefined') ? bunit3 : ''; +} export const wavelengthInfoParsedSuccessfully= (plot) => !plot?.wlData?.failReason; @@ -998,8 +1027,11 @@ export const getWaveLengthUnits= (plot, band) => getWlUnitValue(plot,0,band); */ export const getBandWidthUnits= (plot, band) => getWlUnitValue(plot,1,band); -const getWlUnitValue= (plot, idx, band= Band.NO_BAND) => - hasWLInfo(plot) ? plot.wlDataAry[band.value]?.spectralCoords?.[idx]?.units : ''; +const getWlUnitValue= (plot, idx, band= Band.NO_BAND) => { + if (!hasWLInfo(plot)) return ''; + if (isHiPS(plot)) return idx===0 ? getHiPSCubeWavelengthUnits(plot) : ''; + return plot.wlDataAry[band.value]?.spectralCoords?.[idx]?.units; +}; const micronRe= new RegExp('microns|micron|um|micrometers','gi'); @@ -1049,6 +1081,7 @@ export const getPtWavelength= (plot, pt, cubeIdx, band) => getPtSpectralCoords(p * @return {Array.} */ export function getPtSpectralCoords(plot, pt, cubeIdx, band= Band.NO_BAND) { + if (isHiPS(plot)) return [getHiPSCubeWavelengths(plot)?.[cubeIdx] ?? 0, 0]; if (!plot?.wlDataAry?.[band.value] || !hasWLInfo(plot)) return [0,0]; const ipt= CCUtil.getImageCoords(plot,pt); if (!ipt && plot?.wlData?.hasPixelLevelCoordInfo) return [0,0]; @@ -1155,7 +1188,7 @@ export const pvEqualExScroll= memorizeLastCall((pv1,pv2) => { /** - * Return true if the plot in both PlotViews are rotated the same + * Return true if the plot in both PlotViews are rotated the same, that is north points the same way on the screen * @param {PlotView|undefined} pv1 * @param {PlotView|undefined} pv2 * @return {boolean} @@ -1167,9 +1200,29 @@ export function isRotationMatching(pv1, pv2) { if (!p1 || !p2) return false; if (isNorthCountingRotation(pv1, p1) && isNorthCountingRotation(pv2, p2)) return true; const centerFirstPlot= isImage(p1) ? getCenterPtOfPlot(p1) : undefined; // if image use center point method - const r1 = getRotationAngle(p1,centerFirstPlot) + pv1.rotation; - const r2 = getRotationAngle(p2,centerFirstPlot) + pv2.rotation; - return Math.abs((r1 % 360) - (r2 % 360)) < .9; + const diff= Math.abs(getScreenNorthAngle(pv1,p1,centerFirstPlot) - getScreenNorthAngle(pv2,p2,centerFirstPlot)); + return Math.min(diff, 360-diff) < .9; +} + +/** + * Return true if east is displayed to the left of north, taking the flipY of the PlotView into account + * @param {PlotView} pv + * @param {WebPlot} [plot] + * @return {boolean} + */ +export const isEastDisplayedLeft= (pv, plot= primePlot(pv)) => isEastLeftOfNorth(plot)!==Boolean(pv.flipY); + +/** + * Return the direction of north on the screen, in degrees clockwise from up, in the range 0 to 360 + * @param {PlotView} pv + * @param {WebPlot} plot + * @param {WorldPt} [wp] the world point to base rotation angle on + * @return {number} + */ +function getScreenNorthAngle(pv, plot, wp) { + const imageAngle= getRotationAngle(plot,wp); + const angle= isEastDisplayedLeft(pv,plot) ? imageAngle + pv.rotation : pv.rotation - imageAngle; + return ((angle % 360) + 360) % 360; } const isNorthCountingRotation = (pv, plot) => pv.plotViewCtx.rotateNorthLock || (isPlotNorth(plot) && !pv.rotation); @@ -1187,11 +1240,10 @@ export function isAllStretchDataLoaded(vr) { /** * - * @param {VisRoot} vr * @param {MultiViewerRoot} mvr * @returns {boolean} */ -export function isDefaultCoverageActive(vr, mvr) { +export function isDefaultCoverageActive(mvr) { if (currentP().plotId!==DEFAULT_COVERAGE_PLOT_ID) return false; return Boolean(getViewer(mvr,DEFAULT_COVERAGE_VIEWER_ID)?.mounted); } @@ -1237,7 +1289,8 @@ export function canConvertBetweenHipsAndFits(pv) { } /** - * return an angle that will rotate the pv to match the rotation of masterPv + * return an angle that will rotate the pv to match the rotation of masterPv. The angle is the one passed + * to dispatchRotate with RotateType.ANGLE * @param {PlotView} masterPv * @param {PlotView} pv * @return {number} @@ -1246,21 +1299,11 @@ export function getMatchingRotationAngle(masterPv, pv) { const masterPlot = primePlot(masterPv); const plot = primePlot(pv); if (!plot || !masterPlot) return 0; - const centerMasterPlot= isImage(plot) ? getCenterPtOfPlot(plot) : undefined; // if image use center point method - const flipDiff= masterPv.flipY!==pv.flipY; - const masterRot = masterPv?.rotation * (flipDiff ? -1 : 1); - const rot = getRotationAngle(plot,centerMasterPlot); - let targetRotation; - if (isEastLeftOfNorth(masterPlot) && !masterPv.flipY) { - targetRotation = ((getRotationAngle(masterPlot,centerMasterPlot) + masterRot) - rot) * (flipDiff ? 1 : -1); - } else { - targetRotation = ((getRotationAngle(masterPlot,centerMasterPlot) + (360 - masterRot)) - rot) * (flipDiff ? 1 : -1); - - } - if (!isCsysDirMatching(plot, masterPlot)) targetRotation = 360 - targetRotation; - if (targetRotation < 0) targetRotation += 360; - if (targetRotation > 359) targetRotation %= 360; - return targetRotation; + const centerMasterPlot= isImage(masterPlot) ? getCenterPtOfPlot(masterPlot) : undefined; // if image use center point method + const masterNorth= getScreenNorthAngle(masterPv,masterPlot,centerMasterPlot); + const imageAngle= getRotationAngle(plot,centerMasterPlot); + const targetRotation= isEastDisplayedLeft(pv,plot) ? imageAngle - masterNorth : masterNorth + imageAngle; + return ((targetRotation % 360) + 360) % 360; } /** @@ -1271,12 +1314,12 @@ export function getMatchingRotationAngle(masterPv, pv) { export function makeUniquePlotIdFromBase(inId) { if (!getPlotViewById(visRoot(), inId)) return inId; + const prefix= `${inId} - `; const numList= getPlotViewAry(visRoot()) - .filter((pv) => pv.plotId.startsWith(inId)) .map((pv) => pv.plotId) - .map((id) => id?.substring(inId.length).trim().split('-')?.[1]) - .map(Number) - .filter(Boolean); + .filter((id) => id?.startsWith(prefix)) + .map((id) => Number(id.substring(prefix.length))) + .filter((n) => Number.isInteger(n) && n>0); const maxNum = numList?.length ? Math.max(...numList) : 0; return `${inId} - ${maxNum + 1}`; diff --git a/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js b/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js index fda8d101eb..7b5e2465ea 100644 --- a/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js +++ b/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js @@ -1,7 +1,32 @@ import {primePlot, getPlotViewById, isActivePlotView, getActivePlotView} from '../PlotViewUtil.js'; import {makePlotView} from '../reducer/PlotView.js'; import {WebPlotRequest} from '../WebPlotRequest.js'; -import {findPlot, primePlotType} from '../PlotViewUtil'; +import { + applyToOnePvOrAll, applyToOnePvOrOverlayGroup, canConvertBetweenHipsAndFits, clonePvAry, clonePvAryWithPv, + convertHDUIdxToImageIdx, convertImageIdxToHDU, currentP, findCurrentCenterPoint, findPlot, findPlotGroup, + getAllCanvasLayersForPlot, getAllDrawLayersForPlot, getAllPlotViewIdByOverlayLock, getAllWaveLengthsForCube, + getBandWidthUnits, getCenterOfProjection, getConnectedPlotsIds, getCorners, getCubeLength, + getCubePlaneFromWavelength, getCubePlaneIdx, getDrawLayerById, getDrawLayerByType, getDrawLayersByDisplayGroup, + getDrawLayersByType, getFormattedWaveLengthUnits, getFoV, getHDU, getHDUCount, getHDUIndex, + getHduPlotStartIndexes, getLayerTitle, getMatchingRotationAngle, getNextHiPSPlotId, getNumberOfCubesInPV, + getOnePvOrGroup, getOverlayById, getOverlayByPvAndId, getPlotGroupIdxById, getPlotStateAry, getPlotViewAry, + getPlotViewIdListByPositionLock, getPlotViewIdListInOverlayGroup, getPlotViewIdxById, getPlotViewProxyById, + getPrimaryPlotHdu, getPtSpectralCoords, getPtWavelength, getWavelengthParseFailReason, getWaveLengthUnits, + getWaveLengthUnitsPrecision, hasImageCubes, hasOverlayColorLock, hasPixelLevelWLInfo, hasPlaneOnlyWLInfo, + hasWCSProjection, hasWLInfo, isCube, isDefaultCoverageActive, isDrawLayerAttached, isDrawLayerVisible, + isImageExpanded, isMultiHDUFits, isMultiImageFits, isMultiImageFitsWithSameArea, isPlotIdInPvNewPlotInfoAry, + isPlotViewArysEqual, isPlotViewsEqual, isRotationMatching, isThreeColor, makeUniquePlotIdFromBase, + matchPlotViewByPositionGroup, operateOnOthersInOverlayColorGroup, operateOnOthersInPositionGroup, + plotInActiveGroup, primePlotType, pvEqualExScroll, refreshP, wavelengthInfoParsedSuccessfully +} from '../PlotViewUtil'; +import CoordinateSys from '../CoordSys.js'; +import CysConverter from '../CsysConverter.js'; +import {makeDevicePt, makeImagePt, makeWorldPt} from '../Point.js'; +import {makeTransform} from '../PlotTransformUtils.js'; +import {HIPS_AITOFF} from '../projection/Projection.js'; +import {DEFAULT_COVERAGE_PLOT_ID, DEFAULT_COVERAGE_VIEWER_ID, ExpandType} from '../VisConst.js'; +import * as VisStoreRoots from '../VisStoreRoots.js'; +import * as WebPlotAnalysis from '../WebPlotAnalysis.js'; @@ -122,3 +147,1358 @@ describe('Less used PlotViewUtil functions', () => { }); }); + + + +//============================================================= +//---------- HDU, cube, and wavelength functions +//============================================================= + +/** + * make an image plot + * @param {Object} p + * @param {String} p.plotId + * @param {number} p.idx - index of the plot in the PlotView + * @param {number} [p.hdu] - the SPOT_EXT header value, no header if undefined + * @param {number} [p.cubePlane] - plane of a cube, not a cube if undefined + * @param {number} [p.cubeLength] + * @param {Object} [p.linearWL] - linear wavelength, {crval, cdelt, units}, wavelength= crval + cdelt*cubePlane + * @return {Object} + */ +const makeImagePlot= ({plotId, idx, hdu, cubePlane, cubeLength, linearWL}) => ({ + plotId, + plotType: 'image', + plotImageId: `${plotId}-image${idx}`, + header: hdu!==undefined ? {SPOT_EXT: {value: hdu+''}} : {}, + cubeCtx: cubePlane!==undefined ? {cubePlane, cubeLength} : undefined, + wlData: linearWL ? {hasPlainOnlyCoordInfo: true} : undefined, + wlDataAry: linearWL ? [{ + spectralCoords: [{ + algorithm: 'LINEAR', N: 3, r_j: [0, 0, 1], pc_3j: [0, 0, 1], + crval: linearWL.crval, cdelt: linearWL.cdelt, units: linearWL.units + }] + }] : undefined, +}); + +const makeHiPSPlot= ({plotId='hipsId', cubeDepth=1, cubeIdx=0, hipsProperties={}}={}) => ({ + plotId, + plotType: 'hips', + plotImageId: plotId+'-hips', + cubeDepth, + cubeIdx, + hipsProperties, +}); + +const makePv= (plotId, plots, primeIdx=0, hduPlotStartIndexes=[0], multiHdu=false) => { + const req= WebPlotRequest.makeURIPlotRequest('http://a/b/c.fits'); + req.setPlotId(plotId); + const pv= makePlotView(plotId, req); + pv.plots= plots; + pv.primeIdx= primeIdx; + pv.plotViewCtx= {...pv.plotViewCtx, hduPlotStartIndexes, multiHdu}; + return pv; +}; + +/** + * A multi-HDU FITS file: HDU 0 is an image, HDU 1 is a 3 plane cube, HDU 2 is an image + * plots: [hdu0, hdu1-plane0, hdu1-plane1, hdu1-plane2, hdu2] + * @param {number} [primeIdx] + */ +const makeMultiHduPv= (primeIdx=0) => { + const plotId= 'multiHdu'; + const plots= [ + makeImagePlot({plotId, idx:0, hdu:0}), + makeImagePlot({plotId, idx:1, hdu:1, cubePlane:0, cubeLength:3}), + makeImagePlot({plotId, idx:2, hdu:1, cubePlane:1, cubeLength:3}), + makeImagePlot({plotId, idx:3, hdu:1, cubePlane:2, cubeLength:3}), + makeImagePlot({plotId, idx:4, hdu:2}), + ]; + return makePv(plotId, plots, primeIdx, [0,1,4], true); +}; + +const makeSingleImagePv= () => makePv('single', [makeImagePlot({plotId:'single', idx:0})]); + +/** + * a single HDU, 4 plane cube, wavelength= 2 + .5*plane + * @param {number} [primeIdx] + */ +const makeWLCubePv= (primeIdx=0) => { + const plotId= 'wlCube'; + const linearWL= {crval:2, cdelt:.5, units:'um'}; + const plots= [0,1,2,3].map( (i) => + makeImagePlot({plotId, idx:i, hdu:0, cubePlane:i, cubeLength:4, linearWL})); + return makePv(plotId, plots, primeIdx); +}; + +const makeHiPSPv= (hipsPlot) => makePv(hipsPlot.plotId, [hipsPlot]); + + +describe('Test PlotViewUtil.isCube()', () => { + test('undefined plot', () => expect(isCube(undefined)).toBe(false)); + test('image that is not a cube', () => expect(isCube(makeImagePlot({plotId:'a', idx:0}))).toBe(false)); + test('image that is a cube plane', () => + expect(isCube(makeImagePlot({plotId:'a', idx:0, cubePlane:1, cubeLength:3}))).toBe(true)); + test('HiPS with one plane', () => expect(isCube(makeHiPSPlot({cubeDepth:1}))).toBe(false)); + test('HiPS cube', () => expect(isCube(makeHiPSPlot({cubeDepth:5}))).toBe(true)); +}); + +describe('Test PlotViewUtil.getCubePlaneIdx()', () => { + test('undefined plot', () => expect(getCubePlaneIdx(undefined)).toBe(-1)); + test('image that is not a cube', () => expect(getCubePlaneIdx(makeImagePlot({plotId:'a', idx:0}))).toBe(-1)); + test('image cube plane', () => + expect(getCubePlaneIdx(makeImagePlot({plotId:'a', idx:0, cubePlane:2, cubeLength:3}))).toBe(2)); + test('HiPS cube', () => expect(getCubePlaneIdx(makeHiPSPlot({cubeDepth:5, cubeIdx:3}))).toBe(3)); +}); + +describe('Test PlotViewUtil.getHDU()', () => { + test('undefined plot', () => expect(getHDU(undefined)).toBe(0)); + test('image with SPOT_EXT', () => expect(getHDU(makeImagePlot({plotId:'a', idx:0, hdu:2}))).toBe(2)); + test('image without SPOT_EXT', () => expect(getHDU(makeImagePlot({plotId:'a', idx:0}))).toBe(0)); + test('HiPS', () => expect(getHDU(makeHiPSPlot())).toBe(0)); +}); + +describe('Test PlotViewUtil.getHDUIndex()', () => { + test('undefined PlotView', () => expect(getHDUIndex(undefined)).toBe(0)); + test('prime plot in the first HDU', () => expect(getHDUIndex(makeMultiHduPv(0))).toBe(0)); + test('prime plot is a plane of the cube in the second HDU', () => expect(getHDUIndex(makeMultiHduPv(2))).toBe(1)); + test('passing a plot from the third HDU', () => { + const pv= makeMultiHduPv(0); + expect(getHDUIndex(pv, pv.plots[4])).toBe(2); + }); + test('single image', () => expect(getHDUIndex(makeSingleImagePv())).toBe(0)); +}); + +describe('Test PlotViewUtil.isMultiHDUFits()', () => { + test('undefined PlotView', () => expect(isMultiHDUFits(undefined)).toBe(false)); + test('multi-HDU file', () => expect(isMultiHDUFits(makeMultiHduPv())).toBe(true)); + test('single image', () => expect(isMultiHDUFits(makeSingleImagePv())).toBe(false)); +}); + +describe('Test PlotViewUtil.convertHDUIdxToImageIdx()', () => { + test('undefined PlotView', () => expect(convertHDUIdxToImageIdx(undefined, 0)).toBeUndefined()); + test('object that is not a PlotView', () => expect(convertHDUIdxToImageIdx({plots:[]}, 0)).toBeUndefined()); + test('single image always returns 0', () => expect(convertHDUIdxToImageIdx(makeSingleImagePv(), 1)).toBe(0)); + test('first HDU', () => expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 0)).toBe(0)); + test('cube HDU, first plane by default', () => expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 1)).toBe(1)); + test('cube HDU, plane 2', () => expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 1, 2)).toBe(3)); + test('cube HDU, plane past the end of the cube returns the first plane', () => + expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 1, 5)).toBe(1)); + test('image HDU ignores the cube index', () => expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 2, 2)).toBe(4)); + test('HDU index past the last HDU returns 0', () => expect(convertHDUIdxToImageIdx(makeMultiHduPv(), 3)).toBe(0)); + test('follow uses the cube plane of the prime plot', () => + expect(convertHDUIdxToImageIdx(makeMultiHduPv(3), 1, 'follow')).toBe(3)); +}); + +describe('Test PlotViewUtil.isDrawLayerAttached()', () => { + const dl= {plotIdAry: ['plot1', 'plot2']}; + test('undefined draw layer', () => expect(isDrawLayerAttached(undefined, 'plot1')).toBe(false)); + test('attached plot', () => expect(isDrawLayerAttached(dl, 'plot2')).toBe(true)); + test('plot that is not attached', () => expect(isDrawLayerAttached(dl, 'plot3')).toBe(false)); +}); + +describe('Test PlotViewUtil.hasWLInfo()', () => { + test('undefined plot', () => expect(hasWLInfo(undefined)).toBe(false)); + test('image without wavelength info', () => expect(hasWLInfo(makeImagePlot({plotId:'a', idx:0}))).toBe(false)); + test('image with plane only wavelength info', () => expect(hasWLInfo(makeWLCubePv().plots[0])).toBe(true)); + test('image with pixel level wavelength info', () => + expect(hasWLInfo({...makeImagePlot({plotId:'a', idx:0}), wlData:{hasPixelLevelCoordInfo:true}})).toBe(true)); + test('HiPS cube with a wavelength for every plane', () => + expect(hasWLInfo(makeHiPSPlot({cubeDepth:3, hipsProperties:{data_cube_crval3_arr:'1.5 2.5 3.5'}}))).toBe(true)); + test('HiPS cube without data_cube_crval3_arr', () => expect(hasWLInfo(makeHiPSPlot({cubeDepth:3}))).toBe(false)); + test('HiPS cube with the wrong number of wavelengths', () => + expect(hasWLInfo(makeHiPSPlot({cubeDepth:4, hipsProperties:{data_cube_crval3_arr:'1.5 2.5 3.5'}}))).toBe(false)); + test('HiPS cube with a wavelength that is not a number', () => + expect(hasWLInfo(makeHiPSPlot({cubeDepth:3, hipsProperties:{data_cube_crval3_arr:'1.5 abc 3.5'}}))).toBe(false)); + test('HiPS that is not a cube', () => + expect(hasWLInfo(makeHiPSPlot({cubeDepth:1, hipsProperties:{data_cube_crval3_arr:'1.5'}}))).toBe(false)); +}); + +describe('Test PlotViewUtil.getCubePlaneFromWavelength()', () => { + const hipsPv= makeHiPSPv(makeHiPSPlot({cubeDepth:3, hipsProperties:{data_cube_crval3_arr:'1.5 2.5 3.5'}})); + test('HiPS cube, wavelength of the second plane', () => expect(getCubePlaneFromWavelength(hipsPv, 2.5)).toBe(1)); + test('HiPS cube, wavelength not in the cube', () => expect(getCubePlaneFromWavelength(hipsPv, 9)).toBe(-1)); + test('HiPS cube without wavelength info', () => + expect(getCubePlaneFromWavelength(makeHiPSPv(makeHiPSPlot({cubeDepth:3})), 2.5)).toBe(-1)); + test('image cube with linear wavelength', () => expect(getCubePlaneFromWavelength(makeWLCubePv(), 3)).toBe(2)); + test('image cube with linear wavelength, wavelength not in the cube', () => + expect(getCubePlaneFromWavelength(makeWLCubePv(), 2.25)).toBe(-1)); + test('image cube without wavelength info', () => + expect(getCubePlaneFromWavelength(makeMultiHduPv(1), 3)).toBe(-1)); +}); + + + +//============================================================= +//---------- PlotView and PlotGroup lookup functions +//============================================================= + +describe('Test PlotViewUtil.getPlotViewAry()', () => { + test('undefined ref', () => expect(getPlotViewAry(undefined)).toBeUndefined()); + test('using visRoot', () => expect(getPlotViewAry(visRoot)).toBe(pvAry)); + test('using a PlotView array', () => expect(getPlotViewAry(pvAry)).toBe(pvAry)); + test('using an empty array', () => expect(getPlotViewAry([])).toEqual([])); + test('filter by plot group', () => { + const groupAry= getPlotViewAry(visRoot, 'aTestGroup'); + expect(groupAry.map( (pv) => pv.plotId)).toEqual(['testId1', ACTIVE_ID, 'testId-yyyy']); + }); + test('filter by a plot group that does not exist', () => expect(getPlotViewAry(pvAry, 'noGroup')).toEqual([])); + test('filter by plot group using an empty array', () => expect(getPlotViewAry([], 'aTestGroup')).toEqual([])); + test('filter by plot group using an object that is not a visRoot', () => + expect(getPlotViewAry({}, 'aTestGroup')).toEqual([])); +}); + +describe('Test PlotViewUtil.getPlotViewIdxById()', () => { + test('using visRoot', () => expect(getPlotViewIdxById(visRoot, 'testId-yyyy')).toBe(2)); + test('using a PlotView array', () => expect(getPlotViewIdxById(pvAry, 'testId-zzzz')).toBe(3)); + test('plotId that does not exist', () => expect(getPlotViewIdxById(pvAry, 'noId')).toBe(-1)); + test('undefined plotId', () => expect(getPlotViewIdxById(pvAry, undefined)).toBeUndefined()); +}); + +const plotGroupAry= [ + {plotGroupId: 'aTestGroup', overlayColorLock: true}, + {plotGroupId: 'anotherGroup', overlayColorLock: false}, +]; + +describe('Test PlotViewUtil.getPlotGroupIdxById()', () => { + test('using an object with a plotGroupAry', () => + expect(getPlotGroupIdxById({plotGroupAry}, 'anotherGroup')).toBe(1)); + test('using a PlotGroup array', () => expect(getPlotGroupIdxById(plotGroupAry, 'aTestGroup')).toBe(0)); + test('plotGroupId that does not exist', () => expect(getPlotGroupIdxById(plotGroupAry, 'noGroup')).toBe(-1)); + test('undefined ref', () => expect(getPlotGroupIdxById(undefined, 'aTestGroup')).toBeUndefined()); +}); + +describe('Test PlotViewUtil.findPlotGroup()', () => { + test('find a group', () => expect(findPlotGroup('anotherGroup', plotGroupAry)).toBe(plotGroupAry[1])); + test('plotGroupId that does not exist', () => expect(findPlotGroup('noGroup', plotGroupAry)).toBeUndefined()); + test('undefined plotGroupId', () => expect(findPlotGroup(undefined, plotGroupAry)).toBeUndefined()); + test('undefined plotGroupAry', () => expect(findPlotGroup('aTestGroup', undefined)).toBeUndefined()); +}); + +describe('Test PlotViewUtil.getPlotViewProxyById()', () => { + const proxy= {plotId: 'proxyId'}; + const proxyVisRoot= {...visRoot, plotProxyAry: [{plotId: 'otherProxyId'}, proxy]}; + test('find a proxy', () => expect(getPlotViewProxyById(proxyVisRoot, 'proxyId')).toBe(proxy)); + test('plotId that does not exist', () => expect(getPlotViewProxyById(proxyVisRoot, 'noId')).toBeUndefined()); + test('visRoot without proxies', () => expect(getPlotViewProxyById(visRoot, 'proxyId')).toBeUndefined()); + test('undefined visRoot', () => expect(getPlotViewProxyById(undefined, 'proxyId')).toBeUndefined()); + test('undefined plotId', () => expect(getPlotViewProxyById(proxyVisRoot, undefined)).toBeUndefined()); +}); + +describe('Test PlotViewUtil.getOnePvOrGroup()', () => { + const [lockedGroup, unlockedGroup]= plotGroupAry; + const ids= (pvList) => pvList.map( (pv) => pv.plotId); + test('group with overlay color lock returns the whole group', () => + expect(ids(getOnePvOrGroup(pvAry, 'testId1', lockedGroup))).toEqual(['testId1', ACTIVE_ID, 'testId-yyyy'])); + test('group without overlay color lock returns only the PlotView', () => + expect(ids(getOnePvOrGroup(pvAry, 'testId-zzzz', unlockedGroup))).toEqual(['testId-zzzz'])); + test('forceAllInGroup returns the whole group without a lock', () => { + const noLockGroup= {plotGroupId: 'aTestGroup', overlayColorLock: false}; + expect(ids(getOnePvOrGroup(pvAry, 'testId1', noLockGroup))).toEqual(['testId1']); + expect(ids(getOnePvOrGroup(pvAry, 'testId1', noLockGroup, true))).toEqual(['testId1', ACTIVE_ID, 'testId-yyyy']); + }); + test('PlotView not in the locked group returns only the PlotView', () => + expect(ids(getOnePvOrGroup(pvAry, 'testId-zzzz', lockedGroup))).toEqual(['testId-zzzz'])); +}); + +describe('Test PlotViewUtil.makeUniquePlotIdFromBase()', () => { + const mockVisRoot= (plotIdAry) => { + const plotViewAry= plotIdAry.map( (plotId) => makeMockPv(plotId, 'aTestGroup', 1)); + jest.spyOn(VisStoreRoots, 'visRoot').mockReturnValue({plotViewAry, activePlotId: plotIdAry[0]}); + }; + afterEach(() => jest.restoreAllMocks()); + + test('plotId that is not used is returned unchanged', () => { + mockVisRoot(['otherId']); + expect(makeUniquePlotIdFromBase('plotA')).toBe('plotA'); + }); + test('plotId that is used gets a number', () => { + mockVisRoot(['plotA']); + expect(makeUniquePlotIdFromBase('plotA')).toBe('plotA - 1'); + }); + test('number is one more than the highest number used', () => { + mockVisRoot(['plotA', 'plotA - 1', 'plotA - 3']); + expect(makeUniquePlotIdFromBase('plotA')).toBe('plotA - 4'); + }); + test('plotIds that only start with the same text are not counted', () => { + mockVisRoot(['plotA', 'plotAB - 7']); + expect(makeUniquePlotIdFromBase('plotA')).toBe('plotA - 1'); + }); + test('numbered plotIds that are not a whole number are not counted', () => { + mockVisRoot(['plotA', 'plotA - 2', 'plotA - 9x']); + expect(makeUniquePlotIdFromBase('plotA')).toBe('plotA - 3'); + }); +}); + + + +//============================================================= +//---------- Image type, HDU, and cube functions +//============================================================= + +const makeStubProjection= ({specified=true, implemented=true, celestial=true, name='TAN', raOffset=0}={}) => ({ + isSpecified: () => specified, + isImplemented: () => implemented, + coordSys: {isCelestial: () => celestial}, + getProjectionName: () => name, + getWorldCoords: (x,y) => makeWorldPt(10+raOffset+x*.001, 20+y*.001, CoordinateSys.EQ_J2000), +}); + +const withProjection= (plot, {w=100, h=100, ...projOptions}={}) => + ({...plot, dataWidth:w, dataHeight:h, projection: makeStubProjection(projOptions)}); + +const withThreeColor= (plot, threeColor) => ({...plot, plotState: {isThreeColor: () => threeColor}}); + +describe('Test PlotViewUtil.hasWCSProjection()', () => { + const plot= withProjection(makeImagePlot({plotId:'a', idx:0})); + test('undefined ref', () => expect(hasWCSProjection(undefined)).toBe(false)); + test('plot with a celestial projection', () => expect(hasWCSProjection(plot)).toBe(true)); + test('PlotView with a celestial projection', () => expect(hasWCSProjection(makePv('a', [plot]))).toBe(true)); + test('plot without a projection', () => expect(hasWCSProjection(makeImagePlot({plotId:'a', idx:0}))).toBe(false)); + test('projection that is not specified', () => + expect(hasWCSProjection(withProjection(plot, {specified:false}))).toBe(false)); + test('projection that is not implemented', () => + expect(hasWCSProjection(withProjection(plot, {implemented:false}))).toBe(false)); + test('non-celestial projection', () => { + const linearPlot= withProjection(plot, {celestial:false}); + expect(hasWCSProjection(linearPlot)).toBe(false); + expect(hasWCSProjection(linearPlot, true)).toBe(true); + }); +}); + +describe('Test PlotViewUtil.isThreeColor()', () => { + const plot= makeImagePlot({plotId:'a', idx:0}); + test('undefined plot', () => expect(isThreeColor(undefined)).toBe(false)); + test('three color plot', () => expect(isThreeColor(withThreeColor(plot, true))).toBe(true)); + test('single band plot', () => expect(isThreeColor(withThreeColor(plot, false))).toBe(false)); + test('PlotView with a three color plot', () => + expect(isThreeColor(makePv('a', [withThreeColor(plot, true)]))).toBe(true)); +}); + +describe('Test PlotViewUtil.isMultiImageFits()', () => { + test('multi-HDU file', () => expect(isMultiImageFits(makeMultiHduPv())).toBe(true)); + test('single HDU cube', () => expect(isMultiImageFits(makeWLCubePv())).toBe(true)); + test('single image', () => expect(isMultiImageFits(makeSingleImagePv())).toBe(false)); + test('HiPS is never a multi image FITS', () => { + const pv= makePv('hipsId', [makeHiPSPlot({cubeDepth:3}), makeHiPSPlot({cubeDepth:3})]); + expect(isMultiImageFits(pv)).toBe(false); + }); +}); + +describe('Test PlotViewUtil.isMultiImageFitsWithSameArea()', () => { + const makeAreaPv= (projOptionsAry) => makePv('area', + projOptionsAry.map( (projOptions,idx) => withProjection(makeImagePlot({plotId:'area', idx}), projOptions))); + test('all images cover the same area', () => expect(isMultiImageFitsWithSameArea(makeAreaPv([{},{},{}]))).toBe(true)); + test('single image', () => expect(isMultiImageFitsWithSameArea(makeAreaPv([{}]))).toBe(false)); + test('an image with a different size', () => + expect(isMultiImageFitsWithSameArea(makeAreaPv([{},{w:50}]))).toBe(false)); + test('an image with a different projection', () => + expect(isMultiImageFitsWithSameArea(makeAreaPv([{},{name:'SIN'}]))).toBe(false)); + test('an image covering a different part of the sky', () => + expect(isMultiImageFitsWithSameArea(makeAreaPv([{},{raOffset:1}]))).toBe(false)); +}); + +describe('Test PlotViewUtil.getHduPlotStartIndexes()', () => { + test('undefined PlotView', () => expect(getHduPlotStartIndexes(undefined)).toBeUndefined()); + test('multi-HDU file', () => expect(getHduPlotStartIndexes(makeMultiHduPv())).toEqual([0,1,4])); +}); + +describe('Test PlotViewUtil.getHDUCount()', () => { + test('undefined PlotView', () => expect(getHDUCount(undefined)).toBe(0)); + test('multi-HDU file', () => expect(getHDUCount(makeMultiHduPv())).toBe(3)); + test('single image', () => expect(getHDUCount(makeSingleImagePv())).toBe(1)); +}); + +describe('Test PlotViewUtil.getNumberOfCubesInPV() and hasImageCubes()', () => { + const withCubeCnt= (pv, cubeCnt) => ({...pv, plotViewCtx: {...pv.plotViewCtx, cubeCnt}}); + test('undefined PlotView', () => { + expect(getNumberOfCubesInPV(undefined)).toBe(0); + expect(hasImageCubes(undefined)).toBe(false); + }); + test('image PlotView with a cube', () => { + const pv= withCubeCnt(makeMultiHduPv(), 1); + expect(getNumberOfCubesInPV(pv)).toBe(1); + expect(hasImageCubes(pv)).toBe(true); + }); + test('image PlotView without a cube', () => { + const pv= withCubeCnt(makeSingleImagePv(), 0); + expect(getNumberOfCubesInPV(pv)).toBe(0); + expect(hasImageCubes(pv)).toBe(false); + }); + test('HiPS cube is not an image cube', () => { + const pv= withCubeCnt(makeHiPSPv(makeHiPSPlot({cubeDepth:3})), 1); + expect(getNumberOfCubesInPV(pv)).toBe(0); + expect(hasImageCubes(pv)).toBe(false); + }); +}); + +describe('Test PlotViewUtil.getCubeLength()', () => { + test('undefined', () => expect(getCubeLength(undefined)).toBe(0)); + test('image cube plot', () => + expect(getCubeLength(makeImagePlot({plotId:'a', idx:0, cubePlane:0, cubeLength:4}))).toBe(4)); + test('PlotView with an image cube as the prime plot', () => expect(getCubeLength(makeMultiHduPv(2))).toBe(3)); + test('PlotView with an image that is not a cube as the prime plot', () => + expect(getCubeLength(makeMultiHduPv(4))).toBe(0)); + test('HiPS cube', () => expect(getCubeLength(makeHiPSPlot({cubeDepth:6}))).toBe(6)); + test('HiPS that is not a cube', () => expect(getCubeLength(makeHiPSPlot({cubeDepth:1}))).toBe(0)); +}); + +describe('Test PlotViewUtil.getPrimaryPlotHdu()', () => { + test('undefined PlotView', () => expect(getPrimaryPlotHdu(undefined)).toBe(0)); + test('prime plot in the cube HDU', () => expect(getPrimaryPlotHdu(makeMultiHduPv(2))).toBe(1)); + test('prime plot in the last HDU', () => expect(getPrimaryPlotHdu(makeMultiHduPv(4))).toBe(2)); + test('HiPS', () => expect(getPrimaryPlotHdu(makeHiPSPv(makeHiPSPlot()))).toBe(0)); +}); + +describe('Test PlotViewUtil.convertImageIdxToHDU()', () => { + test('undefined PlotView', () => expect(convertImageIdxToHDU(undefined, 0).hduIdx).toBeUndefined()); + test('single image', () => { + const {hduIdx, cubeIdx}= convertImageIdxToHDU(makeSingleImagePv(), 0); + expect(hduIdx).toBe(0); + expect(cubeIdx).toBeUndefined(); + }); + test('image index past the last plot', () => expect(convertImageIdxToHDU(makeMultiHduPv(), 9).hduIdx).toBe(0)); + test('image in the first HDU', () => { + const {hduIdx, cubeIdx}= convertImageIdxToHDU(makeMultiHduPv(), 0); + expect(hduIdx).toBe(0); + expect(cubeIdx).toBeFalsy(); + }); + test('third plane of the cube HDU', () => { + const {hduIdx, cubeIdx, cube}= convertImageIdxToHDU(makeMultiHduPv(), 3); + expect(hduIdx).toBe(1); + expect(cubeIdx).toBe(2); + expect(cube).toBe(true); + }); + test('image in the last HDU', () => expect(convertImageIdxToHDU(makeMultiHduPv(), 4).hduIdx).toBe(2)); + test('converts back with convertHDUIdxToImageIdx', () => { + const pv= makeMultiHduPv(); + pv.plots.forEach( (p,imageIdx) => { + const {hduIdx, cubeIdx}= convertImageIdxToHDU(pv, imageIdx); + expect(convertHDUIdxToImageIdx(pv, hduIdx, cubeIdx || 0)).toBe(imageIdx); + }); + }); +}); + + + +//============================================================= +//---------- Equality and cloning functions +//============================================================= + +describe('Test PlotViewUtil.isPlotViewsEqual()', () => { + const pv= makeMultiHduPv(1); + test('no keys compares the PlotView object', () => { + expect(isPlotViewsEqual(pv, pv)).toBe(true); + expect(isPlotViewsEqual(pv, {...pv})).toBe(false); + }); + test('copy with the same keys and the same prime plot', () => + expect(isPlotViewsEqual(pv, {...pv}, ['plotId','primeIdx'])).toBe(true)); + test('copy with a different PlotView key', () => + expect(isPlotViewsEqual(pv, {...pv, primeIdx:2}, ['plotId','primeIdx'])).toBe(false)); + test('different prime plot object without plot keys', () => { + const pv2= {...pv, plots: pv.plots.map( (p) => ({...p}))}; + expect(isPlotViewsEqual(pv, pv2, ['plotId'])).toBe(false); + }); + test('different prime plot object with matching plot keys', () => { + const pv2= {...pv, plots: pv.plots.map( (p) => ({...p}))}; + expect(isPlotViewsEqual(pv, pv2, ['plotId'], ['plotImageId'])).toBe(true); + }); + test('different prime plot object with a plot key that does not match', () => { + const pv2= {...pv, plots: pv.plots.map( (p) => ({...p, plotImageId: p.plotImageId+'-x'}))}; + expect(isPlotViewsEqual(pv, pv2, ['plotId'], ['plotImageId'])).toBe(false); + }); + test('only one PlotView has a plot', () => expect(isPlotViewsEqual(pv, {...pv, plots:[]}, ['plotId'])).toBe(false)); + test('image and HiPS', () => { + const hipsPv= {...pv, plots: [makeHiPSPlot({plotId:pv.plotId})], primeIdx:0}; + expect(isPlotViewsEqual(pv, hipsPv, ['plotId'], ['plotId'])).toBe(false); + }); +}); + +describe('Test PlotViewUtil.isPlotViewArysEqual()', () => { + const pvAry1= [makeMultiHduPv(), makeSingleImagePv()]; + test('both empty', () => expect(isPlotViewArysEqual([], undefined)).toBe(true)); + test('only one empty', () => expect(isPlotViewArysEqual(pvAry1, [])).toBe(false)); + test('different lengths', () => expect(isPlotViewArysEqual(pvAry1, [pvAry1[0]], ['plotId'])).toBe(false)); + test('same PlotViews', () => expect(isPlotViewArysEqual(pvAry1, [...pvAry1])).toBe(true)); + test('copies that match on the keys', () => + expect(isPlotViewArysEqual(pvAry1, pvAry1.map( (pv) => ({...pv})), ['plotId'])).toBe(true)); + test('one PlotView that does not match', () => + expect(isPlotViewArysEqual(pvAry1, [pvAry1[0], {...pvAry1[1], plotId:'x'}], ['plotId'])).toBe(false)); +}); + +describe('Test PlotViewUtil.pvEqualExScroll()', () => { + const pv= makeMultiHduPv(1); + test('same object', () => expect(pvEqualExScroll(pv, pv)).toBe(true)); + test('undefined PlotView', () => expect(pvEqualExScroll(pv, undefined)).toBe(false)); + test('only the scroll position is different', () => + expect(pvEqualExScroll(pv, {...pv, scrollX:200, scrollY:300})).toBe(true)); + test('different prime plot index', () => expect(pvEqualExScroll(pv, {...pv, primeIdx:2})).toBe(false)); + test('different rotation', () => expect(pvEqualExScroll(pv, {...pv, rotation:90})).toBe(false)); + test('different plotViewCtx object', () => + expect(pvEqualExScroll(pv, {...pv, plotViewCtx:{...pv.plotViewCtx}})).toBe(false)); + test('new plots array with the same prime plot', () => + expect(pvEqualExScroll(pv, {...pv, plots:[...pv.plots]})).toBe(true)); + test('prime plot with a different zoom factor', () => { + const plots= pv.plots.map( (p,idx) => idx===pv.primeIdx ? {...p, zoomFactor:2} : p); + expect(pvEqualExScroll(pv, {...pv, plots})).toBe(false); + }); + test('different overlay PlotViews', () => + expect(pvEqualExScroll(pv, {...pv, overlayPlotViews:[{plotId:'overlay'}]})).toBe(false)); +}); + +describe('Test PlotViewUtil.clonePvAry()', () => { + test('undefined ref', () => expect(clonePvAry(undefined, 'testId1', {primeIdx:2})).toBeUndefined()); + test('change one PlotView', () => { + const newAry= clonePvAry(visRoot, 'testId-yyyy', {primeIdx:2}); + expect(newAry).not.toBe(pvAry); + expect(newAry[2]).not.toBe(pvAry[2]); + expect(newAry[2].primeIdx).toBe(2); + expect(newAry[2].plotId).toBe('testId-yyyy'); + expect(pvAry[2].primeIdx).toBe(0); + [0,1,3].forEach( (i) => expect(newAry[i]).toBe(pvAry[i])); + }); + test('plotId that does not exist', () => { + const newAry= clonePvAry(pvAry, 'noId', {primeIdx:2}); + expect(newAry).not.toBe(pvAry); + newAry.forEach( (pv,i) => expect(pv).toBe(pvAry[i])); + }); +}); + +describe('Test PlotViewUtil.clonePvAryWithPv()', () => { + test('undefined ref', () => expect(clonePvAryWithPv(undefined, pvAry[0])).toBeUndefined()); + test('replace one PlotView', () => { + const newPv= {...pvAry[3], primeIdx:3}; + const newAry= clonePvAryWithPv(visRoot, newPv); + expect(newAry).not.toBe(pvAry); + expect(newAry[3]).toBe(newPv); + [0,1,2].forEach( (i) => expect(newAry[i]).toBe(pvAry[i])); + }); + test('PlotView that is not in the array', () => { + const newAry= clonePvAryWithPv(pvAry, {plotId:'noId'}); + newAry.forEach( (pv,i) => expect(pv).toBe(pvAry[i])); + }); +}); + +//============================================================= +//---------- Other lookup functions +//============================================================= + +describe('Test PlotViewUtil.getPlotStateAry()', () => { + const state= (name) => ({name}); + test('prime plot only', () => { + const pv= makePv('a', [{...makeImagePlot({plotId:'a', idx:0}), plotState:state('prime')}]); + expect(getPlotStateAry(pv)).toEqual([state('prime')]); + }); + test('prime plot and image overlays', () => { + const pv= { + ...makePv('a', [{...makeImagePlot({plotId:'a', idx:0}), plotState:state('prime')}]), + overlayPlotViews: [{plot:{plotState:state('overlay1')}}, {plot:undefined}, {plot:{plotState:state('overlay2')}}], + }; + expect(getPlotStateAry(pv)).toEqual([state('prime'), state('overlay1'), state('overlay2')]); + }); + test('no prime plot', () => expect(getPlotStateAry(makePv('a', []))).toEqual([])); +}); + +describe('Test PlotViewUtil.isPlotIdInPvNewPlotInfoAry()', () => { + const pvNewPlotInfoAry= [{plotId:'plot1'}, {plotId:'plot2'}]; + test('plotId in the array', () => expect(isPlotIdInPvNewPlotInfoAry(pvNewPlotInfoAry, 'plot2')).toBe(true)); + test('plotId not in the array', () => expect(isPlotIdInPvNewPlotInfoAry(pvNewPlotInfoAry, 'plot3')).toBe(false)); + test('undefined array', () => expect(isPlotIdInPvNewPlotInfoAry(undefined, 'plot1')).toBe(false)); + test('undefined plotId', () => expect(isPlotIdInPvNewPlotInfoAry(pvNewPlotInfoAry, undefined)).toBe(false)); +}); + +describe('Test PlotViewUtil.getNextHiPSPlotId()', () => { + test('each call returns the next numbered id', () => { + const id1= getNextHiPSPlotId(); + const id2= getNextHiPSPlotId(); + expect(id1).toMatch(/^aHiPSId\d+$/); + expect(id2).toMatch(/^aHiPSId\d+$/); + expect(Number(id2.substring('aHiPSId'.length))).toBe(Number(id1.substring('aHiPSId'.length))+1); + }); +}); + + + +//============================================================= +//---------- Wavelength functions +//============================================================= + +/** + * make an image plot with pixel level wavelength and a band width, + * wavelength= 2 + .5*(p + cubePlane) where p is the 1 based FITS pixel x, band width= .1 + * @param {number} cubePlane + */ +const makePixelLevelWLPlot= (cubePlane) => ({ + ...makeImagePlot({plotId:'pixWL', idx:cubePlane, hdu:0, cubePlane, cubeLength:4}), + wlData: {hasPixelLevelCoordInfo: true}, + wlDataAry: [{ + spectralCoords: [ + {algorithm:'LINEAR', N:3, r_j:[0,0,1], pc_3j:[1,0,1], crval:2, cdelt:.5, units:'um'}, + {algorithm:'LINEAR', N:3, r_j:[0,0,1], pc_3j:[0,0,0], crval:.1, cdelt:1, units:'nm'}, + ] + }], +}); + +const hipsWLProps= {data_cube_crval3_arr:'1.5 2.5 3.5', data_cube_bunit3:'nm'}; + +describe('Test PlotViewUtil wavelength parse status functions', () => { + const failedPlot= {...makeImagePlot({plotId:'a', idx:0}), wlData:{failReason:'bad CTYPE3'}}; + test('wavelengthInfoParsedSuccessfully()', () => { + expect(wavelengthInfoParsedSuccessfully(makeWLCubePv().plots[0])).toBe(true); + expect(wavelengthInfoParsedSuccessfully(makeImagePlot({plotId:'a', idx:0}))).toBe(true); + expect(wavelengthInfoParsedSuccessfully(failedPlot)).toBe(false); + }); + test('getWavelengthParseFailReason()', () => { + expect(getWavelengthParseFailReason(failedPlot)).toBe('bad CTYPE3'); + expect(getWavelengthParseFailReason(makeWLCubePv().plots[0])).toBeUndefined(); + expect(getWavelengthParseFailReason(undefined)).toBeUndefined(); + }); +}); + +describe('Test PlotViewUtil.hasPixelLevelWLInfo() and hasPlaneOnlyWLInfo()', () => { + test('undefined plot', () => { + expect(hasPixelLevelWLInfo(undefined)).toBe(false); + expect(hasPlaneOnlyWLInfo(undefined)).toBe(false); + }); + test('image with plane only wavelength', () => { + const plot= makeWLCubePv().plots[0]; + expect(hasPixelLevelWLInfo(plot)).toBe(false); + expect(hasPlaneOnlyWLInfo(plot)).toBe(true); + }); + test('image with pixel level wavelength', () => { + const plot= makePixelLevelWLPlot(0); + expect(hasPixelLevelWLInfo(plot)).toBe(true); + expect(hasPlaneOnlyWLInfo(plot)).toBe(false); + }); + test('HiPS cube with wavelengths is neither', () => { + const plot= makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}); + expect(hasPixelLevelWLInfo(plot)).toBe(false); + expect(hasPlaneOnlyWLInfo(plot)).toBe(false); + }); +}); + +describe('Test PlotViewUtil.getPtSpectralCoords() and getPtWavelength()', () => { + test('undefined plot', () => { + expect(getPtSpectralCoords(undefined, undefined, 0)).toEqual([0,0]); + expect(getPtWavelength(undefined, undefined, 0)).toBe(0); + }); + test('image without wavelength', () => + expect(getPtSpectralCoords(makeImagePlot({plotId:'a', idx:0}), undefined, 0)).toEqual([0,0])); + test('image with plane only wavelength does not need a point', () => { + const plot= makeWLCubePv().plots[0]; + expect(getPtWavelength(plot, undefined, 0)).toBe(2); + expect(getPtWavelength(plot, undefined, 3)).toBe(3.5); + }); + test('image with pixel level wavelength uses the point', () => { + const plot= makePixelLevelWLPlot(1); + expect(getPtSpectralCoords(plot, makeImagePt(4.5, 3.5), 1)).toEqual([5, .1]); + expect(getPtWavelength(plot, makeImagePt(0.5, 3.5), 1)).toBe(3); + }); + test('image with pixel level wavelength without a point', () => + expect(getPtSpectralCoords(makePixelLevelWLPlot(1), undefined, 1)).toEqual([0,0])); + test('HiPS cube with wavelengths', () => { + const plot= makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}); + expect(getPtSpectralCoords(plot, undefined, 1)).toEqual([2.5,0]); + expect(getPtWavelength(plot, undefined, 2)).toBe(3.5); + }); + test('HiPS cube plane past the end', () => + expect(getPtWavelength(makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}), undefined, 5)).toBe(0)); + test('HiPS cube without wavelengths', () => + expect(getPtSpectralCoords(makeHiPSPlot({cubeDepth:3}), undefined, 1)).toEqual([0,0])); +}); + +describe('Test PlotViewUtil.getAllWaveLengthsForCube()', () => { + test('undefined PlotView', () => expect(getAllWaveLengthsForCube(undefined)).toBeUndefined()); + test('image cube with plane only wavelength', () => + expect(getAllWaveLengthsForCube(makeWLCubePv(2))).toEqual([2, 2.5, 3, 3.5])); + test('image cube with pixel level wavelength uses the point', () => { + const pv= makePv('pixWL', [0,1,2,3].map( (i) => makePixelLevelWLPlot(i))); + expect(getAllWaveLengthsForCube(pv, makeImagePt(4.5, 3.5))).toEqual([4.5, 5, 5.5, 6]); + }); + test('image cube without wavelength', () => expect(getAllWaveLengthsForCube(makeMultiHduPv(1))).toBeUndefined()); + test('image that is not a cube', () => expect(getAllWaveLengthsForCube(makeSingleImagePv())).toBeUndefined()); + test('HiPS cube with wavelengths', () => + expect(getAllWaveLengthsForCube(makeHiPSPv(makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps})))) + .toEqual([1.5, 2.5, 3.5])); + test('HiPS cube without wavelengths', () => + expect(getAllWaveLengthsForCube(makeHiPSPv(makeHiPSPlot({cubeDepth:3})))).toBeUndefined()); +}); + +describe('Test PlotViewUtil.getWaveLengthUnits() and getBandWidthUnits()', () => { + test('undefined plot', () => { + expect(getWaveLengthUnits(undefined)).toBe(''); + expect(getBandWidthUnits(undefined)).toBe(''); + }); + test('image without wavelength', () => expect(getWaveLengthUnits(makeImagePlot({plotId:'a', idx:0}))).toBe('')); + test('image with wavelength', () => expect(getWaveLengthUnits(makeWLCubePv().plots[0])).toBe('um')); + test('image with a band width', () => { + const plot= makePixelLevelWLPlot(0); + expect(getWaveLengthUnits(plot)).toBe('um'); + expect(getBandWidthUnits(plot)).toBe('nm'); + }); + test('HiPS cube uses data_cube_bunit3', () => { + const plot= makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}); + expect(getWaveLengthUnits(plot)).toBe('nm'); + expect(getBandWidthUnits(plot)).toBe(''); + }); + test('HiPS cube with a data_cube_bunit3 of null', () => + expect(getWaveLengthUnits(makeHiPSPlot({cubeDepth:3, hipsProperties:{...hipsWLProps, data_cube_bunit3:'null'}}))) + .toBe('')); + test('HiPS cube with data_cube_bunit3 but no wavelengths', () => + expect(getWaveLengthUnits(makeHiPSPlot({cubeDepth:3, hipsProperties:{data_cube_bunit3:'nm'}}))).toBe('')); +}); + +describe('Test PlotViewUtil.getFormattedWaveLengthUnits()', () => { + const MICRON= 'μm'; + test('micron strings use the micron symbol', () => { + ['um', 'micron', 'microns', 'Microns', 'micrometers'].forEach( (u) => + expect(getFormattedWaveLengthUnits(u)).toBe(MICRON)); + }); + test('other units are unchanged', () => { + expect(getFormattedWaveLengthUnits('nm')).toBe('nm'); + expect(getFormattedWaveLengthUnits('')).toBe(''); + }); + test('only replace a whole unit string unless anyPartOfStr', () => { + expect(getFormattedWaveLengthUnits('erg/s/cm2/um')).toBe('erg/s/cm2/um'); + expect(getFormattedWaveLengthUnits('erg/s/cm2/um', true)).toBe(`erg/s/cm2/${MICRON}`); + }); + test('using a plot', () => { + expect(getFormattedWaveLengthUnits(makeWLCubePv().plots[0])).toBe(MICRON); + expect(getFormattedWaveLengthUnits(makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}))).toBe('nm'); + }); +}); + +describe('Test PlotViewUtil.getWaveLengthUnitsPrecision()', () => { + test('undefined', () => expect(getWaveLengthUnitsPrecision(undefined)).toBe(4)); + test('empty units', () => expect(getWaveLengthUnitsPrecision('')).toBe(4)); + test('micron units', () => { + expect(getWaveLengthUnitsPrecision('um')).toBe(3); + expect(getWaveLengthUnitsPrecision('microns')).toBe(3); + }); + test('other units', () => expect(getWaveLengthUnitsPrecision('nm')).toBe(4)); + test('using a plot', () => { + expect(getWaveLengthUnitsPrecision(makeWLCubePv().plots[0])).toBe(3); + expect(getWaveLengthUnitsPrecision(makeHiPSPlot({cubeDepth:3, hipsProperties:hipsWLProps}))).toBe(4); + }); +}); + + + +//============================================================= +//---------- Overlay and position group functions +//============================================================= + +const makeGroupPv= (plotId, plotGroupId, plot) => ({...makePv(plotId, plot ? [plot] : []), plotGroupId}); +const makeGroupImagePlot= (plotId, threeColor=false) => withThreeColor(makeImagePlot({plotId, idx:0}), threeColor); + +/** + * groupA: two images, a three color image, a HiPS, and a PlotView without plots. groupB: one image + */ +const groupPvAry= [ + makeGroupPv('g1', 'groupA', makeGroupImagePlot('g1')), + makeGroupPv('g2', 'groupA', makeGroupImagePlot('g2')), + makeGroupPv('g3Color', 'groupA', makeGroupImagePlot('g3Color', true)), + makeGroupPv('gHiPS', 'groupA', withThreeColor(makeHiPSPlot({plotId:'gHiPS'}), false)), + makeGroupPv('gEmpty', 'groupA'), + makeGroupPv('b1', 'groupB', makeGroupImagePlot('b1')), +]; +const [g1Pv]= groupPvAry; + +const makeGroupVisRoot= ({overlayColorLock=true, positionLock=false, activePlotId='g1'}={}) => ({ + plotViewAry: groupPvAry, + activePlotId, + positionLock, + plotGroupAry: [{plotGroupId:'groupA', overlayColorLock}, {plotGroupId:'groupB', overlayColorLock:false}], +}); + +const ids= (pvList) => pvList.map( (pv) => pv.plotId); + +/** + * call operate with a mock function and return the plotIds it was called with + * @param {Function} operate - called with the mock function + */ +const getOperatedIds= (operate) => { + const opFunc= jest.fn(); + operate(opFunc); + return opFunc.mock.calls.map( ([pv]) => pv.plotId); +}; + +const markChanged= (pv) => ({...pv, changed:true}); +const changedIds= (pvList) => pvList.filter( (pv) => pv.changed).map( (pv) => pv.plotId); + +describe('Test PlotViewUtil.hasOverlayColorLock()', () => { + test('PlotView in a locked group', () => + expect(hasOverlayColorLock(g1Pv, {plotGroupId:'groupA', overlayColorLock:true})).toBe(true)); + test('PlotView in an unlocked group', () => + expect(hasOverlayColorLock(g1Pv, {plotGroupId:'groupA', overlayColorLock:false})).toBe(false)); + test('PlotView in a different group', () => + expect(hasOverlayColorLock(g1Pv, {plotGroupId:'groupB', overlayColorLock:true})).toBe(false)); + test('undefined group', () => expect(hasOverlayColorLock(g1Pv, undefined)).toBe(false)); +}); + +describe('Test PlotViewUtil.getPlotViewIdListByPositionLock()', () => { + test('undefined plotId', () => expect(getPlotViewIdListByPositionLock(makeGroupVisRoot(), undefined)).toEqual([])); + test('without position lock, using a plotId', () => + expect(getPlotViewIdListByPositionLock(makeGroupVisRoot(), 'g2')).toEqual(['g2'])); + test('without position lock, using a PlotView', () => + expect(getPlotViewIdListByPositionLock(makeGroupVisRoot(), g1Pv)).toEqual(['g1'])); + test('with position lock, every PlotView with plots in any group', () => + expect(getPlotViewIdListByPositionLock(makeGroupVisRoot({positionLock:true}), 'g1')) + .toEqual(['g1', 'g2', 'g3Color', 'gHiPS', 'b1'])); +}); + +describe('Test PlotViewUtil.getPlotViewIdListInOverlayGroup()', () => { + test('undefined plotId', () => expect(getPlotViewIdListInOverlayGroup(makeGroupVisRoot(), undefined)).toEqual([])); + test('plotId that does not exist', () => expect(getPlotViewIdListInOverlayGroup(makeGroupVisRoot(), 'noId')).toEqual([])); + test('locked group returns every PlotView in the group', () => + expect(getPlotViewIdListInOverlayGroup(makeGroupVisRoot(), 'g2')) + .toEqual(['g1', 'g2', 'g3Color', 'gHiPS', 'gEmpty'])); + test('unlocked group returns only the PlotView', () => + expect(getPlotViewIdListInOverlayGroup(makeGroupVisRoot({overlayColorLock:false}), g1Pv)).toEqual(['g1'])); + test('PlotView in an unlocked group', () => + expect(getPlotViewIdListInOverlayGroup(makeGroupVisRoot(), 'b1')).toEqual(['b1'])); +}); + +describe('Test PlotViewUtil.getAllPlotViewIdByOverlayLock()', () => { + const vr= makeGroupVisRoot(); + test('undefined plotId', () => expect(getAllPlotViewIdByOverlayLock(vr, undefined)).toEqual([])); + test('plotId that does not exist', () => expect(getAllPlotViewIdByOverlayLock(vr, 'noId')).toEqual([])); + test('unlocked group returns only the PlotView', () => + expect(getAllPlotViewIdByOverlayLock(makeGroupVisRoot({overlayColorLock:false}), 'g1')).toEqual(['g1'])); + test('locked group returns every PlotView', () => + expect(getAllPlotViewIdByOverlayLock(vr, 'g1')).toEqual(ids(groupPvAry))); + test('locked group, only PlotViews with plots', () => + expect(getAllPlotViewIdByOverlayLock(vr, g1Pv, true)).toEqual(['g1', 'g2', 'g3Color', 'gHiPS', 'b1'])); + test('locked group, only PlotViews with plots of the same type', () => + expect(getAllPlotViewIdByOverlayLock(vr, g1Pv, true, true)).toEqual(['g1', 'g2', 'g3Color', 'b1'])); +}); + +describe('Test PlotViewUtil.plotInActiveGroup()', () => { + test('no active plot', () => + expect(plotInActiveGroup(makeGroupVisRoot({activePlotId:null}), 'g1')).toBe(false)); + test('undefined visRoot', () => expect(plotInActiveGroup(undefined, 'g1')).toBe(false)); + test('plot in the active group', () => expect(plotInActiveGroup(makeGroupVisRoot(), 'gHiPS')).toBe(true)); + test('plot in a different group', () => expect(plotInActiveGroup(makeGroupVisRoot(), 'b1')).toBe(false)); +}); + +describe('Test PlotViewUtil.operateOnOthersInOverlayColorGroup()', () => { + const vr= makeGroupVisRoot(); + test('undefined source PlotView', () => + expect(getOperatedIds( (f) => operateOnOthersInOverlayColorGroup(vr, undefined, f))).toEqual([])); + test('locked group, others in the group of the same type', () => + expect(getOperatedIds( (f) => operateOnOthersInOverlayColorGroup(vr, g1Pv, f))) + .toEqual(['g2', 'g3Color', 'gEmpty'])); + test('ignore three color', () => + expect(getOperatedIds( (f) => operateOnOthersInOverlayColorGroup(vr, g1Pv, f, true))).toEqual(['g2', 'gEmpty'])); + test('any plot type', () => + expect(getOperatedIds( (f) => operateOnOthersInOverlayColorGroup(vr, g1Pv, f, false, true))) + .toEqual(['g2', 'g3Color', 'gHiPS', 'gEmpty'])); + test('unlocked group', () => + expect(getOperatedIds( (f) => + operateOnOthersInOverlayColorGroup(makeGroupVisRoot({overlayColorLock:false}), g1Pv, f))).toEqual([])); +}); + +describe('Test PlotViewUtil.operateOnOthersInPositionGroup()', () => { + const vr= makeGroupVisRoot({positionLock:true}); + test('without position lock', () => + expect(getOperatedIds( (f) => operateOnOthersInPositionGroup(makeGroupVisRoot(), g1Pv, f))).toEqual([])); + test('with position lock, others of the same type in any group', () => + expect(getOperatedIds( (f) => operateOnOthersInPositionGroup(vr, g1Pv, f))) + .toEqual(['g2', 'g3Color', 'gEmpty', 'b1'])); + test('ignore three color', () => + expect(getOperatedIds( (f) => operateOnOthersInPositionGroup(vr, g1Pv, f, true))).toEqual(['g2', 'gEmpty', 'b1'])); + test('any plot type', () => + expect(getOperatedIds( (f) => operateOnOthersInPositionGroup(vr, g1Pv, f, false, true))) + .toEqual(['g2', 'g3Color', 'gHiPS', 'gEmpty', 'b1'])); +}); + +describe('Test PlotViewUtil.matchPlotViewByPositionGroup()', () => { + test('without position lock returns the same array', () => + expect(matchPlotViewByPositionGroup(makeGroupVisRoot(), g1Pv, groupPvAry, false, markChanged)).toBe(groupPvAry)); + test('with position lock, changes others of the same type', () => { + const newAry= matchPlotViewByPositionGroup(makeGroupVisRoot({positionLock:true}), g1Pv, groupPvAry, false, markChanged); + expect(changedIds(newAry)).toEqual(['g2', 'g3Color', 'gEmpty', 'b1']); + expect(newAry[0]).toBe(g1Pv); + }); + test('with position lock, any plot type', () => { + const newAry= matchPlotViewByPositionGroup(makeGroupVisRoot({positionLock:true}), g1Pv, groupPvAry, true, markChanged); + expect(changedIds(newAry)).toEqual(['g2', 'g3Color', 'gHiPS', 'gEmpty', 'b1']); + }); +}); + +describe('Test PlotViewUtil.applyToOnePvOrAll()', () => { + test('plotId that does not exist', () => + expect(applyToOnePvOrAll(true, groupPvAry, 'noId', false, markChanged)).toBeUndefined()); + test('only the PlotView', () => + expect(changedIds(applyToOnePvOrAll(false, groupPvAry, 'g2', false, markChanged))).toEqual(['g2'])); + test('all PlotViews of the same type', () => + expect(changedIds(applyToOnePvOrAll(true, groupPvAry, 'g2', false, markChanged))) + .toEqual(['g1', 'g2', 'g3Color', 'gEmpty', 'b1'])); + test('all PlotViews of any type', () => + expect(changedIds(applyToOnePvOrAll(true, groupPvAry, 'g2', true, markChanged))).toEqual(ids(groupPvAry))); +}); + +describe('Test PlotViewUtil.applyToOnePvOrOverlayGroup()', () => { + const [lockedA]= makeGroupVisRoot().plotGroupAry; + const unlockedA= {plotGroupId:'groupA', overlayColorLock:false}; + test('plotId that does not exist', () => + expect(applyToOnePvOrOverlayGroup(groupPvAry, 'noId', lockedA, false, markChanged)).toBeUndefined()); + test('no group, only the PlotView', () => + expect(changedIds(applyToOnePvOrOverlayGroup(groupPvAry, 'g2', undefined, false, markChanged))).toEqual(['g2'])); + test('unlocked group, only the PlotView', () => + expect(changedIds(applyToOnePvOrOverlayGroup(groupPvAry, 'g2', unlockedA, false, markChanged))).toEqual(['g2'])); + test('locked group, the group PlotViews of the same type', () => + expect(changedIds(applyToOnePvOrOverlayGroup(groupPvAry, 'g2', lockedA, false, markChanged))) + .toEqual(['g1', 'g2', 'g3Color', 'gEmpty'])); + test('locked group, the group PlotViews of any type', () => + expect(changedIds(applyToOnePvOrOverlayGroup(groupPvAry, 'g2', lockedA, true, markChanged))) + .toEqual(['g1', 'g2', 'g3Color', 'gHiPS', 'gEmpty'])); +}); + + +//============================================================= +//---------- Overlay and draw layer functions +//============================================================= + +const dlAry= [ + {drawLayerId:'dl1', drawLayerTypeId:'typeA', displayGroupId:'dg1', plotIdAry:['p1','p2'], visiblePlotIdAry:['p1'], + title:'Layer 1'}, + {drawLayerId:'dl2', drawLayerTypeId:'typeB', displayGroupId:'dg2', plotIdAry:['p2'], visiblePlotIdAry:['p2'], + title:{p2:'Layer 2 on p2'}, autoFormatTitle:true}, + {drawLayerId:'dl3', drawLayerTypeId:'typeA', displayGroupId:'dg1', plotIdAry:['p3'], visiblePlotIdAry:[], + title:'Layer 3', autoFormatTitle:true}, +]; +const dlRoot= {drawLayerAry: dlAry}; +const dlIds= (list) => list.map( (dl) => dl.drawLayerId); + +describe('Test PlotViewUtil.getAllDrawLayersForPlot()', () => { + test('undefined ref', () => expect(getAllDrawLayersForPlot(undefined, 'p1')).toBeUndefined()); + test('undefined plotId', () => expect(getAllDrawLayersForPlot(dlRoot, undefined)).toEqual([])); + test('no draw layers', () => expect(getAllDrawLayersForPlot([], 'p1')).toEqual([])); + test('attached draw layers', () => expect(dlIds(getAllDrawLayersForPlot(dlRoot, 'p2'))).toEqual(['dl1', 'dl2'])); + test('visible draw layers', () => expect(dlIds(getAllDrawLayersForPlot(dlAry, 'p2', true))).toEqual(['dl2'])); + test('attached but not visible', () => expect(getAllDrawLayersForPlot(dlAry, 'p3', true)).toEqual([])); +}); + +describe('Test PlotViewUtil.getConnectedPlotsIds()', () => { + test('undefined ref', () => expect(getConnectedPlotsIds(undefined, 'dl1')).toEqual([])); + test('draw layer that does not exist', () => expect(getConnectedPlotsIds(dlRoot, 'noDl')).toEqual([])); + test('attached plots', () => expect(getConnectedPlotsIds(dlRoot, 'dl1')).toEqual(['p1', 'p2'])); + test('visible plots', () => expect(getConnectedPlotsIds(dlAry, 'dl1', true)).toEqual(['p1'])); +}); + +describe('Test PlotViewUtil draw layer lookup functions', () => { + test('getDrawLayerByType() returns the first of the type', () => { + expect(getDrawLayerByType(dlRoot, 'typeA')).toBe(dlAry[0]); + expect(getDrawLayerByType(dlAry, 'typeB')).toBe(dlAry[1]); + expect(getDrawLayerByType(dlAry, 'typeC')).toBeUndefined(); + expect(getDrawLayerByType(undefined, 'typeA')).toBeUndefined(); + }); + test('getDrawLayersByType() returns all of the type', () => { + expect(dlIds(getDrawLayersByType(dlRoot, 'typeA'))).toEqual(['dl1', 'dl3']); + expect(getDrawLayersByType(dlAry, 'typeC')).toEqual([]); + expect(getDrawLayersByType(undefined, 'typeA')).toBeUndefined(); + }); + test('getDrawLayerById()', () => { + expect(getDrawLayerById(dlRoot, 'dl2')).toBe(dlAry[1]); + expect(getDrawLayerById(dlAry, 'noDl')).toBeUndefined(); + expect(getDrawLayerById(undefined, 'dl2')).toBeUndefined(); + }); + test('getDrawLayersByDisplayGroup() returns the first draw layer in the display group', () => { + expect(getDrawLayersByDisplayGroup(dlRoot, 'dg1')).toBe(dlAry[0]); + expect(getDrawLayersByDisplayGroup(dlAry, 'noGroup')).toBeUndefined(); + }); +}); + +describe('Test PlotViewUtil.isDrawLayerVisible()', () => { + test('undefined draw layer', () => expect(isDrawLayerVisible(undefined, 'p1')).toBe(false)); + test('visible plot', () => expect(isDrawLayerVisible(dlAry[0], 'p1')).toBe(true)); + test('attached but not visible plot', () => expect(isDrawLayerVisible(dlAry[0], 'p2')).toBe(false)); +}); + +describe('Test PlotViewUtil.getLayerTitle()', () => { + test('title without auto format', () => expect(getLayerTitle('p1', dlAry[0])).toBe('Layer 1')); + test('auto format title by plotId', () => expect(getLayerTitle('p2', dlAry[1])).toBe('Layer 2 on p2')); + test('auto format title that is a string', () => expect(getLayerTitle('p3', dlAry[2])).toBe('Layer 3')); +}); + +describe('Test PlotViewUtil.getOverlayById() and getOverlayByPvAndId()', () => { + const overlay1= {imageOverlayId:'overlay1'}; + const overlay2= {imageOverlayId:'overlay2'}; + const overlayPv= {...makePv('withOverlays', [makeImagePlot({plotId:'withOverlays', idx:0})]), + overlayPlotViews:[overlay1, overlay2]}; + test('getOverlayById()', () => { + expect(getOverlayById(overlayPv, 'overlay2')).toBe(overlay2); + expect(getOverlayById(overlayPv, 'noOverlay')).toBeUndefined(); + expect(getOverlayById(undefined, 'overlay1')).toBeUndefined(); + }); + test('getOverlayByPvAndId()', () => { + const ary= [makeSingleImagePv(), overlayPv]; + expect(getOverlayByPvAndId(ary, 'withOverlays', 'overlay1')).toBe(overlay1); + expect(getOverlayByPvAndId(ary, 'single', 'overlay1')).toBeUndefined(); + expect(getOverlayByPvAndId(ary, 'noId', 'overlay1')).toBeUndefined(); + }); +}); + +describe('Test PlotViewUtil.getAllCanvasLayersForPlot()', () => { + const addCanvas= (id) => { + const c= document.createElement('canvas'); + if (id) c.id= id; + document.body.appendChild(c); + }; + afterEach(() => { document.body.innerHTML= ''; }); + + test('image layers first then draw layers, each in index order', () => { + ['dl-1.1-plotX', 'image-1-plotX', 'dl-0.2-plotX', 'image-0-otherPlot', undefined, 'image-0-plotX', 'dl-0.1-plotX'] + .forEach(addCanvas); + expect(getAllCanvasLayersForPlot('plotX').map( (c) => c.id)) + .toEqual(['image-0-plotX', 'image-1-plotX', 'dl-0.1-plotX', 'dl-0.2-plotX', 'dl-1.1-plotX']); + }); + test('no canvases for the plot', () => { + addCanvas('image-0-otherPlot'); + expect(getAllCanvasLayersForPlot('plotX')).toEqual([]); + }); +}); + + + +//============================================================= +//---------- Geometry and view functions +//============================================================= + +describe('Test PlotViewUtil.getFoV()', () => { + const DEV_PT_TYPE= makeDevicePt(0,0).type; + const VIEW_WIDTH= 500; + + /** + * Stub CysConverter.make so world coordinates are along the equator, then distance in degrees is the + * difference in RA. A device x is devScale/zoomFactor degrees per pixel, an image x is .001 degrees per pixel. + * @param {Object} p + * @param {number} [p.devScale] + * @param {boolean} [p.deviceFails] - device points are outside the projection + * @param {boolean} [p.imageFails] - image points are outside the projection + */ + const stubConverter= ({devScale=.01, deviceFails=false, imageFails=false}={}) => { + jest.spyOn(CysConverter, 'make').mockImplementation( () => ({ + zoomFactor: 1, + getWorldCoords(pt) { + if (pt.type===DEV_PT_TYPE) { + return deviceFails ? null : makeWorldPt(10 + pt.x*devScale/this.zoomFactor, 0, CoordinateSys.EQ_J2000); + } + return imageFails ? null : makeWorldPt(10 + pt.x*.001, 0, CoordinateSys.EQ_J2000); + }, + })); + }; + afterEach(() => jest.restoreAllMocks()); + + const makeFoVPlot= ({wrapping=false, viewDim={width:VIEW_WIDTH, height:400}}={}) => ({ + ...makeImagePlot({plotId:'fov', idx:0}), + viewDim, + dataWidth: 1000, + screenSize: {width: 2000, height: 1600}, + projection: {isWrappingProjection: () => wrapping}, + }); + const makeFoVHiPSPlot= (maptype) => ({ + ...makeHiPSPlot({plotId:'fovHiPS'}), + viewDim: {width:VIEW_WIDTH, height:400}, + projection: {isWrappingProjection: () => false, header: {maptype}}, + }); + + test('undefined', () => expect(getFoV(undefined)).toBe(false)); + test('no view size', () => { + stubConverter(); + expect(getFoV(makeFoVPlot({viewDim:{width:0, height:0}}))).toBe(false); + }); + test('distance across the view', () => { + stubConverter(); + expect(getFoV(makeFoVPlot())).toBeCloseTo((VIEW_WIDTH-2)*.01, 6); + }); + test('using a PlotView', () => { + stubConverter(); + expect(getFoV(makePv('fov', [makeFoVPlot()]))).toBeCloseTo((VIEW_WIDTH-2)*.01, 6); + }); + test('alternate zoom factor', () => { + stubConverter(); + expect(getFoV(makeFoVPlot(), 2)).toBeCloseTo((VIEW_WIDTH-2)*.01/2, 6); + }); + test('all sky image doubles the distance from the edge to the center', () => { + stubConverter(); + expect(getFoV(makeFoVPlot({wrapping:true}))).toBeCloseTo((VIEW_WIDTH/2-1)*.01*2, 6); + }); + test('outside the projection, all sky image', () => { + stubConverter({deviceFails:true}); + expect(getFoV(makeFoVPlot({wrapping:true}))).toBe(360); + }); + test('outside the projection, HiPS', () => { + stubConverter({deviceFails:true}); + expect(getFoV(makeFoVHiPSPlot(undefined))).toBe(180); + }); + test('outside the projection, Aitoff HiPS', () => { + stubConverter({deviceFails:true}); + expect(getFoV(makeFoVHiPSPlot(HIPS_AITOFF))).toBe(360); + }); + test('outside the projection, image uses the image width scaled to the view', () => { + stubConverter({deviceFails:true}); + // image width is 1000*.001= 1 degree, the view shows 500 of 2000 screen pixels + expect(getFoV(makeFoVPlot())).toBeCloseTo(VIEW_WIDTH/2000, 6); + }); + test('outside the projection, image with an alternate zoom factor', () => { + stubConverter({deviceFails:true}); + // the view shows 500 of 1000*4 screen pixels + expect(getFoV(makeFoVPlot(), 4)).toBeCloseTo(VIEW_WIDTH/4000, 6); + }); + test('outside the projection, image corners also outside', () => { + stubConverter({deviceFails:true, imageFails:true}); + expect(getFoV(makeFoVPlot())).toBe(false); + }); +}); + + +describe('Test PlotViewUtil.getCenterOfProjection()', () => { + test('undefined plot', () => expect(getCenterOfProjection(undefined)).toBeUndefined()); + test('center is CRVAL1, CRVAL2 in the image coordinate system', () => { + const plot= {...makeImagePlot({plotId:'a', idx:0}), imageCoordSys: CoordinateSys.GALACTIC, + projection: {header: {crval1: 120.5, crval2: -30.25}}}; + const wp= getCenterOfProjection(plot); + expect(wp.x).toBe(120.5); + expect(wp.y).toBe(-30.25); + expect(wp.cSys).toBe(CoordinateSys.GALACTIC); + }); +}); + +describe('Test PlotViewUtil.findCurrentCenterPoint()', () => { + // view is 400x300, so the device center is (200,150). With no rotation the screen point is device+scroll, + // then image x= screenX/zoom, image y= dataHeight - screenY/zoom + const viewDim= {width:400, height:300}; + const ZOOM= 2; + const DATA_HEIGHT= 800; + + const makeCenterPv= ({scrollX=100, scrollY=50, rotation=0}={}) => { + const plot= { + ...makeImagePlot({plotId:'center', idx:0}), + dataWidth: 1000, dataHeight: DATA_HEIGHT, zoomFactor: ZOOM, + affTrans: makeTransform(0, 0, scrollX, scrollY, rotation, false, false, viewDim), + }; + return {...makePv('center', [plot]), viewDim, scrollX, scrollY, rotation, flipX:false, flipY:false}; + }; + const expectImagePt= (pt, x, y) => { + expect(pt.x).toBeCloseTo(x, 6); + expect(pt.y).toBeCloseTo(y, 6); + }; + + test('PlotView without a plot', () => expect(findCurrentCenterPoint(makePv('empty', []))).toBeUndefined()); + test('uses the PlotView scroll', () => + expectImagePt(findCurrentCenterPoint(makeCenterPv()), (200+100)/ZOOM, DATA_HEIGHT-(150+50)/ZOOM)); + test('passed scroll overrides the PlotView scroll', () => + expectImagePt(findCurrentCenterPoint(makeCenterPv(), 300, 250), (200+300)/ZOOM, DATA_HEIGHT-(150+250)/ZOOM)); + test('PlotView without a scroll uses 0,0', () => { + const pv= {...makeCenterPv(), scrollX:undefined, scrollY:undefined}; + expectImagePt(findCurrentCenterPoint(pv), 200/ZOOM, DATA_HEIGHT-150/ZOOM); + }); + test('only one passed scroll value uses the PlotView scroll', () => + expectImagePt(findCurrentCenterPoint(makeCenterPv(), 300), (200+100)/ZOOM, DATA_HEIGHT-(150+50)/ZOOM)); + test('rotation is around the view center so the center point does not move', () => + expectImagePt(findCurrentCenterPoint(makeCenterPv({rotation:90})), (200+100)/ZOOM, DATA_HEIGHT-(150+50)/ZOOM)); +}); + +describe('Test PlotViewUtil.isImageExpanded()', () => { + test('expanded modes', () => { + expect(isImageExpanded(true)).toBe(true); + expect(isImageExpanded(ExpandType.GRID)).toBe(true); + expect(isImageExpanded(ExpandType.SINGLE)).toBe(true); + }); + test('not expanded', () => { + expect(isImageExpanded(ExpandType.COLLAPSE)).toBe(false); + expect(isImageExpanded(false)).toBe(false); + expect(isImageExpanded(undefined)).toBe(false); + }); +}); + + +describe('Test PlotViewUtil.getCorners()', () => { + /** + * Stub CysConverter.make so an image point converts to a world point with the same x and y + * @param {Function} [failPt] - return true for an image point that is outside the projection + */ + const stubConverter= (failPt= () => false) => { + jest.spyOn(CysConverter, 'make').mockImplementation( () => ({ + getWorldCoords: (pt) => failPt(pt) ? null : makeWorldPt(pt.x, pt.y, CoordinateSys.EQ_J2000), + })); + }; + afterEach(() => jest.restoreAllMocks()); + const plot= {...makeImagePlot({plotId:'corners', idx:0}), dataWidth:100, dataHeight:50}; + + test('undefined plot', () => expect(getCorners(undefined)).toBeNull()); + test('corners in order: bottom left, bottom right, top right, top left', () => { + stubConverter(); + expect(getCorners(plot).map( (wp) => [wp.x, wp.y])).toEqual([[0,0], [100,0], [100,50], [0,50]]); + }); + test('a corner outside the projection', () => { + stubConverter( (pt) => pt.x===100 && pt.y===50); + expect(getCorners(plot)).toBeNull(); + }); +}); + +describe('Test PlotViewUtil rotation matching', () => { + /** + * Stub the WebPlotAnalysis functions using values set on the test plot: + * angle- the rotation angle of the image, north- the image is north up, eastLeft- east is left of north + */ + beforeEach(() => { + jest.spyOn(WebPlotAnalysis, 'getRotationAngle').mockImplementation( (plot) => plot?.angle ?? 0); + jest.spyOn(WebPlotAnalysis, 'isPlotNorth').mockImplementation( (plot) => Boolean(plot?.north)); + jest.spyOn(WebPlotAnalysis, 'getCenterPtOfPlot').mockImplementation( () => makeWorldPt(10, 20)); + jest.spyOn(WebPlotAnalysis, 'isEastLeftOfNorth').mockImplementation( (plot) => plot?.eastLeft ?? true); + jest.spyOn(WebPlotAnalysis, 'isCsysDirMatching') + .mockImplementation( (p1,p2) => (p1?.eastLeft ?? true) === (p2?.eastLeft ?? true)); + }); + afterEach(() => jest.restoreAllMocks()); + + const makeRotPv= ({angle=0, rotation=0, north=false, eastLeft=true, flipY=false, rotateNorthLock=false}={}) => { + const pv= makePv('rot', [{...makeImagePlot({plotId:'rot', idx:0}), angle, north, eastLeft}]); + return {...pv, rotation, flipY, plotViewCtx:{...pv.plotViewCtx, rotateNorthLock}}; + }; + + describe('isRotationMatching()', () => { + test('PlotView without a plot', () => expect(isRotationMatching(makePv('empty', []), makeRotPv())).toBe(false)); + test('both locked north up', () => + expect(isRotationMatching(makeRotPv({angle:30, rotateNorthLock:true}), + makeRotPv({angle:100, rotateNorthLock:true}))).toBe(true)); + test('both north up and not rotated', () => + expect(isRotationMatching(makeRotPv({north:true}), makeRotPv({north:true}))).toBe(true)); + test('image angle plus PlotView rotation is the same', () => + expect(isRotationMatching(makeRotPv({angle:30, rotation:10}), makeRotPv({angle:20, rotation:20}))).toBe(true)); + test('within .9 degrees', () => + expect(isRotationMatching(makeRotPv({angle:40}), makeRotPv({angle:40.5}))).toBe(true)); + test('more than .9 degrees apart', () => + expect(isRotationMatching(makeRotPv({angle:40}), makeRotPv({angle:42}))).toBe(false)); + test('north up but rotated compares the angles', () => + expect(isRotationMatching(makeRotPv({north:true, rotation:90}), makeRotPv({north:true}))).toBe(false)); + test('angles that differ by 360', () => + expect(isRotationMatching(makeRotPv({angle:10}), makeRotPv({angle:10, rotation:360}))).toBe(true)); + test('angles on either side of 0', () => + expect(isRotationMatching(makeRotPv({angle:359.8}), makeRotPv({angle:.1}))).toBe(true)); + test('rotated north sums to 360', () => + expect(isRotationMatching(makeRotPv({angle:10, rotation:350}), makeRotPv({angle:359.9}))).toBe(true)); + test('east right of north subtracts the image angle', () => + expect(isRotationMatching(makeRotPv({angle:30, rotation:45, eastLeft:false}), + makeRotPv({angle:10, rotation:25, eastLeft:false}))).toBe(true)); + test('flipped PlotView subtracts the image angle', () => + expect(isRotationMatching(makeRotPv({angle:30, rotation:45, flipY:true}), + makeRotPv({angle:10, rotation:5}))).toBe(true)); + }); + + describe('getMatchingRotationAngle()', () => { + test('PlotView without a plot', () => + expect(getMatchingRotationAngle(makeRotPv(), makePv('empty', []))).toBe(0)); + test('same angle and no rotation', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30}), makeRotPv({angle:30}))).toBeCloseTo(0, 6)); + test('different image angles', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30}), makeRotPv({angle:10}))).toBeCloseTo(340, 6)); + test('master PlotView is rotated', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30, rotation:45}), makeRotPv({angle:10}))).toBeCloseTo(295, 6)); + test('both have east right of north', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30, rotation:45, eastLeft:false}), + makeRotPv({angle:10, eastLeft:false}))).toBeCloseTo(25, 6)); + test('east is on different sides of north', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30, rotation:45}), + makeRotPv({angle:10, eastLeft:false}))).toBeCloseTo(85, 6)); + test('only the master PlotView is flipped', () => + expect(getMatchingRotationAngle(makeRotPv({angle:30, rotation:45, flipY:true}), + makeRotPv({angle:10}))).toBeCloseTo(355, 6)); + }); +}); + + +describe('Test PlotViewUtil.isDefaultCoverageActive()', () => { + const mockActivePlot= (activePlotId) => { + const plotViewAry= [DEFAULT_COVERAGE_PLOT_ID, 'otherPlot'].map( (plotId) => makeMockPv(plotId, 'aTestGroup', 1)); + jest.spyOn(VisStoreRoots, 'visRoot').mockReturnValue({plotViewAry, activePlotId}); + }; + const makeMvr= (mounted) => [{viewerId:'otherViewer', mounted:true}, {viewerId:DEFAULT_COVERAGE_VIEWER_ID, mounted}]; + afterEach(() => jest.restoreAllMocks()); + + test('coverage plot is active and the coverage viewer is mounted', () => { + mockActivePlot(DEFAULT_COVERAGE_PLOT_ID); + expect(isDefaultCoverageActive(makeMvr(true))).toBe(true); + }); + test('coverage viewer is not mounted', () => { + mockActivePlot(DEFAULT_COVERAGE_PLOT_ID); + expect(isDefaultCoverageActive(makeMvr(false))).toBe(false); + }); + test('no coverage viewer', () => { + mockActivePlot(DEFAULT_COVERAGE_PLOT_ID); + expect(isDefaultCoverageActive([{viewerId:'otherViewer', mounted:true}])).toBe(false); + expect(isDefaultCoverageActive(undefined)).toBe(false); + }); + test('another plot is active', () => { + mockActivePlot('otherPlot'); + expect(isDefaultCoverageActive(makeMvr(true))).toBe(false); + }); +}); + +describe('Test PlotViewUtil.canConvertBetweenHipsAndFits()', () => { + const withConversion= (hipsImageConversion) => { + const pv= makeSingleImagePv(); + return {...pv, plotViewCtx:{...pv.plotViewCtx, hipsImageConversion}}; + }; + test('undefined PlotView', () => expect(canConvertBetweenHipsAndFits(undefined)).toBe(false)); + test('PlotView without HiPS image conversion', () => expect(canConvertBetweenHipsAndFits(makeSingleImagePv())).toBe(false)); + test('HiPS and image request roots', () => + expect(canConvertBetweenHipsAndFits(withConversion({hipsRequestRoot:{}, imageRequestRoot:{}}))).toBe(true)); + test('missing the image request root', () => + expect(canConvertBetweenHipsAndFits(withConversion({hipsRequestRoot:{}}))).toBe(false)); + test('missing the HiPS request root', () => + expect(canConvertBetweenHipsAndFits(withConversion({imageRequestRoot:{}}))).toBe(false)); +}); + + + +//============================================================= +//---------- Store lookup functions +//============================================================= + +describe('Test PlotViewUtil.refreshP() and currentP()', () => { + // the store holds the visRoot defined at the top of this file: testId1 has 3 plots with primeIdx 1 + beforeEach(() => jest.spyOn(VisStoreRoots, 'visRoot').mockReturnValue(visRoot)); + afterEach(() => jest.restoreAllMocks()); + + describe('refreshP()', () => { + test('undefined', () => expect(refreshP(undefined)).toBeUndefined()); + test('object without a plotId', () => expect(refreshP({})).toBeUndefined()); + test('old copy of a PlotView returns the PlotView in the store', () => + expect(refreshP({...pvAry[0]})).toBe(pvAry[0])); + test('old copy of a plot returns the prime plot in the store', () => + expect(refreshP({...pvAry[0].plots[1]})).toBe(pvAry[0].plots[1])); + test('a plot that is not the prime plot still returns the prime plot', () => + expect(refreshP(pvAry[0].plots[2])).toBe(pvAry[0].plots[1])); + test('PlotView that is no longer in the store', () => + expect(refreshP({...pvAry[0], plotId:'noId'})).toBeUndefined()); + test('object with a plotId that is not a PlotView or a plot', () => + expect(refreshP({plotId:'testId1'})).toBeUndefined()); + }); + + describe('currentP()', () => { + test('no plotId returns the active PlotView', () => { + const {pv, plot, plotId, active}= currentP(); + expect(pv).toBe(pvAry[1]); + expect(plot).toBe(pvAry[1].plots[0]); + expect(plotId).toBe(ACTIVE_ID); + expect(active).toBe(true); + }); + test('plotId of a PlotView that is not active', () => { + const {pv, plot, plotId, active}= currentP('testId1'); + expect(pv).toBe(pvAry[0]); + expect(plot).toBe(pvAry[0].plots[1]); + expect(plotId).toBe('testId1'); + expect(active).toBe(false); + }); + test('plotId of the active PlotView', () => expect(currentP(ACTIVE_ID).active).toBe(true)); + test('plotId that does not exist', () => { + const {pv, plot, plotId, active}= currentP('noId'); + expect(pv).toBeUndefined(); + expect(plot).toBeUndefined(); + expect(plotId).toBeUndefined(); + expect(active).toBe(false); + }); + }); +}); diff --git a/src/firefly/js/visualize/reducer/HandlePlotChange.js b/src/firefly/js/visualize/reducer/HandlePlotChange.js index b7913e495e..8b0ef79409 100644 --- a/src/firefly/js/visualize/reducer/HandlePlotChange.js +++ b/src/firefly/js/visualize/reducer/HandlePlotChange.js @@ -11,7 +11,7 @@ import { OVERLAY_PLOT_CHANGE_ATTRIBUTES, PLOT_PROGRESS_UPDATE, POSITION_LOCKING, PROCESS_SCROLL, RECENTER, REQUEST_LOCAL_DATA, ROTATE, STRETCH_CHANGE, UPDATE_VIEW_SIZE, ZOOM_HIPS, ZOOM_IMAGE, ZOOM_LOCKING } from '../VisConst'; -import {getRotationAngle, isCsysDirMatching, isEastLeftOfNorth, isPlotNorth} from '../WebPlotAnalysis'; +import {getRotationAngle, isEastLeftOfNorth, isPlotNorth} from '../WebPlotAnalysis'; import {RotateType} from '../PlotState'; import { replacePlotView, replacePrimaryPlot, changePrimePlot, updatePlotViewScrollXY, @@ -33,7 +33,7 @@ import { matchPlotViewByPositionGroup, getPlotViewIdxById, getPlotGroupIdxById, findPlotGroup, getPlotViewById, findCurrentCenterPoint, getCenterOfProjection, isRotationMatching, hasWCSProjection, isThreeColor, getHDU, getMatchingRotationAngle, isCube, - convertImageIdxToHDU, hasLocalStretchByteData + convertImageIdxToHDU, hasLocalStretchByteData, isEastDisplayedLeft } from '../PlotViewUtil.js'; import Point, {parseAnyPt, makeImagePt, makeWorldPt, makeDevicePt} from '../Point.js'; import {updateTransform} from '../PlotTransformUtils.js'; @@ -452,14 +452,8 @@ function rotatePvToMatch(pv, masterPv, rotateNorthLock) { const plot= primePlot(pv); if (!masterPlot || !plot) return pv; if (getRotationAngle(masterPlot)!==getRotationAngle(plot)) rotateNorthLock= false; - const csysDirMatching= isCsysDirMatching(plot,masterPlot); - let rotation= getMatchingRotationAngle(masterPv,pv); - if (isEastLeftOfNorth(masterPlot)) { - rotation= csysDirMatching ? 360-rotation :360+rotation; - } - else { - rotation= csysDirMatching ? 360+rotation :360-rotation; - } + const angle= getMatchingRotationAngle(masterPv,pv); + const rotation= isEastDisplayedLeft(pv,plot) ? 360-angle : angle; // same conversion as RotateType.ANGLE pv= {...pv, rotation, plotViewCtx: {...pv.plotViewCtx, rotateNorthLock}}; return updateTransform(pv); } diff --git a/src/firefly/js/visualize/saga/MouseReadoutWatch.js b/src/firefly/js/visualize/saga/MouseReadoutWatch.js index d2de7c4370..46e0df2565 100644 --- a/src/firefly/js/visualize/saga/MouseReadoutWatch.js +++ b/src/firefly/js/visualize/saga/MouseReadoutWatch.js @@ -16,7 +16,7 @@ import {MouseState} from '../VisMouseSync.js'; import CsysConverter, {CysConverter} from '../CsysConverter.js'; import {getPixScale, getScreenPixScale, getScreenPixScaleArcSec, isImage, isHiPS, getFluxUnits} from '../WebPlot.js'; import { - getHealpixCellAtNorder, getHealpixPixelAtNorder, getPlotTilePixelAngSize, makeHipsFitsTilePath + getHiPSFitsTileLocation, getPlotTilePixelAngSize } from '../HiPSUtil.js'; import {mouseUpdatePromise, fireMouseReadoutChange} from '../VisMouseSync'; import { @@ -273,11 +273,9 @@ function makeHiPSPlotAsyncReadout(plotView, worldPt, screenPt, imagePt, threeCol const radix = 10; const norderForFitsReadout= plot.hasFitsCube ? Number(plot.hipsProperties?.hips_order) : hipsLocation.tileNorder; - const cell = getHealpixCellAtNorder(norderForFitsReadout, worldPt, plot.dataCoordSys); - if (!cell || norderForFitsReadout<1) return; - const hipsTileUrl= makeHipsFitsTilePath(plot,norderForFitsReadout,cell.ipix); - const {tileImagePt}= getHealpixPixelAtNorder(norderForFitsReadout,worldPt,plot.dataCoordSys); - return doFluxCall(plotView, makeImagePt(tileImagePt.x+.5, tileImagePt.y+.5), worldPt, hipsTileUrl, plot.cubeIdx) + const {tileUrl, tilePt}= getHiPSFitsTileLocation(plot, worldPt, norderForFitsReadout); + if (!tileUrl) return; + return doFluxCall(plotView, makeImagePt(tilePt.x+.5, tilePt.y+.5), worldPt, tileUrl, plot.cubeIdx) .then((fluxResult) => { return makeReadoutWithFlux(readoutItems, primePlot(plotView), fluxResult, radix, false); }); diff --git a/src/firefly/js/visualize/ui/VisMiniToolbar.jsx b/src/firefly/js/visualize/ui/VisMiniToolbar.jsx index 02f0aa171c..1509e42129 100644 --- a/src/firefly/js/visualize/ui/VisMiniToolbar.jsx +++ b/src/firefly/js/visualize/ui/VisMiniToolbar.jsx @@ -42,6 +42,7 @@ import { dispatchChangeActivePlotView, dispatchChangeExpandedMode, dispatchChangeHiPS, dispatchFlip, dispatchRestoreDefaults, dispatchRotate } from '../ImagePlotDispatch'; +import {isHiPSFitsCube} from '../HiPSUtil.js'; import {ExpandType} from '../VisConst'; import {getDlAry, visRoot} from '../VisStoreRoots'; import {getMultiViewRoot, getViewer} from '../MultiViewCntlr.js'; @@ -375,21 +376,22 @@ function startExtraction(element,type,modalEndInfo) { const ExtractRow= ({pv,enabled,modalEndInfo,mi}) => { const plot= primePlot(pv); const standIm= isImage(plot) && !isThreeColor(pv); - const cube= standIm && isCube(primePlot(pv)); + const cube= (standIm && isCube(plot)) || isHiPSFitsCube(plot); + const hipsWithFits= isHiPS(plot) && plot.hasFits; return ( Extract: startExtraction(element,Z_AXIS,modalEndInfo)} visible={mi.extractZAxis}/> - startExtraction(element,LINE,modalEndInfo)} visible={mi.extractLine}/> - startExtraction(element,POINTS,modalEndInfo)} visible={mi.extractPoint}/> diff --git a/src/firefly/js/visualize/ui/extraction/ExtractionChart.jsx b/src/firefly/js/visualize/ui/extraction/ExtractionChart.jsx index a366c5bc5f..2dab999ebf 100644 --- a/src/firefly/js/visualize/ui/extraction/ExtractionChart.jsx +++ b/src/firefly/js/visualize/ui/extraction/ExtractionChart.jsx @@ -3,7 +3,7 @@ import {getExtName} from '../../FitsHeaderUtil.js'; import { getAllWaveLengthsForCube, getHDU, getPtWavelength, hasWCSProjection, hasWLInfo, primePlot } from '../../PlotViewUtil.js'; -import {getFluxUnits} from '../../WebPlot.js'; +import {getFluxUnits, isHiPS} from '../../WebPlot.js'; const plotlyDivStyle = {width: '100%', height: '100%'}; @@ -38,8 +38,10 @@ function makePlotlyLayoutObj(title, xAxis, yAxis, reversed = false) { } -function makePlotlyDataObj(xDataAry, yDataAry, x, y, ttXStr, ttYStr, makeXDesc = (i) => formatNumber(xDataAry[i],6), - highlightXDesc = formatNumber(x,6), makeYDesc = (i) => formatNumber(yDataAry[i],6)) { +function makePlotlyDataObj(xDataAry, yDataAry, x, y, ttXStr, ttYStr, + makeXDesc = (i) => formatNumber(xDataAry[i],6), + highlightXDesc = formatNumber(x,6), + makeYDesc = (i) => formatNumber(yDataAry[i],6)) { const dataRoot = {mode: 'markers', hoverinfo: 'text'}; const result = [ { @@ -82,12 +84,14 @@ function makePlotlyDataObj(xDataAry, yDataAry, x, y, ttXStr, ttYStr, makeXDesc = * @param {string} combineOp - how the values in the box are combined: 'AVG', 'SUM', or 'OR' * @param {string} title - chart title * @param {boolean} reversed - if true, reverse the x-axis + * @param {string} [hipsUnits] - HiPS only, the units of the pixel values * @return {ExtractionChartParams} */ -export function genSliceChartData(plot, xDataAry, yDataAry, x, y, pointSize, combineOp, title, reversed) { +export function genSliceChartData(plot, xDataAry, yDataAry, x, y, pointSize, combineOp, title, reversed, hipsUnits) { const unitStr = hasWCSProjection(plot) ? ' (arcsec)' : ''; - const yUnits = getFluxUnits(plot); - const yAxisLabel = getExtName(plot) || 'HDU# ' + getHDU(plot); + const hips= isHiPS(plot); + const yUnits = hips ? (hipsUnits ?? '') : getFluxUnits(plot); + const yAxisLabel = hips ? 'Pixel Value' : getExtName(plot) || 'HDU# ' + getHDU(plot); return { plotlyDivStyle, plotlyData: makePlotlyDataObj(xDataAry, yDataAry, x, y, 'Offset' + unitStr, yAxisLabel), @@ -108,15 +112,29 @@ export function genSliceChartData(plot, xDataAry, yDataAry, x, y, pointSize, com * @param {string} title - chart title * @param {string} chartXAxis - 'imageX' to plot against image x, any other value to plot against image y * @param {number} activeIdx - index into imPtAry of the highlighted point + * @param {string} [hipsUnits] - HiPS only, the units of the pixel values * @return {ExtractionChartParams} */ -export function genPointChartData(plot, dataAry, imPtAry, x, y, pointSize, combineOp, title, chartXAxis, activeIdx) { +export function genPointChartData(plot, dataAry, imPtAry, x, y, pointSize, combineOp, title, chartXAxis, activeIdx, hipsUnits='') { - const xAxis = chartXAxis === 'imageX' ? imPtAry.map((pt) => pt.x) : imPtAry.map((pt) => pt.y); - const xAxisTitle = chartXAxis === 'imageX' ? 'Image X' : 'Image Y'; - const xLabel = (i) => `(${imPtAry[i].x},${imPtAry[i].y})`; - const yUnits = getFluxUnits(plot); - const yAxisLabel = getExtName(plot) || 'HDU# ' + getHDU(plot); + const useXforXAxis= chartXAxis === 'imageX'; + const xAxis = useXforXAxis ? imPtAry.map((pt) => pt.x) : imPtAry.map((pt) => pt.y); + const xLabel = (i) => `(${formatNumber(imPtAry[i].x,6)},${formatNumber(imPtAry[i].y,6)})`; + let xAxisTitle; + let yAxisLabel; + let yUnits; + if (isHiPS(plot)) { + xAxisTitle = useXforXAxis ? 'RA' : 'Dec'; + yAxisLabel= 'Pixel Value'; + yUnits= hipsUnits; + } + else { + xAxisTitle = hasWCSProjection(plot) + ? useXforXAxis ? 'RA' : 'Dec' + : useXforXAxis ? 'Image X' : 'Image Y'; + yAxisLabel = getExtName(plot) || 'HDU# ' + getHDU(plot); + yUnits = getFluxUnits(plot); + } return { plotlyDivStyle, diff --git a/src/firefly/js/visualize/ui/extraction/ExtractionDialog.jsx b/src/firefly/js/visualize/ui/extraction/ExtractionDialog.jsx index a9746b58f9..73ff400bc4 100644 --- a/src/firefly/js/visualize/ui/extraction/ExtractionDialog.jsx +++ b/src/firefly/js/visualize/ui/extraction/ExtractionDialog.jsx @@ -1,8 +1,6 @@ /* * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt */ - - import {Box, Button, Divider, Stack, Tooltip, Typography} from '@mui/joy'; import {isUndefined} from 'lodash'; import React, {useEffect, useState} from 'react'; @@ -17,7 +15,7 @@ import ExtractLineTool, { addLineDistAttributesToPlots, COLUMN_SELECTION, FREE_SELECTION, LINE_SELECTION } from '../../../drawingLayers/ExtractLineTool.js'; import ExtractPointsTool from '../../../drawingLayers/ExtractPointsTool.js'; -import {callGetCubeDrillDownAry, callGetPointExtractionAry} from '../../../rpc/PlotServicesJson.js'; +import {callGetCubeDrillDownAry, callGetFileFlux, callGetPointExtractionAry} from '../../../rpc/PlotServicesJson.js'; import {onTableLoaded} from '../../../tables/TableUtil.js'; import CompleteButton from '../../../ui/CompleteButton.jsx'; import DialogRootContainer from '../../../ui/DialogRootContainer.jsx'; @@ -29,34 +27,35 @@ import {CHART_RESIZE_DEBOUNCE, wrapResizeMonitor} from '../../../ui/ResizeMonito import {useFieldGroupValue, useStoreConnector} from '../../../ui/SimpleComponent.jsx'; import {ValidationField} from '../../../ui/ValidationField'; import {intValidator} from '../../../util/Validate'; +import {Band} from '../../Band'; import {CCUtil, CysConverter} from '../../CsysConverter.js'; import {dispatchAttachLayerToPlot, dispatchCreateDrawLayer, dispatchModifyCustomField} from '../../DrawLayerDispatch'; import {getExtName, hasFloatingData} from '../../FitsHeaderUtil.js'; -import {dispatchAttributeChange, dispatchChangePointSelection, dispatchChangePrimePlot} from '../../ImagePlotDispatch'; +import {getHiPSFitsTileLocation, getHiPSNorderlevel, isHiPSFitsCube} from '../../HiPSUtil.js'; +import { + dispatchAttributeChange, dispatchChangeHiPS, dispatchChangePointSelection, dispatchChangePrimePlot +} from '../../ImagePlotDispatch'; +import {STATUS_VALUE} from '../../MouseReadoutCntlr'; import {PlotAttribute} from '../../PlotAttribute.js'; import { convertHDUIdxToImageIdx, currentP, getCubePlaneFromWavelength, getDrawLayerByType, getHDU, getHDUIndex, - getCubePlaneIdx, getPlotViewAry, hasWCSProjection, hasWLInfo, isDrawLayerAttached, isCube, isMultiHDUFits, - primePlot + getCubePlaneIdx, getPlotViewAry, hasWCSProjection, hasWLInfo, isDrawLayerAttached, isCube, + primePlot, getPlotStateAry } from '../../PlotViewUtil.js'; import {makeImagePt} from '../../Point'; import {PLOT_IMAGE} from '../../VisConst'; import {getDlAry, visRoot} from '../../VisStoreRoots'; import {computeDistance, computeScreenDistance, getLinePointAry} from '../../VisUtil.js'; +import {isHiPS, isImage} from '../../WebPlot.js'; import {genPointChartData, genSliceChartData, genZAxisChartData, POINTS_CURVE_NUM} from './ExtractionChart.jsx'; -import {keepDataExtraction, keepZAxisExtraction} from './ExtractionTable.jsx'; +import {keepDataExtraction, keepHiPSPointExtraction, keepZAxisExtraction} from './ExtractionTable.jsx'; import { - EXTRACT_DIALOG_ID, endExtraction, EXTRACT_END_ID, ZAXIS_POINT_SELECTION_ID + EXTRACT_DIALOG_ID, endExtraction, EXTRACT_END_ID, ZAXIS_POINT_SELECTION_ID, POINT_CHART_TITLE, makeHiPSLinePtAry, + makeHiPSLineExtractionTitle, makeExtractPtAry, makeLineExtractionTitle, CHART_ID, HIPS_APERTURE_NOTE, CUBE_WARNING, + SELECT_TYPE_TIP } from './ExtractionUIUtil'; -const CUBE_WARNING=` -Values are taken directly from cube plane pixels without spatial interpolation, background subtraction, or PSF corrections. -This is a quick-look tool for exploration, not a research-ready product. -`; - -const CHART_ID= 'extractionChart'; -const SELECT_TYPE_TIP= 'Choose mouse selection type: mouse line lock, mouse column lock or free selection'; const exTypeCntl= { Z_AXIS: { @@ -74,7 +73,6 @@ const exTypeCntl= { }; - function enableDrawLayer(typeId) { const {pv}= currentP(); const dl= getDrawLayerByType(getDlAry(), typeId); @@ -146,14 +144,19 @@ const combineIntOps= [ ]; -function afterZAxisChartRedraw(imPt, pv, chart) { +const canExtractZAxis= (plot) => (isImage(plot) && isCube(plot)) || isHiPSFitsCube(plot); + +function afterZAxisChartRedraw(imPt, inPv, chart) { chart.on('plotly_click', (ev) => { setTimeout( () => { - const plane= hasWLInfo(primePlot(pv)) ? - getCubePlaneFromWavelength(pv,ev.points[0].x,imPt) : ev.points[0].x-1; - const primeIdx= convertHDUIdxToImageIdx(pv, getHDUIndex(pv,primePlot(pv)), plane); - if (pv.primeIdx!==primeIdx) { - dispatchChangePrimePlot({plotId:pv.plotId,primeIdx}); + const {pv,plot,plotId}= currentP(inPv.plotId); + const plane= hasWLInfo(plot) ? getCubePlaneFromWavelength(pv,ev.points[0].x,imPt) : ev.points[0].x-1; + if (isHiPSFitsCube(plot)) { + if (plane>-1 && plot.cubeIdx!==plane) dispatchChangeHiPS({plotId, cubeIdx:plane}); + } + else { + const primeIdx= convertHDUIdxToImageIdx(pv, getHDUIndex(pv,plot), plane); + if (pv.primeIdx!==primeIdx) dispatchChangePrimePlot({plotId,primeIdx}); } },5); }); @@ -213,19 +216,6 @@ function ExtractionChart({plotlyDivStyle, plotlyData, plotlyLayout, afterRedraw, const ExtractionChartResizeable= wrapResizeMonitor(ExtractionChart,CHART_RESIZE_DEBOUNCE); -function makeLineExtractionTitle(pv,x1,y1,x2,y2) { - const plot= primePlot(pv); - let hduInfo= ''; - let cubeInfo= ''; - if (isMultiHDUFits(pv)) { - const extName= getExtName(plot); - hduInfo= (extName || ` HDU #${getHDU(plot)}`) + ' - '; - } - if (getCubePlaneIdx(plot)>-1) { - cubeInfo= `Plane #${getCubePlaneIdx(plot)+1} - `; - } - return `Line Extract Preview - ${hduInfo}${cubeInfo}(${x1},${y1}) to (${x2},${y2})`; -} function PointExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { @@ -234,41 +224,41 @@ function PointExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { const [combineOp,setCombineOp]= useState(AVG); const [chartXAxis]= useState('imageX'); const plot= primePlot(pv); + const hips= isHiPS(plot); const ptAry=plot?.attributes?.[PlotAttribute.PT_ARY] ??[]; - const cc= CysConverter.make(plot); - const imPtAry= ptAry - .map( (pt) => cc?.getImageCoords(pt)) - .map( (pt) => pt? makeImagePt(Math.trunc(pt.x), Math.trunc(pt.y)) : undefined) - .filter((pt) => pt); + const nOrder= hips ? getHiPSPointNorder(plot) : undefined; + const {foundPts, imPtAry, hipsTileLocAry, wptAry}= makeExtractPtAry(plot, ptAry, nOrder); - const {plotId,plotImageId}= plot ?? {}; - const hduNum= getHDU(plot); + const {plotId,plotImageId,cubeIdx}= plot ?? {}; + const hduNum= getHDU(plot); // not used for HiPS, the server uses the first image HDU of the tile const plane= getCubePlaneIdx(plot)>-1 ? getCubePlaneIdx(plot) : 0; const {x:chartX,y:chartY,chartXAxis:lastChartChartXAxis=chartXAxis,idx:chartIdx}=plot?.attributes?.[PlotAttribute.SELECT_ACTIVE_CHART_PT] ?? {}; useEffect(() => { let active= true; const getData= async () => { - if (imPtAry && imPtAry.length && plot) { - const dataAry= await callGetPointExtractionAry(plot, hduNum, plane, imPtAry, pointSize, pointSize, combineOp); - if (!active) return; - const chartTitle= 'Point Extract Preview'; - let activeIdx= 0; - const key= lastChartChartXAxis==='imageX' ? 'x' : 'y'; - if (plot.attributes[PlotAttribute.SELECT_ACTIVE_CHART_PT]) { - activeIdx= imPtAry[chartIdx]?.[key]===chartX ? chartIdx : imPtAry.findIndex( (pt) => pt[key]===chartX); - if (activeIdx<0) activeIdx= 0; - } - const chartData= - genPointChartData(plot, dataAry,imPtAry, imPtAry[activeIdx][key], dataAry[activeIdx], - pointSize,combineOp, chartTitle, chartXAxis, activeIdx); - setChartParams(chartData); + if (!foundPts) return; + const dataAry= await callGetPointExtractionAry( + {plot, hduNum, plane, ptAry:hips?hipsTileLocAry:imPtAry, ptSizeX:pointSize, ptSizeY:pointSize, combineOp}); + if (!active) return; + const hipsUnits= hips ? await getHiPSPointUnits(pv, hipsTileLocAry[0], wptAry[0]) : ''; + if (!active) return; + const chartPtAry= wptAry ?? imPtAry; // RA or Dec for HiPS or an image with a WCS, otherwise image x or y + let activeIdx= 0; + const key= lastChartChartXAxis==='imageX' ? 'x' : 'y'; + if (plot.attributes[PlotAttribute.SELECT_ACTIVE_CHART_PT]) { + activeIdx= chartPtAry[chartIdx]?.[key]===chartX ? chartIdx : chartPtAry.findIndex( (pt) => pt[key]===chartX); + if (activeIdx<0) activeIdx= 0; } + const chartData= + genPointChartData(plot, dataAry,chartPtAry, chartPtAry[activeIdx][key], dataAry[activeIdx], + pointSize,combineOp, POINT_CHART_TITLE, chartXAxis, activeIdx, hipsUnits); + setChartParams(chartData); }; getData(); return () => { active= false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - },[ptAry.length,hduNum,plotId,plotImageId,pointSize,combineOp,chartX,chartY,chartXAxis,chartIdx]); + },[ptAry.length,hduNum,plotId,plotImageId,cubeIdx,nOrder,pointSize,combineOp,chartX,chartY,chartXAxis,chartIdx]); return ( Click on an image to extract a point, continue clicking to extract more points. {pvCnt>1 && Shift-click will change the selected image without extracting points. } + {hips && {HIPS_APERTURE_NOTE}} ), afterRedraw: (chart,pl) => afterPointsChartRedraw(pv,chart,pl,chartXAxis, imPtAry), - callKeepExtraction: (save, doOverlay) => - keepDataExtraction({pv,baseImPtAry:imPtAry,save,doOverlay,pointSize,combineOp}) + callKeepExtraction: (save, doOverlay) => hips + ? keepHiPSPointExtraction({pv, hipsTileLocAry, hipsWptAry: wptAry, plane, pointSize, combineOp, save, doOverlay}) + : keepDataExtraction({pv,baseImPtAry:imPtAry,save,doOverlay,pointSize,combineOp}) }} /> ); } +/** + * get the flux units of the HiPS FITS tiles by doing a flux call on one point + * @param {PlotView} pv + * @param {{tileUrl:String, tilePt:ImagePt}} tileLoc - the FITS tile location of the point + * @param {WorldPt} wpt - the point + * @return {Promise} the units, empty string if they can not be determined + */ +async function getHiPSPointUnits(pv, tileLoc, wpt) { + try { + const plot= primePlot(pv); + const result= await callGetFileFlux(getPlotStateAry(pv), tileLoc.tilePt, wpt, true, tileLoc.tileUrl, plot?.cubeIdx); + const fluxResult= result?.[Band.NO_BAND.key]; + return fluxResult?.status===STATUS_VALUE ? (fluxResult.unit ?? '') : ''; + } catch { + return ''; + } +} + +/** + * the norder of the HiPS FITS tiles to extract points from, FITS cube tiles only exist at hips_order, + * otherwise use the norder that is currently displayed + * @param {WebPlot} plot + * @return {number} + */ +const getHiPSPointNorder= (plot) => + plot.hasFitsCube ? Number(plot.hipsProperties?.hips_order) : getHiPSNorderlevel(plot, true).norder; + function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { @@ -297,7 +316,10 @@ function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { const [pointSize,setPointSize]= useState(1); const [combineOp,setCombineOp]= useState(AVG); const [axis,setAxis]= useState('x'); + const [hipsLinePts,setHipsLinePts]= useState(undefined); const plot= primePlot(pv); + const hips= isHiPS(plot); + const hipsNorder= hips ? getHiPSPointNorder(plot) : undefined; const {pt0:pt1,pt1:pt2}=plot?.attributes?.[PlotAttribute.ACTIVE_DISTANCE] ?? {}; const extractionData=plot?.attributes?.[PlotAttribute.EXTRACTION_DATA] ?? false; const cc= CysConverter.make(plot); @@ -307,16 +329,41 @@ function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { const y1= Math.trunc(ipt1?.y ?? 0); const x2= Math.trunc(ipt2?.x ?? 0); const y2= Math.trunc(ipt2?.y ?? 0); - const {plotId,plotImageId}= plot ?? {}; - const hduNum= getHDU(plot); + const {plotId,plotImageId,cubeIdx}= plot ?? {}; + const hduNum= getHDU(plot); // not used for HiPS, the server uses the first image HDU of the tile const plane= getCubePlaneIdx(plot)>-1 ? getCubePlaneIdx(plot) : 0; const {x:chartX,y:chartY}=plot?.attributes?.[PlotAttribute.SELECT_ACTIVE_CHART_PT] ?? {}; useEffect(() => { let active= true; + const getHiPSData= async () => { + const {imPtAry:newImPtAry, hipsTileLocAry, hipsWptAry}= makeHiPSLinePtAry(plot, ipt1, ipt2, hipsNorder); + if (!newImPtAry.length) { + setImPtAry(undefined); + setHipsLinePts(undefined); + setChartParams({}); + return; + } + const dataAry= await callGetPointExtractionAry( + {plot, hduNum, plane, ptAry:hipsTileLocAry, ptSizeX:pointSize, ptSizeY:pointSize, combineOp}); + if (!active) return; + const hipsUnits= await getHiPSPointUnits(pv, hipsTileLocAry[0], hipsWptAry[0]); + if (!active) return; + const xOffAry= hipsWptAry.map( (wpt) => computeDistance(hipsWptAry[0],wpt)*3600); + const chartData= genSliceChartData(plot, xOffAry, dataAry, chartX, chartY, pointSize, combineOp, + makeHiPSLineExtractionTitle(plot, hipsWptAry), false, hipsUnits); + setChartParams(chartData); + setImPtAry(newImPtAry); + setHipsLinePts({hipsTileLocAry, hipsWptAry}); + }; + const getData= async () => { if (ipt1 && ipt2 && plot) { + if (hips) { + await getHiPSData(); + return; + } const {dataWidth,dataHeight}= plot; const {direction,axis:newAxis}= xLineData(ipt1,ipt2); const newImPtAry= getLinePointAry(ipt1,ipt2) @@ -330,7 +377,8 @@ function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { const pointSizeX= newAxis==='y' ? pointSize : 1; const pointSizeY= newAxis==='x' ? pointSize : 1; - const dataAry= await callGetPointExtractionAry(plot, hduNum, plane, newImPtAry, pointSizeX, pointSizeY, combineOp); + const dataAry= await callGetPointExtractionAry( + {plot, hduNum, plane, ptAry:newImPtAry, ptSizeX:pointSizeX, ptSizeY:pointSizeY, combineOp}); if (!active) return; const chartTitle= makeLineExtractionTitle(pv,x1,y1,x2,y2); const pt0= hasWCSProjection(plot) ? cc.getWorldCoords(newImPtAry[0]) : newImPtAry[0]; @@ -347,22 +395,25 @@ function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { if (extractionData) void getData(); return () => { active= false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - },[x1,y1,x2,y2,hduNum,plotId,plotImageId,pointSize,combineOp,chartX,chartY]); + },[x1,y1,x2,y2,hduNum,plotId,plotImageId,cubeIdx,hipsNorder,pointSize,combineOp,chartX,chartY]); const plotlyDataToUse= extractionData&&!helpWarning ? plotlyData : undefined; + let sizeType= axis==='y' ? SIZE_HORIZONTAL : SIZE_VERTICAL; + if (hips) sizeType= SIZE_SQUARE; // the tile pixels are not lined up with the line, so use a square aperture return ( , afterRedraw: (chart,pl) => afterLineChartRedraw(pv,chart,pl,imPtAry), - callKeepExtraction: (save, doOverlay) => - keepDataExtraction({pv,baseImPtAry:getLinePointAry(ipt1, ipt2),save, + callKeepExtraction: (save, doOverlay) => hips + ? keepHiPSPointExtraction({pv, ...hipsLinePts, plane, pointSize, combineOp, save, doOverlay, isLine:true}) + : keepDataExtraction({pv,baseImPtAry:getLinePointAry(ipt1, ipt2),save, doOverlay,axis,pointSize,combineOp, isLine:true}) }}> - + {!hips && } ); } @@ -371,26 +422,31 @@ function LineExtractionPanel({canCreateExtractionTable, pv, pvCnt}) { const CubeStartUpHelp= ({plot}) => ( - {isCube(plot) ? + {canExtractZAxis(plot) ? 'Click on a pixel to extract data from all planes of the cube' : 'Please choose a cube to extract z-axis data'} - {isCube(plot) && + {canExtractZAxis(plot) && {CUBE_WARNING} } + {isHiPSFitsCube(plot) && + {HIPS_APERTURE_NOTE} + } ); + const LineStartUpHelp= ({helpWarning,plot,pvCnt}) => ( Draw line on image to extract point on line and show chart. {pvCnt>1 && !helpWarning && Shift-click will change the selected image without selecting a new line. } - {!helpWarning && OR} - {!helpWarning && + {!helpWarning && isHiPS(plot) && {HIPS_APERTURE_NOTE}} + {!helpWarning && isImage(plot) && OR} + {!helpWarning && isImage(plot) && @@ -559,32 +615,40 @@ function ZAxisExtractionPanel({canCreateExtractionTable, pv}) { const [{plotlyDivStyle, plotlyData, plotlyLayout},setChartParams]= useState({}); const plot= primePlot(pv); const {pt}=plot?.attributes?.[PlotAttribute.ACTIVE_POINT] ?? {}; - const ipt= CCUtil.getImageCoords(plot,pt); + const hipsCube= isHiPSFitsCube(plot); + const wpt= hipsCube ? CCUtil.getWorldCoords(plot,pt) : undefined; + const tileLoc= hipsCube + ? getHiPSFitsTileLocation(plot, wpt, Number(plot.hipsProperties?.hips_order)) + : {}; + const hipsTileUrl= hipsCube ? tileLoc.tileUrl : undefined; + const ipt= hipsCube ? tileLoc.tilePt : CCUtil.getImageCoords(plot,pt); const x= Math.trunc(ipt?.x ?? 0); const y= Math.trunc(ipt?.y ?? 0); - const {plotId,plotImageId}= plot ?? {}; - const hduNum= getHDU(plot); - const extName= getExtName(plot); + const {plotId,plotImageId,cubeIdx}= plot ?? {}; + const hduNum= getHDU(plot); // not used for HiPS, the server uses the first image HDU of the tile + const extName= hipsCube ? undefined : getExtName(plot); useEffect(() => { let active= true; const updateChart= async () => { if (ipt && plot) { - if (!isCube(plot)) { + if (!canExtractZAxis(plot)) { setChartParams({}); return; } - const dataAry = await callGetCubeDrillDownAry(plot, hduNum, ipt, pointSize, combineOp); + const dataAry = await callGetCubeDrillDownAry( {plot, hduNum, ipt, pointSize, combineOp, hipsTileUrl}); if (!active) return; const plane=getCubePlaneIdx(plot); - const chartTitle= `Z-Axis Preview - ${extName?extName+',':''} HDU #${hduNum}, Point: (${x},${y})`; + const chartTitle= hipsCube + ? `Z-Axis Preview - Norder ${tileLoc.norder}, Npix ${tileLoc.ipix}, Point: (${x},${y})` + : `Z-Axis Preview - ${extName?extName+',':''} HDU #${hduNum}, Point: (${x},${y})`; setChartParams(genZAxisChartData(makeImagePt(x,y), pv, dataAry, plane , dataAry[plane] , pointSize, combineOp, chartTitle)); } }; void updateChart(); return () => { active= false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - },[x,y,hduNum,plotId,pointSize,combineOp,plotImageId]); + },[x,y,hduNum,plotId,pointSize,combineOp,plotImageId,tileLoc.ipix,cubeIdx]); return ( , afterRedraw: (chart,pl) => afterZAxisChartRedraw(makeImagePt(x,y), pv,chart,pl), callKeepExtraction: (download, doOverlay) => - keepZAxisExtraction(makeImagePt(x,y), pv, plot, plot?.plotState.getWorkingFitsFileStr(), - hduNum, pointSize, combineOp, download,doOverlay), + keepZAxisExtraction(makeImagePt(x,y), pv, plot, + hipsCube ? hipsTileUrl : plot?.plotState.getWorkingFitsFileStr(), + hduNum, pointSize, combineOp, download,doOverlay, wpt), }} /> ); } @@ -623,7 +688,7 @@ function ExtractionPanelView({pointSize, setPointSize, afterRedraw, plotlyDivSty for(let i= 1; i<=7;i+=2) sizeOp.push({label:`${i}x${i}`, value:i}); } - const canExtract = plotlyData && canCreateExtractionTable; + const canExtract = plotlyData && canCreateExtractionTable && callKeepExtraction; return ( void keepExtractionAndPin(callKeepExtraction, allowPinnedCharts()) } />} - {plotlyData && + {plotlyData && callKeepExtraction && void callKeepExtraction(true,false)}/>} {plotlyData && diff --git a/src/firefly/js/visualize/ui/extraction/ExtractionTable.jsx b/src/firefly/js/visualize/ui/extraction/ExtractionTable.jsx index 8b09564183..dacade4eca 100644 --- a/src/firefly/js/visualize/ui/extraction/ExtractionTable.jsx +++ b/src/firefly/js/visualize/ui/extraction/ExtractionTable.jsx @@ -15,13 +15,13 @@ import {CCUtil, CysConverter} from '../../CsysConverter'; import {getExtName} from '../../FitsHeaderUtil'; import {visRoot} from '../../VisStoreRoots'; import { - getAllWaveLengthsForCube, getHDU, getHduPlotStartIndexes, getCubePlaneIdx, getPlotViewAry, + getAllWaveLengthsForCube, getHDU, getCubePlaneIdx, getPlotViewAry, getPtWavelength, getWaveLengthUnits, hasPixelLevelWLInfo, hasWCSProjection, hasWLInfo, isCube, isMultiHDUFits, primePlot, } from '../../PlotViewUtil'; import {makeImagePt} from '../../Point'; -import {getFluxUnits, isImage} from '../../WebPlot'; -import {addZAxisExtractionWatcher} from '../ExtractionWatchers'; +import {isHiPS, isImage} from '../../WebPlot'; +import {addZAxisExtractionWatcher} from './ExtractionWatchers'; let idCnt = 0; const MAX_HDU= 21; @@ -59,13 +59,27 @@ function doDispatchTable(req, doOverlay) { let titleCnt = 1; -export function keepZAxisExtraction(pt, pv, plot, filename, refHDUNum, extractionSize, combineOp, save = false, doOverlay = true) { +/** + * @param {ImagePt} pt - for HiPS, the pixel in the FITS cube tile + * @param {PlotView} pv + * @param {WebPlot|undefined} plot + * @param {String} filename - the server FITS file, for HiPS the url of the FITS cube tile + * @param {number} refHDUNum + * @param {number} extractionSize + * @param {String} combineOp + * @param {boolean} [save] + * @param {boolean} [doOverlay] + * @param {WorldPt} [wpt] - world point of the extraction, required for HiPS, computed from pt if not passed + * @return {Promise>|undefined} the table ids + */ +export function keepZAxisExtraction(pt, pv, plot, filename, refHDUNum, extractionSize, combineOp, save = false, doOverlay = true, + wpt= undefined) { if (!pv || !plot || !filename) { showInfoPopup('Plot no longer exists. Cannot extract.'); return; } - const params= {pt,pv,filename,refHDUNum,extractionSize,combineOp,save,doOverlay}; + const params= {pt,wpt,pv,filename,refHDUNum,extractionSize,combineOp,save,doOverlay}; if (extractionSize===1) { return Promise.resolve([makeZAsixExtractionTable(params)]); @@ -86,16 +100,16 @@ export function keepZAxisExtraction(pt, pv, plot, filename, refHDUNum, extractio } -export function makeZAsixExtractionTable({pt, pv, filename, refHDUNum, extractionSize, combineOp, secondaryCombine, save = false, doOverlay = true}) { +export function makeZAsixExtractionTable({pt, wpt:inWpt, pv, filename, refHDUNum, extractionSize, combineOp, secondaryCombine, + save = false, doOverlay = true}) { const plot= primePlot(pv); - const wlUnit = getWaveLengthUnits(plot); - const wpt = CCUtil.getWorldCoords(plot, pt); - const fluxUnit = getHduPlotStartIndexes(pv) - .map((idx) => ({hdu: getHDU(pv.plots[idx]), unit: getFluxUnits(pv.plots[idx])})) - .map(({hdu, unit}) => `${hdu}=${unit}`); + if (!plot) return; + const wlUnit= getWaveLengthUnits(plot); + const wpt = inWpt ?? CCUtil.getWorldCoords(plot, pt); const tbl_id = getNextTblId(); addZAxisExtractionWatcher(tbl_id); + const dataTableReq = makeTblRequest('ExtractFromImage', `Extraction Z-Axis - ${titleCnt}`, { startIdx: 0, @@ -104,9 +118,8 @@ export function makeZAsixExtractionTable({pt, pv, filename, refHDUNum, extractio wpt: wpt?.toString(), wlAry: hasWLInfo(plot) ? JSON.stringify(getAllWaveLengthsForCube(pv, pt)) : undefined, wlUnit, - fluxUnit: JSON.stringify(fluxUnit), secondaryCombine: secondaryCombine==='AUTO' ? 'AUTO' : 'SAME', - filename, + ...(isHiPS(plot) ? {hipsTileUrl: filename} : {filename}), refHDUNum, extractionSizeX: extractionSize, extractionSizeY: extractionSize, @@ -126,6 +139,70 @@ export function makeZAsixExtractionTable({pt, pv, filename, refHDUNum, extractio +/** + * Make a table from the values at points or along a line of a HiPS, the values come from the HiPS FITS tiles. + * Like a FITS image, the table has a column for every HDU in the tile that matches the reference HDU. + * @param {Object} params + * @param {PlotView} params.pv + * @param {Array.<{tileUrl:String, tilePt:ImagePt}>} params.hipsTileLocAry - the FITS tile location of each point + * @param {Array.} params.hipsWptAry - the world point of each point, same order as hipsTileLocAry + * @param {number} params.plane - the cube plane + * @param {number} params.pointSize + * @param {String} params.combineOp + * @param {boolean} [params.save] + * @param {boolean} [params.doOverlay] + * @param {boolean} [params.isLine] - if true the points are along a line + * @return {Promise>|undefined} the table ids + */ +export function keepHiPSPointExtraction(params) { + const {pv,hipsTileLocAry,pointSize}= params; + if (!pv || !isHiPS(primePlot(pv)) || !hipsTileLocAry?.length) { + showInfoPopup('Plot no longer exists. Cannot extract.'); + return; + } + if (pointSize===1) return Promise.resolve([makeHiPSPointExtractionTable(params)]); + return new Promise((resolve) => { + showFieldGroupPopup({groupKey:'ExtractOptions', keepState:true, + successText:'Pin Chart/Table', title:'Secondary HDUs', + content:( + + + + ), + onSuccess:({secondaryCombine}) => { + resolve([makeHiPSPointExtractionTable({...params,secondaryCombine})]); + } + }); + }); +} + +function makeHiPSPointExtractionTable({pv, hipsTileLocAry, hipsWptAry, plane, pointSize, combineOp, + secondaryCombine, save=false, doOverlay=true, isLine=false}) { + const plot= primePlot(pv); + const tbl_id = getNextTblId(); + const dataTableReq = makeTblRequest('ExtractFromImage', + makePlaneTitle(`Extract ${isLine?'Line':'Points'}`, pv, plot, titleCnt), + { + startIdx: 0, + extractionType: isLine ? 'line' : 'points', + [ServerParams.HIPS_TILE_URL_ARY]: JSON.stringify(hipsTileLocAry.map( ({tileUrl}) => tileUrl)), + [ServerParams.PTARY]: JSON.stringify(hipsTileLocAry.map( ({tilePt}) => tilePt.toString())), + [ServerParams.WPT_ARY]: JSON.stringify(hipsWptAry.map( (wpt) => wpt.toString())), + [ServerParams.PLANE]: plane, + extractionSizeX: pointSize, + extractionSizeY: pointSize, + [ServerParams.COMBINE_OP]: combineOp, + secondaryCombine: secondaryCombine==='AUTO' ? 'AUTO' : 'SAME', + allMatchingHDUs: true, + }, + {tbl_id}); + if (save) dataTableReq.pageSize = 0; + save ? doDispatchTableSaving(dataTableReq, doOverlay) : doDispatchTable(dataTableReq, doOverlay); + idCnt++; + titleCnt++; + return tbl_id; +} + export function keepDataExtraction({pv, baseImPtAry, save, doOverlay, axis='both', pointSize, combineOp, isLine=false}) { const plot= primePlot(pv); const filename= plot?.plotState?.getWorkingFitsFileStr(); @@ -201,7 +278,7 @@ const ExtractTableOptions= ({pvAry:initPvAry, usingAperture}) => { ? initPvAry.filter( (pv) => primePlot(pv).totalImageHdusInFile<=MAX_HDU && hasWCSProjection(pv)) : initPvAry.filter( (pv) => hasWCSProjection(pv)); setPvAry(newPvAry); - }, [allHdus, allToSameFile, initPvAry, someHduOverMax]); + }, [allHdus, allToSameFile, initPvAry, setPvAry, someHduOverMax]); const wcsPvAry= initPvAry.filter( (testPv) => hasWCSProjection(testPv)); const wcsWarning= wcsPvAry.length < initPvAry.length; diff --git a/src/firefly/js/visualize/ui/extraction/ExtractionUIUtil.js b/src/firefly/js/visualize/ui/extraction/ExtractionUIUtil.js index 39476de873..f34ed1adab 100644 --- a/src/firefly/js/visualize/ui/extraction/ExtractionUIUtil.js +++ b/src/firefly/js/visualize/ui/extraction/ExtractionUIUtil.js @@ -3,11 +3,18 @@ import {dispatchCancelActionWatcher} from '../../../core/MasterSaga'; import ExtractHiPSTileTool from '../../../drawingLayers/ExtractHiPSTileTool'; import ExtractLineTool from '../../../drawingLayers/ExtractLineTool'; import ExtractPointsTool from '../../../drawingLayers/ExtractPointsTool'; +import CysConverter from '../../CsysConverter'; import {dispatchDestroyDrawLayer, dispatchDetachLayerFromPlot} from '../../DrawLayerDispatch'; +import {getExtName} from '../../FitsHeaderUtil'; +import {getHiPSFitsTileLocation, getTilePixelAngSize} from '../../HiPSUtil'; import {dispatchAttributeChange, dispatchChangePointSelection} from '../../ImagePlotDispatch'; import {PlotAttribute} from '../../PlotAttribute'; -import {currentP, getPlotViewAry, primePlot} from '../../PlotViewUtil'; +import { + currentP, getCubePlaneIdx, getHDU, getPlotViewAry, hasWCSProjection, isCube, isMultiHDUFits, primePlot +} from '../../PlotViewUtil'; +import {makeImagePt} from '../../Point'; import {visRoot} from '../../VisStoreRoots'; +import {computeDistance} from '../../VisUtil'; import {isHiPS} from '../../WebPlot'; @@ -21,6 +28,25 @@ export const LINE = 'LINE'; export const POINTS = 'POINTS'; export const HIPS_TILE= 'HIPS_TILE'; + +export const POINT_CHART_TITLE= 'Point Extract Preview'; + +export const CUBE_WARNING=` +Values are taken directly from cube plane pixels without spatial interpolation, background subtraction, or PSF corrections. +This is a quick-look tool for exploration, not a research-ready product. +`; + +export const HIPS_APERTURE_NOTE= + 'HiPS: values come from the HiPS FITS tiles. An aperture larger than 1x1 that extends past the edge of a tile gives no value for that point.'; + +export const CHART_ID= 'extractionChart'; + +export const SELECT_TYPE_TIP= 'Choose mouse selection type: mouse line lock, mouse column lock or free selection'; + +const MAX_HIPS_LINE_PTS= 1500; + + + export function endExtraction() { cancelPointExtraction(); cancelZaxisExtraction(); @@ -73,3 +99,115 @@ function cancelPointExtraction() { } dispatchHideDialog(EXTRACT_DIALOG_ID); } + +export function makeHiPSLineExtractionTitle(plot, wptAry) { + const cubeInfo= isCube(plot) ? `Plane #${getCubePlaneIdx(plot)+1} - ` : ''; + const ptStr= (wpt) => `(${wpt.x.toFixed(5)},${wpt.y.toFixed(5)})`; + return `Line Extract Preview - ${cubeInfo}${ptStr(wptAry[0])} to ${ptStr(wptAry[wptAry.length-1])}`; +} + +export function makeLineExtractionTitle(pv,x1,y1,x2,y2) { + const plot= primePlot(pv); + let hduInfo= ''; + let cubeInfo= ''; + if (isMultiHDUFits(pv)) { + const extName= getExtName(plot); + hduInfo= (extName || ` HDU #${getHDU(plot)}`) + ' - '; + } + if (getCubePlaneIdx(plot)>-1) { + cubeInfo= `Plane #${getCubePlaneIdx(plot)+1} - `; + } + return `Line Extract Preview - ${hduInfo}${cubeInfo}(${x1},${y1}) to (${x2},${y2})`; +} + + +/** + * Make the points along a line on a HiPS, spaced about one FITS tile pixel apart. The line is straight in the + * HiPS projection, the same as it is drawn. A point in the same tile pixel as the point before it is skipped. + * @param {WebPlot} plot + * @param {ImagePt} ipt1 - the start of the line, image point of the HiPS projection + * @param {ImagePt} ipt2 - the end of the line, image point of the HiPS projection + * @param {number} hipsNorder - the norder of the FITS tiles + * @return {{imPtAry:Array., hipsTileLocAry:Array., hipsWptAry:Array.}} + * imPtAry - the image point of the HiPS projection of each point, used by the chart + * hipsTileLocAry - the FITS tile location of each point, in the same order as imPtAry + * hipsWptAry - the world point of each point, in the same order as imPtAry + */ +export function makeHiPSLinePtAry(plot, ipt1, ipt2, hipsNorder) { + const imPtAry= []; + const hipsTileLocAry= []; + const hipsWptAry= []; + const cc= CysConverter.make(plot); + const wpt1= cc.getWorldCoords(ipt1); + const wpt2= cc.getWorldCoords(ipt2); + if (!wpt1 || !wpt2) return {imPtAry, hipsTileLocAry, hipsWptAry}; + + const steps= Math.min(Math.ceil(computeDistance(wpt1,wpt2)/getTilePixelAngSize(hipsNorder)), MAX_HIPS_LINE_PTS); + let lastTilePixel; + for(let i=0; i<=steps; i++) { + const fraction= steps ? i/steps : 0; + const imPt= makeImagePt(ipt1.x+(ipt2.x-ipt1.x)*fraction, ipt1.y+(ipt2.y-ipt1.y)*fraction); + const wpt= cc.getWorldCoords(imPt); + if (!wpt) continue; + const tileLoc= getHiPSFitsTileLocation(plot, wpt, hipsNorder); + if (!tileLoc.tileUrl) continue; + const tilePixel= `${tileLoc.tileUrl};${tileLoc.tilePt.x},${tileLoc.tilePt.y}`; + if (tilePixel===lastTilePixel) continue; + lastTilePixel= tilePixel; + imPtAry.push(imPt); + hipsTileLocAry.push(tileLoc); + hipsWptAry.push(wpt); + } + return {imPtAry, hipsTileLocAry, hipsWptAry}; +} + +/** + * Convert the selected points to image points. For HiPS also find the FITS tile location of each point, + * points that are not in a FITS tile are removed. + * @param {WebPlot|undefined} plot + * @param {Array.} ptAry - the selected points + * @param {number} [hipsNorder] - for HiPS, the norder of the FITS tiles + * @return {{foundPts:boolean, imPtAry:Array., hipsTileLocAry:Array., + * wptAry:Array.}} + * foundPts - true if there are any points to extract + * imPtAry - the image points, for HiPS these are in the HiPS projection. + * For an image these are passed to callGetPointExtractionAry + * hipsTileLocAry - HiPS only, the FITS tile location of each point, in the same order as imPtAry. + * These are passed to callGetPointExtractionAry + * wptAry - the world point of each point, in the same order as imPtAry, used by the chart. + * undefined for an image without a WCS projection + */ +export function makeExtractPtAry(plot, ptAry, hipsNorder) { + if (!plot) return {foundPts:false, imPtAry:[]}; + const cc= CysConverter.make(plot); + const toImPt= (pt) => { + const ipt= cc.getImageCoords(pt); + return ipt ? makeImagePt(Math.trunc(ipt.x), Math.trunc(ipt.y)) : undefined; + }; + + if (isHiPS(plot)) { + const hipsPtAry= ptAry + .map( (pt) => { + const wpt= cc.getWorldCoords(pt); + return {imPt: toImPt(pt), wpt, tileLoc: getHiPSFitsTileLocation(plot, wpt, hipsNorder)}; + }) + .filter( ({imPt, wpt, tileLoc}) => imPt && wpt && tileLoc.tileUrl); + return { + foundPts: hipsPtAry.length>0, + imPtAry: hipsPtAry.map( ({imPt}) => imPt), + hipsTileLocAry: hipsPtAry.map( ({tileLoc}) => tileLoc), + wptAry: hipsPtAry.map( ({wpt}) => wpt), + }; + } + else { + const wcs= hasWCSProjection(plot); + const imagePtAry= ptAry + .map( (pt) => ({imPt: toImPt(pt), wpt: wcs ? cc.getWorldCoords(pt) : undefined})) + .filter( ({imPt, wpt}) => imPt && (!wcs || wpt)); + return { + foundPts: imagePtAry.length>0, + imPtAry: imagePtAry.map( ({imPt}) => imPt), + wptAry: wcs ? imagePtAry.map( ({wpt}) => wpt) : undefined, + }; + } +} diff --git a/src/firefly/js/visualize/ui/ExtractionWatchers.js b/src/firefly/js/visualize/ui/extraction/ExtractionWatchers.js similarity index 69% rename from src/firefly/js/visualize/ui/ExtractionWatchers.js rename to src/firefly/js/visualize/ui/extraction/ExtractionWatchers.js index 4a7b8d1a3a..6ae8b70ba2 100644 --- a/src/firefly/js/visualize/ui/ExtractionWatchers.js +++ b/src/firefly/js/visualize/ui/extraction/ExtractionWatchers.js @@ -2,25 +2,27 @@ * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt */ import {isEmpty} from 'lodash'; -import {dispatchAddActionWatcher} from 'firefly/core/MasterSaga.js'; +import {dispatchAddActionWatcher} from '../../../core/MasterSaga.js'; import { dispatchAttachLayerToPlot, dispatchCreateDrawLayer, dispatchDestroyDrawLayer, dispatchModifyCustomField -} from '../DrawLayerDispatch'; -import {dispatchChangePrimePlot} from '../ImagePlotDispatch'; -import {CHANGE_PRIME_PLOT} from '../VisConst'; +} from '../../DrawLayerDispatch'; +import {isHiPSFitsCube} from '../../HiPSUtil.js'; +import {dispatchChangeHiPS, dispatchChangePrimePlot} from '../../ImagePlotDispatch'; +import {CHANGE_HIPS, CHANGE_PRIME_PLOT} from '../../VisConst'; import { dispatchTableHighlight, TABLE_HIGHLIGHT, TABLE_LOADED, TABLE_REMOVE, TABLE_SELECT, TABLE_UPDATE, TBL_RESULTS_ACTIVE -} from 'firefly/tables/TablesCntlr.js'; -import {getCellValue, getColumnIdx, getMetaEntry, getTblById} from 'firefly/tables/TableUtil.js'; -import {MetaConst} from 'firefly/data/MetaConst.js'; +} from '../../../tables/TablesCntlr.js'; +import {getCellValue, getColumnIdx, getMetaEntry, getTblById} from '../../../tables/TableUtil.js'; +import {MetaConst} from '../../../data/MetaConst.js'; import { - convertHDUIdxToImageIdx, getDrawLayerById, getHDU, getHDUIndex, getCubePlaneIdx, isCube, primePlot -} from 'firefly/visualize/PlotViewUtil.js'; -import {makeImagePt, parseImagePt} from 'firefly/visualize/Point.js'; -import SearchTarget from 'firefly/drawingLayers/SearchTarget.js'; -import {findPlotViewUsingFitsPathMeta} from 'firefly/visualize/saga/CatalogWatcher.js'; -import {dlRoot} from '../VisStoreRoots'; + convertHDUIdxToImageIdx, getDrawLayerById, getHDU, getHDUIndex, getCubePlaneIdx, getPlotViewAry, isCube, primePlot +} from '../../PlotViewUtil.js'; +import {makeImagePt, parseImagePt, parseWorldPt} from '../../Point.js'; +import SearchTarget from '../../../drawingLayers/SearchTarget.js'; +import {findPlotViewUsingFitsPathMeta} from '../../saga/CatalogWatcher.js'; +import {dlRoot, visRoot} from '../../VisStoreRoots'; +import {isHiPS} from '../../WebPlot.js'; let idCnt=0; @@ -36,7 +38,7 @@ export function addZAxisExtractionWatcher(tbl_id) { id: 'plot-zaxis-watcher-${idCnt}--'+tbl_id, callback:zAxisExtractionPlotWatcher, params:{tbl_id}, - actions:[CHANGE_PRIME_PLOT, TABLE_REMOVE] + actions:[CHANGE_PRIME_PLOT, CHANGE_HIPS, TABLE_REMOVE] }); } @@ -94,12 +96,15 @@ function zAxisExtractionTableWatcher(action,cancelSelf,{tbl_id, drawLayerId=unde idCnt++; drawLayerId= `zaxis-drill-point-${idCnt}--`+tbl_id; const imPt= parseImagePt(getMetaEntry(table,MetaConst.FITS_IM_PT)); + const searchTargetPoint= isHiPS(plot) + ? parseWorldPt(getMetaEntry(table,MetaConst.FITS_WORLD_PT)) + : makeImagePt(Math.trunc(imPt.x)+.5, Math.trunc(imPt.y)+.5); const newDL = dispatchCreateDrawLayer(SearchTarget.TYPE_ID, { plotId: plot.plotId, drawLayerId, layersPanelLayoutId: 'z-axis-layout', - searchTargetPoint: makeImagePt(Math.trunc(imPt.x)+.5, Math.trunc(imPt.y)+.5), + searchTargetPoint, titlePrefix: table.title, canUserDelete: true, color: SELECTED_COLOR @@ -110,8 +115,13 @@ function zAxisExtractionTableWatcher(action,cancelSelf,{tbl_id, drawLayerId=unde if (isNaN(table.highlightedRow)) return retData(); const plane= getCellValue(table,table.highlightedRow,'plane'); - const primeIdx= convertHDUIdxToImageIdx(pv,getHDUIndex(pv,plot),plane-1); - if (pv.primeIdx!==primeIdx) dispatchChangePrimePlot({plotId:plot.plotId,primeIdx}); + if (isHiPS(plot)) { + if (plot.cubeIdx!==plane-1) dispatchChangeHiPS({plotId:plot.plotId, cubeIdx:plane-1}); + } + else { + const primeIdx= convertHDUIdxToImageIdx(pv,getHDUIndex(pv,plot),plane-1); + if (pv.primeIdx!==primeIdx) dispatchChangePrimePlot({plotId:plot.plotId,primeIdx}); + } return retData(); } @@ -119,6 +129,10 @@ function zAxisExtractionTableWatcher(action,cancelSelf,{tbl_id, drawLayerId=unde function isTargetCube(tbl_id) { const {table,pv,plot,extractionType,hdus,hduNum}= getInfo(tbl_id); if (!pv || !plot|| !table || extractionType!=='z-axis') return; + if (isHiPS(plot)) { + const wpt= parseWorldPt(getMetaEntry(table,MetaConst.FITS_WORLD_PT)); + return isHiPSFitsCube(plot) && Boolean(wpt) && getColumnIdx(table, 'plane')>=0; + } if (!isCube(plot)) return false; const imPt= parseImagePt(getMetaEntry(table,MetaConst.FITS_IM_PT)); if (isEmpty(hdus) || !imPt || !Object.keys(hdus).includes(hduNum+'')) return false; @@ -128,7 +142,7 @@ function isTargetCube(tbl_id) { function getInfo(tbl_id) { const table= getTblById(tbl_id); - const pv= findPlotViewUsingFitsPathMeta(table); + const pv= findPlotViewUsingFitsPathMeta(table) ?? findHiPSPlotViewUsingTileUrlMeta(table); if (!pv || !table || !getMetaEntry(table, MetaConst.FITS_EXTRACTION_TYPE)) return {}; const plot= primePlot(pv); @@ -143,6 +157,17 @@ function getInfo(tbl_id) { } +function findHiPSPlotViewUsingTileUrlMeta(table) { + const tileUrl= getMetaEntry(table,MetaConst.HIPS_TILE_URL); + if (!tileUrl) return; + return getPlotViewAry(visRoot()).find( (pv) => { + const plot= primePlot(pv); + if (!isHiPSFitsCube(plot)) return false; + const root= plot.hipsUrlRoot.endsWith('/') ? plot.hipsUrlRoot : plot.hipsUrlRoot+'/'; + return tileUrl.startsWith(root); + }); +} + function getHDUs(table) { const hduPairs= getMetaEntry(table,MetaConst.FITS_IMAGE_HDU); return hduPairs.split(';').reduce( (res,str) => { diff --git a/src/firefly/js/visualize/ui/extraction/__tests__/ExtractionUIUtil-test.js b/src/firefly/js/visualize/ui/extraction/__tests__/ExtractionUIUtil-test.js new file mode 100644 index 0000000000..0792b070e9 --- /dev/null +++ b/src/firefly/js/visualize/ui/extraction/__tests__/ExtractionUIUtil-test.js @@ -0,0 +1,193 @@ +import CoordinateSys from '../../../CoordSys.js'; +import CysConverter from '../../../CsysConverter.js'; +import * as HiPSUtil from '../../../HiPSUtil.js'; +import {makeImagePt, makeWorldPt} from '../../../Point.js'; +import {makeExtractPtAry, makeHiPSLineExtractionTitle, makeHiPSLinePtAry} from '../ExtractionUIUtil.js'; + + +/* + * The tests stub the coordinate converter and the HiPS tile lookup so the results can be computed by hand: + * - an image point (x,y) is the world point (x/1000, y/1000), so 1 image unit is .001 degrees. + * An image point with x<0 has no world point, a world point with x<0 has no image point. + * - a FITS tile pixel is .001 degrees, and covers 2x2 image units, so tile pixel= (floor(x/2), floor(y/2)) + * - each tile is 100 image units wide, there is no tile at y >= 50 + */ +const IMAGE_SCALE= 1000; +const isWorldPt= (pt) => pt.type===makeWorldPt(0,0).type; + +const stubConverter= () => { + jest.spyOn(CysConverter, 'make').mockImplementation( () => ({ + getWorldCoords: (pt) => { + if (isWorldPt(pt)) return pt; + return pt.x<0 ? null : makeWorldPt(pt.x/IMAGE_SCALE, pt.y/IMAGE_SCALE, CoordinateSys.EQ_J2000); + }, + getImageCoords: (pt) => { + if (!isWorldPt(pt)) return pt; + return pt.x<0 ? null : makeImagePt(pt.x*IMAGE_SCALE+.4, pt.y*IMAGE_SCALE+.7); + }, + })); +}; + +const stubTiles= () => { + jest.spyOn(HiPSUtil, 'getTilePixelAngSize').mockReturnValue(1/IMAGE_SCALE); + jest.spyOn(HiPSUtil, 'getHiPSFitsTileLocation').mockImplementation( (plot, wpt, norder) => { + const x= Math.round(wpt.x*IMAGE_SCALE); + const y= Math.round(wpt.y*IMAGE_SCALE); + if (y>=50) return {}; + const ipix= Math.floor(x/100); + return {norder, ipix, tileUrl: `http://hips/Norder${norder}/Npix${ipix}.fits`, + tilePt: makeImagePt(Math.floor(x/2), Math.floor(y/2))}; + }); +}; + +const makeHiPSPlot= ({cubeDepth=1, cubeIdx=0}={}) => + ({plotId:'hips', plotType:'hips', plotImageId:'hips-image', cubeDepth, cubeIdx, hipsProperties:{}}); + +const makeImagePlot= (wcs) => ({ + plotId:'image', plotType:'image', plotImageId:'image-0', header:{}, + projection: wcs ? {isSpecified: () => true, isImplemented: () => true, coordSys: {isCelestial: () => true}} : undefined, +}); + +const tileKey= ({tileUrl,tilePt}) => `${tileUrl};${tilePt.x},${tilePt.y}`; + +beforeEach(() => { + stubConverter(); + stubTiles(); +}); +afterEach(() => jest.restoreAllMocks()); + + +describe('Test ExtractionUIUtil.makeHiPSLinePtAry()', () => { + const NORDER= 5; + + test('points are about one tile pixel apart and a point in the same tile pixel is skipped', () => { + // 10 image units is .01 deg, so 10 steps, the tile pixel changes every 2 steps + const {imPtAry, hipsTileLocAry, hipsWptAry}= + makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(0,0), makeImagePt(10,0), NORDER); + expect(imPtAry.map( (pt) => pt.x)).toEqual([0,2,4,6,8,10]); + expect(hipsTileLocAry.map( ({tilePt}) => tilePt.x)).toEqual([0,1,2,3,4,5]); + expect(hipsTileLocAry.every( (tileLoc) => tileLoc.norder===NORDER)).toBe(true); + expect(hipsWptAry.map( (wpt) => wpt.x)).toEqual([0,.002,.004,.006,.008,.01]); + }); + + test('the three arrays are in the same order', () => { + const {imPtAry, hipsTileLocAry, hipsWptAry}= + makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(90,10), makeImagePt(120,30), NORDER); + expect(imPtAry.length).toBeGreaterThan(1); + expect(hipsTileLocAry.length).toBe(imPtAry.length); + expect(hipsWptAry.length).toBe(imPtAry.length); + imPtAry.forEach( (imPt,i) => { + expect(hipsWptAry[i].x).toBeCloseTo(imPt.x/IMAGE_SCALE, 9); + expect(hipsWptAry[i].y).toBeCloseTo(imPt.y/IMAGE_SCALE, 9); + expect(hipsTileLocAry[i].tilePt.x).toBe(Math.floor(Math.round(imPt.x)/2)); + }); + }); + + test('a line that crosses into another tile has points in both tiles', () => { + const {hipsTileLocAry}= makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(90,0), makeImagePt(110,0), NORDER); + expect(new Set(hipsTileLocAry.map( ({tileUrl}) => tileUrl))) + .toEqual(new Set([`http://hips/Norder${NORDER}/Npix0.fits`, `http://hips/Norder${NORDER}/Npix1.fits`])); + }); + + test('no two points in a row are in the same tile pixel', () => { + const {hipsTileLocAry}= makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(0,0), makeImagePt(37,23), NORDER); + hipsTileLocAry.slice(1).forEach( (tileLoc,i) => expect(tileKey(tileLoc)).not.toBe(tileKey(hipsTileLocAry[i]))); + }); + + test('points that are not in a tile are skipped', () => { + // there is no tile at y>=50, so the line from y=0 to y=100 only has points in tile pixel rows 0 to 24 + const {imPtAry, hipsTileLocAry}= makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(0,0), makeImagePt(0,100), NORDER); + expect(imPtAry.every( (pt) => Math.round(pt.y)<50)).toBe(true); + expect(hipsTileLocAry.map( ({tilePt}) => tilePt.y)).toEqual([...Array(25)].map( (v,i) => i)); + }); + + test('a line with a start or end point outside the projection has no points', () => { + const empty= {imPtAry:[], hipsTileLocAry:[], hipsWptAry:[]}; + expect(makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(-1,0), makeImagePt(10,0), NORDER)).toEqual(empty); + expect(makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(0,0), makeImagePt(-10,0), NORDER)).toEqual(empty); + }); + + test('a line with the same start and end point has one point', () => { + const {imPtAry}= makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(4,4), makeImagePt(4,4), NORDER); + expect(imPtAry).toEqual([makeImagePt(4,4)]); + }); + + test('a long line is limited in the number of points', () => { + // 20000 image units would be 10000 tile pixels without the limit + const {imPtAry}= makeHiPSLinePtAry(makeHiPSPlot(), makeImagePt(0,0), makeImagePt(20000,0), NORDER); + expect(imPtAry.length).toBeLessThan(10000); + expect(imPtAry[0].x).toBe(0); + expect(imPtAry[imPtAry.length-1].x).toBe(20000); + }); +}); + + +describe('Test ExtractionUIUtil.makeHiPSLineExtractionTitle()', () => { + const wptAry= [makeWorldPt(10.123456,-20.5), makeWorldPt(11,-21.25)]; + test('HiPS that is not a cube', () => + expect(makeHiPSLineExtractionTitle(makeHiPSPlot(), wptAry)) + .toBe('Line Extract Preview - (10.12346,-20.50000) to (11.00000,-21.25000)')); + test('HiPS cube shows the plane', () => + expect(makeHiPSLineExtractionTitle(makeHiPSPlot({cubeDepth:3, cubeIdx:1}), wptAry)) + .toBe('Line Extract Preview - Plane #2 - (10.12346,-20.50000) to (11.00000,-21.25000)')); + test('one point', () => + expect(makeHiPSLineExtractionTitle(makeHiPSPlot(), [wptAry[0]])) + .toBe('Line Extract Preview - (10.12346,-20.50000) to (10.12346,-20.50000)')); +}); + + +describe('Test ExtractionUIUtil.makeExtractPtAry()', () => { + const NORDER= 3; + + test('undefined plot', () => expect(makeExtractPtAry(undefined, [makeWorldPt(1,1)], NORDER)) + .toEqual({foundPts:false, imPtAry:[]})); + + test('HiPS, the image point is truncated and the arrays are in the same order', () => { + const ptAry= [makeWorldPt(.004,.002), makeWorldPt(.0101,.0203)]; + const {foundPts, imPtAry, hipsTileLocAry, wptAry}= makeExtractPtAry(makeHiPSPlot(), ptAry, NORDER); + expect(foundPts).toBe(true); + expect(imPtAry).toEqual([makeImagePt(4,2), makeImagePt(10,20)]); + expect(wptAry).toEqual(ptAry); + expect(hipsTileLocAry.map( ({tilePt}) => [tilePt.x,tilePt.y])).toEqual([[2,1],[5,10]]); + expect(hipsTileLocAry.every( (tileLoc) => tileLoc.norder===NORDER)).toBe(true); + }); + + test('HiPS, points outside the projection or not in a tile are removed', () => { + const ptAry= [makeWorldPt(-.001,0), makeWorldPt(.006,.004), makeWorldPt(.001,.08)]; + const {foundPts, imPtAry, hipsTileLocAry, wptAry}= makeExtractPtAry(makeHiPSPlot(), ptAry, NORDER); + expect(foundPts).toBe(true); + expect(imPtAry).toEqual([makeImagePt(6,4)]); + expect(wptAry).toEqual([ptAry[1]]); + expect(hipsTileLocAry.length).toBe(1); + }); + + test('HiPS, no points in a tile', () => { + const {foundPts, imPtAry, hipsTileLocAry, wptAry}= makeExtractPtAry(makeHiPSPlot(), [makeWorldPt(.001,.08)], NORDER); + expect(foundPts).toBe(false); + expect(imPtAry).toEqual([]); + expect(hipsTileLocAry).toEqual([]); + expect(wptAry).toEqual([]); + }); + + test('image with a WCS, the world points are in the same order as the image points', () => { + const ptAry= [makeWorldPt(.004,.002), makeImagePt(-5,3), makeWorldPt(.0101,.0203)]; + const {foundPts, imPtAry, hipsTileLocAry, wptAry}= makeExtractPtAry(makeImagePlot(true), ptAry, NORDER); + expect(foundPts).toBe(true); + expect(imPtAry).toEqual([makeImagePt(4,2), makeImagePt(10,20)]); // the point with no world point is removed + expect(wptAry).toEqual([ptAry[0], ptAry[2]]); + expect(hipsTileLocAry).toBeUndefined(); + }); + + test('image without a WCS has no world points', () => { + const ptAry= [makeImagePt(3.9,7.2), makeImagePt(-5,3)]; + const {foundPts, imPtAry, wptAry}= makeExtractPtAry(makeImagePlot(false), ptAry, NORDER); + expect(foundPts).toBe(true); + expect(imPtAry).toEqual([makeImagePt(3,7), makeImagePt(-5,3)]); + expect(wptAry).toBeUndefined(); + }); + + test('no points', () => { + expect(makeExtractPtAry(makeImagePlot(false), [], NORDER).foundPts).toBe(false); + expect(makeExtractPtAry(makeHiPSPlot(), [], NORDER).foundPts).toBe(false); + }); +}); From 861690f61d0abb445472aee4238453f60e2b22bc Mon Sep 17 00:00:00 2001 From: roby Date: Fri, 23 Oct 2026 14:59:23 -0600 Subject: [PATCH 2/2] Firefly-1960: fixed extraction type issue --- .../edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java | 2 +- .../edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java index 890957c8eb..2ecc1044e1 100644 --- a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java +++ b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsExtract.java @@ -92,7 +92,7 @@ private static Number doResult(SumResult r, Function f) { private static Class arrayTypeFromBitpix(Header header) { return switch (FitsReadUtil.getBitPix(header)) { case -32 -> Float.TYPE; - case 8, 16, 32 -> Integer.TYPE; + case 8, 16, 32 -> FitsReadUtil.hasScaling(header) ? Float.TYPE : Integer.TYPE; case 64 -> Long.TYPE; default -> Double.TYPE; }; diff --git a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java index c8dd28460a..b2ecf9861c 100644 --- a/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java +++ b/src/firefly/java/edu/caltech/ipac/visualize/plot/plotdata/FitsReadUtil.java @@ -437,6 +437,7 @@ public static int getNaxisN(Header h, int num, boolean compressed) { public static int getZNaxisN(Header h, int axis) { return h.getIntValue("ZNAXIS"+axis,-1); } public static double getBscale(Header h) { return h.getDoubleValue(Standard.BSCALE, 1.0); } public static double getBzero(Header h) { return h.getDoubleValue(Standard.BZERO, 0.0); } + public static boolean hasScaling(Header h) { return getBscale(h) != 1.0 || getBzero(h) != 0.0; } public static double getBlankValue(Header h) { // blank value is only applicable to integer values (BITPIX > 0) return getBitPix(h) > 0 ? h.getDoubleValue(Standard.BLANK, Double.NaN) : Double.NaN;