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..981361a267 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; @@ -34,7 +35,6 @@ import edu.caltech.ipac.visualize.plot.ImagePt; import edu.caltech.ipac.visualize.plot.plotdata.FitsExtract; import edu.caltech.ipac.visualize.plot.plotdata.FitsRead; -import edu.caltech.ipac.visualize.plot.plotdata.FitsReadUtil; import nom.tam.fits.Fits; import java.io.BufferedReader; @@ -47,7 +47,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 +135,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(); @@ -294,20 +324,18 @@ public static WebPlotResult crop(PlotState state, ImagePt c1, ImagePt c2, boolea Fits cropFits; - boolean saveCropFits = true; if (state.isMultiImageFile(bands[i])) { if (cropMultiAll) { File originalFile = PlotStateUtil.getOriginalFile(state, bands[i]); CropFile.crop_extensions(originalFile.getPath(), cropFile.getPath(), (int) c1.getX(), (int) c1.getY(), (int) c2.getX(), (int) c2.getY()); - cropFits = new Fits(cropFile); - saveCropFits = false; } else { Fits fits = new Fits(PlotStateUtil.getWorkingFitsFile(state, bands[i])); cropFits = CropFile.do_crop(fits, state.getImageIdx(bands[i]) + 1, (int) c1.getX(), (int) c1.getY(), (int) c2.getX(), (int) c2.getY()); + cropFits.write(cropFile); } } else { @@ -324,14 +352,9 @@ public static WebPlotResult crop(PlotState state, ImagePt c1, ImagePt c2, boolea cropFits = CropFile.do_crop(fits, (int) c1.getX(), (int) c1.getY(), (int) c2.getX(), (int) c2.getY()); } + cropFits.write(cropFile); } - FitsRead[] fr= FitsCacher.loadFits(cropFits, cropFile).getFitReadAry(); - - - if (saveCropFits) FitsReadUtil.writeFitsFileForCropOnly(cropFile,fr,cropFits); - - String fReq = ServerContext.replaceWithPrefix(cropFile); cropRequest[i] = WebPlotRequest.makeFilePlotRequest(fReq, 1); 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..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 @@ -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"); @@ -84,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; }; @@ -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..63c9b0dbfb 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 @@ -405,16 +405,6 @@ public static ImageHDU makeEmptyImageHDU(Header newHeader) { return new ImageHDU(newHeader, null); } - public static void writeFitsFileForCropOnly(File outfile, FitsRead[] fitsReadAry, Fits refFits) throws IOException { - Fits outputFits = new Fits(); - for (FitsRead fr : fitsReadAry) { - BasicHDU refHdu = refFits.getHDU(0); - ImageHDU imageHDU = makeImageHDU(refHdu.getHeader(), FitsReadUtil.getImageData(refHdu, fr.getDataFloatForOlderUtils())); - outputFits.addHDU(imageHDU); - } - outputFits.write(outfile); - } - public static int getBitPix(Header h) {return h.getIntValue(Standard.BITPIX); } public static int getNaxis(Header h) { return h.getIntValue(Standard.NAXIS, 0); } public static int getZNaxis(Header h) { return h.getIntValue("ZNAXIS", 0); } @@ -437,6 +427,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; @@ -444,6 +435,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/util/ObsCoreSRegionParser.js b/src/firefly/js/util/ObsCoreSRegionParser.js index 640b6be114..8b648b5c2f 100644 --- a/src/firefly/js/util/ObsCoreSRegionParser.js +++ b/src/firefly/js/util/ObsCoreSRegionParser.js @@ -55,7 +55,7 @@ const getUnitType = (coordSys) => { }; const getDim = (dim, coordSys, unit, toUnit) => { - return getUnitType() === ShapeDataObj.UnitType.IMAGE_PIXEL ? dim : convertAngle(unit, toUnit.key, Number(dim)); + return getUnitType() === ShapeDataObj.UnitType.IMAGE_PIXEL ? dim : convertAngle(unit, toUnit.key, Number(dim), false); }; diff --git a/src/firefly/js/visualize/CoordSys.js b/src/firefly/js/visualize/CoordSys.js index 4e9d0ef6a7..96fb6d209f 100644 --- a/src/firefly/js/visualize/CoordSys.js +++ b/src/firefly/js/visualize/CoordSys.js @@ -58,7 +58,7 @@ export const CoordinateSys = function () { const parse= (desc) => { if (!desc) return undefined; - desc= normalizeCoordSysStr(desc); + desc= normalizeCoordSysStr(desc).trim().toUpperCase(); if (desc===EQ_J2000.toString() || desc==='EQJ2000' ||desc==='EQJ' || desc==='J2000' || desc==='ICRS') { return EQ_J2000; } else if (desc===EQ_B2000.toString() || desc==='EQB2000' ) { @@ -81,6 +81,8 @@ export const CoordinateSys = function () { return ZEROBASED; } else if (desc===FITSPIXEL.toString()) { return FITSPIXEL; + } else if (desc===UNDEFINED.toString()) { + return UNDEFINED; } else { return undefined; } diff --git a/src/firefly/js/visualize/CoordUtil.js b/src/firefly/js/visualize/CoordUtil.js index 6c44ff6b5d..6ea61f5f10 100644 --- a/src/firefly/js/visualize/CoordUtil.js +++ b/src/firefly/js/visualize/CoordUtil.js @@ -116,13 +116,13 @@ const sex2dd = function (coordstr, islat, isequ) { const sepJoin= sep.join(''); // join all the separators for easier testing if (part.length===3) { if (sepJoin==='hms' || sepJoin==='hm ') degrees = 0; - else if (sepJoin==='dms' || sepJoin==='dm ' || sepJoin===`d'"` || sepJoin===`d' `) degrees = 1;// eslint-disable-line @stylistic/js/quotes + else if (sepJoin==='dms' || sepJoin==='dm ' || sepJoin===`d'"` || sepJoin===`d' `) degrees = 1; else if (sepJoin===':: ' || sepJoin===' ') degrees = -1; else throw INVALID_SEPARATOR; } else if (part.length===2) { if (sepJoin==='hm' || sepJoin==='h ') degrees = 0; - else if (sepJoin==='dm' || sepJoin==='d ' || sepJoin===`d'` || sepJoin===`'"` || sepJoin===`' `) degrees = 1;// eslint-disable-line @stylistic/js/quotes + else if (sepJoin==='dm' || sepJoin==='d ' || sepJoin===`d'` || sepJoin===`'"` || sepJoin===`' `) degrees = 1; else if (sepJoin==='ms' || sepJoin==='m ' || sepJoin===': ' || sepJoin===' ') degrees = -1; else throw INVALID_SEPARATOR; } @@ -137,7 +137,7 @@ const sex2dd = function (coordstr, islat, isequ) { isdec= !(isequ && !islat); } else if (sep[0] ==='m' || sep[0] ==='s') { degrees = -1; - } else if (sep[0] ===`'` || sep[0] ==='"') {// eslint-disable-line @stylistic/js/quotes + } else if (sep[0] ===`'` || sep[0] ==='"') { degrees = 1; } else { throw INVALID_SEPARATOR; diff --git a/src/firefly/js/visualize/HiPSListUtil.js b/src/firefly/js/visualize/HiPSListUtil.js index 60af079ec5..cee48233a8 100644 --- a/src/firefly/js/visualize/HiPSListUtil.js +++ b/src/firefly/js/visualize/HiPSListUtil.js @@ -6,7 +6,7 @@ import {makeTblRequest, MAX_ROW} from '../tables/TableRequestUtil.js'; import {getColumnIdx, doFetchTable} from '../tables/TableUtil.js'; import {ServerParams} from '../data/ServerParams.js'; import {isBlankHiPSURL} from './WebPlot.js'; -import {fixHiPSRoot} from '../visualize/WebPlotRequest'; +import {fixHiPSRoot} from './WebPlotRequest'; /** * @typedef HiPSDataType 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..4acb2361f6 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 @@ -777,7 +797,7 @@ function getHipsPixelCutRangeValues(plot) { const max= Number(stretchValues[1]); if (!isNaN(min) && !isNaN(max) && min < max) { const algorithm= (stretchValues.length>2 && RangeValues.isAlgorithmSupported(stretchValues[2])) - ? stretchValues[2] + ? stretchValues[2].toLowerCase() : 'log'; const rv= RangeValues.makeSimple(ABSOLUTE_STR,min,max,algorithm); if (algorithm==='asinh') rv.asinhQValue= 1; 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/Point.js b/src/firefly/js/visualize/Point.js index ac06b7168f..c09bda9278 100644 --- a/src/firefly/js/visualize/Point.js +++ b/src/firefly/js/visualize/Point.js @@ -133,7 +133,7 @@ export class SimplePt { static make(x, y, type) { const pt = new SimplePt(x, y); - if (type && Object.keys(Point).includes(type)) { + if (type && Object.values(Point).includes(type)) { pt.type = type; } return pt; @@ -391,7 +391,7 @@ export function makeAnyPt(x,y,coordSys) { case CoordinateSys.PIXEL: return makeImagePt(x, y); case CoordinateSys.ZEROBASED: return makeZeroBasedImagePt(x, y); case CoordinateSys.FITSPIXEL: return makeFitsImagePt(x, y); - default: return makeWorldPt(x,y,coordSys,true); + default: return makeWorldPt(x,y,csys,true); } } diff --git a/src/firefly/js/visualize/RangeValues.js b/src/firefly/js/visualize/RangeValues.js index 591ae0bc57..c85a3e4189 100644 --- a/src/firefly/js/visualize/RangeValues.js +++ b/src/firefly/js/visualize/RangeValues.js @@ -47,6 +47,9 @@ const boundsStrToConst = { const RGB_PRESERVE_HUE_DEFAULT= 0; // user 0 for false +// lowerWhich, lowerValue, upperWhich, upperValue, algorithm, zscaleContrast, zscaleSamples, zscaleSamplesPerLine +const REQUIRED_PARAM_IDX= [0,1,2,3,6,7,8,9]; + export class RangeValues { constructor( lowerWhich= PERCENTAGE, lowerValue= 1.0, @@ -194,7 +197,7 @@ export class RangeValues { */ static makeSimple(boundsType= PERCENTAGE_STR, lowerValue= 1.0, - upperValue= 1.0, + upperValue= 99.0, algorithm= LINEAR_STR) { let btValue= PERCENTAGE; @@ -225,7 +228,7 @@ export class RangeValues { */ static serializeSimple(boundsType= PERCENTAGE_STR, lowerValue= 1.0, - upperValue= 1.0, + upperValue= 99.0, algorithm= LINEAR_STR ) { const rv= RangeValues.makeSimple(boundsType,lowerValue,upperValue,algorithm); return rv.toJSON(); @@ -246,7 +249,8 @@ export class RangeValues { const params= sIn.split(',').map( (v) => Number.parseFloat(v) ); - const valid= params.every( (v)=> typeof v !== 'undefined'); + if (params.length !isNaN(params[idx]) ); return valid ? new RangeValues(...params) : undefined; } @@ -272,7 +276,7 @@ export class RangeValues { } static isAlgorithmSupported(a) { - return Boolean(alStrToConst[a]); + return Boolean(alStrToConst[a?.toLowerCase()]); } } diff --git a/src/firefly/js/visualize/VisUtil.js b/src/firefly/js/visualize/VisUtil.js index 9bed4d0aba..cd8e8a63e4 100644 --- a/src/firefly/js/visualize/VisUtil.js +++ b/src/firefly/js/visualize/VisUtil.js @@ -108,12 +108,12 @@ export function getLonDist(lon1,lon2) { if (lon1>lon2) { const diff= lon1-lon2; if (diff<=180) return -diff; - else return ( (360-lon1) - lon2); + else return ( (360-lon1) + lon2); } else { const diff= lon2-lon1; if (diff<=180) return diff; - else return -1*( (360-lon2) - lon1); + else return -1*( (360-lon2) + lon1); } } @@ -144,6 +144,29 @@ export function convertCelestial(wpt, to= CoordinateSys.EQ_J2000) { const convertToJ2000= (wpt) => convertCelestial(wpt); +/** + * Find the center of the smallest longitude range that holds all the longitudes. The range is everything outside + * the largest gap between neighboring longitudes, so it works across the 0/360 line. + * @param {Array.} lons + * @return {number} center longitude in [0,360) + */ +function computeCentralLon(lons) { + const sortedLons= lons.map( (lon) => ((lon%360)+360)%360 ).sort( (lon1,lon2) => lon1-lon2 ); + const lastIdx= sortedLons.length-1; + let gapIdx= -1; // -1 is the gap that crosses the 0/360 line + let maxGap= sortedLons[0] + 360 - sortedLons[lastIdx]; + for(let i=0; imaxGap) { + maxGap= gap; + gapIdx= i; + } + } + if (gapIdx===-1) return (sortedLons[0] + sortedLons[lastIdx]) / 2; + const centralLon= (sortedLons[gapIdx+1] + sortedLons[gapIdx] + 360) / 2; + return centralLon>=360 ? centralLon-360 : centralLon; +} + /** * Find an approximate central point and search radius for a group of positions * @@ -151,28 +174,17 @@ const convertToJ2000= (wpt) => convertCelestial(wpt); * @return {{centralPoint:WorldPt, maxRadius: Number}} */ export function computeCentralPointAndRadius(inPoints) { - let lon; - let radius; let maxRadius = Number.NEGATIVE_INFINITY; const points= inPoints .map((wp) => convertToJ2000(wp)) .filter(Boolean); + if (!points.length) return {centralPoint:undefined, maxRadius}; - - /* get max,min of lon and lat */ - let maxLon = Number.NEGATIVE_INFINITY; - let minLon = Number.POSITIVE_INFINITY; let maxLat = Number.NEGATIVE_INFINITY; let minLat = Number.POSITIVE_INFINITY; points.forEach((pt) => { - if (pt.x > maxLon) { - maxLon = pt.x; - } - if (pt.x < minLon) { - minLon = pt.x; - } if (pt.y > maxLat) { maxLat = pt.y; } @@ -180,18 +192,13 @@ export function computeCentralPointAndRadius(inPoints) { minLat = pt.y; } }); - if (maxLon - minLon > 180) { - minLon = 360 + minLon; - } - lon = (maxLon + minLon) / 2; - if (lon > 360) lon -= 360; const lat = (maxLat + minLat) / 2; - const centralPoint = makeWorldPt(lon, lat); + const centralPoint = makeWorldPt(computeCentralLon(points.map( (pt) => pt.x)), lat); points.forEach((pt) => { - radius = computeDistance(centralPoint, + const radius = computeDistance(centralPoint, makeWorldPt(pt.x, pt.y)); if (maxRadius < radius) { maxRadius = radius; @@ -245,8 +252,6 @@ function isOnePoint(wpList) { * @return {number} position angle in degrees between the two objects */ export function getPositionAngle(ra0, dec0, ra, dec) { - let sind, sinpa, cospa; - const alf = ra * DtoR; const alf0 = ra0 * DtoR; const del = dec * DtoR; @@ -261,15 +266,11 @@ export function getPositionAngle(ra0, dec0, ra, dec) { const dist = Math.acos(cosd); let pa = 0.0; if (dist > 0.0000004) { - sind = Math.sin(dist); - cospa = (sd * cd0 - cd * sd0 * cosda) / sind; - if (cospa > 1.0) cospa = 1.0; - if (cospa < -1.0) cospa = -1.0; - sinpa = cd * Math.sin(alf - alf0) / sind; - pa = Math.acos(cospa) * RtoD; - if (sinpa < 0.0) pa = 360.0 - (pa); + // atan2 keeps full precision near 0 and 180, where acos loses it + const eastComponent = cd * Math.sin(alf - alf0); + const northComponent = sd * cd0 - cd * sd0 * cosda; + pa = (Math.atan2(eastComponent, northComponent) * RtoD + 360) % 360; } - // dist *= RtoD; if (dec0===90) pa = 180.0; if (dec0===-90) pa = 0.0; @@ -378,7 +379,7 @@ export function getTranslateAndRotatePosition(referencePosition, rotatedReferenc * @param {number} phi the position angle in degrees to the second object * @return {WorldPt} WorldPt of the new object */ -const getNewPosition= function(ra, dec, dist, phi) { +function getNewPosition(ra, dec, dist, phi) { let tmp; let ra1; @@ -402,7 +403,7 @@ const getNewPosition= function(ra, dec, dist, phi) { } ra1 *= RtoD; return makeWorldPt(ra1, dec1); -}; +} @@ -461,34 +462,27 @@ export function linesIntersect1(a,b,c,d,p,q,r,s) { } } +/** + * Test to see if two line segments intersect. Segments that touch at an end point intersect, parallel and + * collinear segments do not. + * @param {number} x1 first segment start x + * @param {number} y1 first segment start y + * @param {number} x2 first segment end x + * @param {number} y2 first segment end y + * @param {number} x3 second segment start x + * @param {number} y3 second segment start y + * @param {number} x4 second segment end x + * @param {number} y4 second segment end y + * @return {boolean} true if segment (x1,y1)-(x2,y2) intersects segment (x3,y3)-(x4,y4) + */ export function lineIntersect2(x1,y1,x2,y2, x3,y3,x4,y4) { - var x=((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); - var y=((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)); - if (isNaN(x)||isNaN(y)) { - return false; - } else { - if (x1>=x2) { - if (!(x2<=x&&x<=x1)) {return false;} - } else { - if (!(x1<=x&&x<=x2)) {return false;} - } - if (y1>=y2) { - if (!(y2<=y&&y<=y1)) {return false;} - } else { - if (!(y1<=y&&y<=y2)) {return false;} - } - if (x3>=x4) { - if (!(x4<=x&&x<=x3)) {return false;} - } else { - if (!(x3<=x&&x<=x4)) {return false;} - } - if (y3>=y4) { - if (!(y4<=y&&y<=y3)) {return false;} - } else { - if (!(y3<=y&&y<=y4)) {return false;} - } - } - return true; + // t and u are how far along each segment the crossing is, 0 to 1 means it is on the segment. This avoids + // comparing a computed crossing point to the zero width range of a vertical or horizontal segment. + const denom= (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3); + if (!denom) return false; // parallel, collinear, or NaN input + const t= ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / denom; + const u= ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / denom; + return t>=0 && t<=1 && u>=0 && u<=1; } @@ -504,22 +498,23 @@ export function lineIntersect2(x1,y1,x2,y2, x3,y3,x4,y4) { */ export function contains(x0, y0, w0, h0, x, y) { return (x >= x0 && y >= y0 && x < x0 + w0 && y < y0 + h0); -}; +} /** - * test to see if the first rectangle contains the second rectangle + * test to see if the first rectangle contains the second rectangle. The second rectangle may reach the edges of + * the first, so a rectangle contains itself. * @param x0 the point x of the rec, top left * @param y0 the point y of the rec, top left * @param w0 the rec width * @param h0 the rec height * @param x the second point x, top left * @param y the second point y, top left - * @param w h the second rec width - * @param h the second rec height - * @return {boolean} true if rectangles intersect + * @param w the second rec width, not negative + * @param h the second rec height, not negative + * @return {boolean} true if the first rectangle contains the second */ export const containsRec= function(x0, y0, w0, h0, x, y, w, h) { - return contains(x0,y0,w0,h0,x,y) && contains(x0,y0,w0,h0,x+w,y+h); + return x >= x0 && y >= y0 && x + w <= x0 + w0 && y + h <= y0 + h0; }; export const containsCircle= function(x, y, centerX, centerY, radius) { @@ -786,8 +781,8 @@ export function isAngleUnit(unit) { * @param {string} to 'degree' or 'deg', 'arcmin', 'arcsec', 'radian' case insensitive * @param {*} angle number or string * @param {boolean} limitPrecision limit the number of digits after decimal point for arcmin and arcsec units - * by truncation. Set it to false if you want full precision or want to handle precision in the caller function - * in another way (like rounding instead of truncation). Default is true. + * by rounding. Set it to false if you want full precision or want to handle precision in the caller function + * in another way. Default is true. * @returns {number} */ export function convertAngle(from, to, angle, limitPrecision=true) { @@ -813,13 +808,11 @@ export function convertAngle(from, to, angle, limitPrecision=true) { const v= numAngle * Math.pow(60.0, (toIdx - fromIdx)); if (!limitPrecision) return v; - switch (to) { + switch (to.toLowerCase()) { case 'arcsec': - return Math.round(10*v)/10; // limit to 1 decimal places by truncating extra digits + return Math.round(10*v)/10; // limit to 1 decimal place case 'arcmin': - return Math.round(10000*v)/10000; // limit to 4 decimal places by truncating extra digits - case 'degree': - return v; + return Math.round(10000*v)/10000; // limit to 4 decimal places } return v; } @@ -867,7 +860,7 @@ export function findIntersectionPt(seg1x1, seg1y1, seg1x2, seg1y2, seg2x1, sec2y export function doSegmentsCross(seg1x1, seg1y1, seg1x2, seg1y2, seg2x1, sec2y2, seg2x2, seg2y2) { const result= findIntersectionPt(seg1x1, seg1y1, seg1x2, seg1y2, seg2x1, sec2y2, seg2x2, seg2y2); if (!result) return false; - return result.onSeg1 || result.onSeg2; + return result.onSeg1 && result.onSeg2; } export function lineCrossesRect(segX1, segY1, segX2, segY2, x, y, w,h) { @@ -971,13 +964,14 @@ export function pointInRec(point1, view_corners) { * @param pts * @param cc * @param pt - * @returns {number} + * @returns {number} the distance in screen pixels, NaN if a point is missing or can't be converted to screen coordinates */ export function distToLine(pts, cc, pt) { if (!pt || !pts || !pts[0] || !pts[1]) return NaN; const spt = cc ? cc.getScreenCoords(pt) : makeScreenPt(pt.x, pt.y); const pt0 = cc ? cc.getScreenCoords(pts[0]) : makeScreenPt(pts[0].x, pts[0].y); const pt1 = cc ? cc.getScreenCoords(pts[1]) : makeScreenPt(pts[1].x, pts[1].y); + if (!spt || !pt0 || !pt1) return NaN; // off the projection const e1 = makeScreenPt((pt1.x - pt0.x), (pt1.y - pt0.y)); const e2 = makeScreenPt((spt.x - pt0.x), (spt.y - pt0.y)); const e3 = makeScreenPt((pt0.x - pt1.x), (pt0.y - pt1.y)); @@ -1101,25 +1095,24 @@ export function getLinePointAry(pt1, pt2) { if (deltaX > deltaY) { const slope = (y2-y1)/(x2-x1); const yIntercept = y1-slope*x1; - const minX = Math.trunc(Math.min(x1, x2)); - const maxX = Math.trunc(Math.max(x1, x2)); + const minX = Math.floor(Math.min(x1, x2)); + const maxX = Math.floor(Math.max(x1, x2)); for (x=minX; x<=maxX; x+=1) { - y = Math.trunc(slope*x + yIntercept); + y = Math.floor(slope*x + yIntercept); pts.push(makeImagePt(x,y)); } return pts; } else if (y1!==y2) { const islope = (x2-x1)/(y2-y1); const xIntercept = x1-islope*y1; - const minY = Math.trunc(Math.min(y1, y2)); - const maxY = Math.trunc(Math.max(y1, y2)); + const minY = Math.floor(Math.min(y1, y2)); + const maxY = Math.floor(Math.max(y1, y2)); for (y=minY; y<=maxY; y+=1) { - x = Math.trunc(islope*y + xIntercept); + x = Math.floor(islope*y + xIntercept); pts.push(makeImagePt(x,y)); } return pts; } - return; } function getEllipsePointForAngle(cx, cy, rx, ry, rotationAngle, theta) { diff --git a/src/firefly/js/visualize/ZoomUtil.js b/src/firefly/js/visualize/ZoomUtil.js index 3a2bb17023..b4edb3959d 100644 --- a/src/firefly/js/visualize/ZoomUtil.js +++ b/src/firefly/js/visualize/ZoomUtil.js @@ -95,8 +95,7 @@ export function getEstimatedFullZoomFactor(plot, viewDim, fullType) { let overrideFullType= fullType; if (plot.attributes[PlotAttribute.EXPANDED_TO_FIT_TYPE]) { - const s= plot.attributes[PlotAttribute.EXPANDED_TO_FIT_TYPE]; - if (FullType.has(s)) overrideFullType= FullType.get(s); + overrideFullType= FullType.get(plot.attributes[PlotAttribute.EXPANDED_TO_FIT_TYPE]) ?? fullType; } return getEstimatedFullZoomFactorDetails(overrideFullType, plot.dataWidth, plot.dataHeight, width,height, isHiPS(plot) && isHiPSAitoff(plot)); diff --git a/src/firefly/js/visualize/__tests__/CoordSys-test.js b/src/firefly/js/visualize/__tests__/CoordSys-test.js new file mode 100644 index 0000000000..0d1fec14e2 --- /dev/null +++ b/src/firefly/js/visualize/__tests__/CoordSys-test.js @@ -0,0 +1,178 @@ +import CoordinateSys, { + ECLIPTIC_B, ECLIPTIC_J, EQUATORIAL_B, EQUATORIAL_J, GALACTIC_JSYS, NONCELESTIAL, SUPERGALACTIC_JSYS, + findCoordSys, findValidCoordSysStr +} from '../CoordSys.js'; + + +describe('Test CoordinateSys constants', () => { + + test('celestial coordinate systems', () => { + const expectedProps= [ + // [coordSys, toString, isEquatorial, jsys, equinox] + [CoordinateSys.EQ_J2000, 'EQ_J2000', true, EQUATORIAL_J, 2000], + [CoordinateSys.EQ_B2000, 'EQ_B2000', true, EQUATORIAL_B, 2000], + [CoordinateSys.EQ_B1950, 'EQ_B1950', true, EQUATORIAL_B, 1950], + [CoordinateSys.GALACTIC, 'GALACTIC', false, GALACTIC_JSYS, 2000], + [CoordinateSys.SUPERGALACTIC, 'SUPERGALACTIC', false, SUPERGALACTIC_JSYS, 2000], + [CoordinateSys.ECL_J2000, 'EC_J2000', false, ECLIPTIC_J, 2000], + [CoordinateSys.ECL_B1950, 'EC_B1950', false, ECLIPTIC_B, 1950], + ]; + expectedProps.forEach(([coordSys, desc, isEquatorial, jsys, equinox]) => { + expect(coordSys.toString()).toEqual(desc); + expect(coordSys.isEquatorial()).toBe(isEquatorial); + expect(coordSys.isCelestial()).toBe(true); + expect(coordSys.getJsys()).toEqual(jsys); + expect(coordSys.getEquinox()).toEqual(equinox); + }); + }); + + test('non-celestial coordinate systems', () => { + const expectedDesc= [ + [CoordinateSys.PIXEL, 'PIXEL'], + [CoordinateSys.SCREEN_PIXEL, 'SCREEN_PIXEL'], + [CoordinateSys.ZEROBASED, 'ZERO-BASED'], + [CoordinateSys.FITSPIXEL, 'FITSPIXEL'], + [CoordinateSys.UNDEFINED, 'UNDEFINED'], + ]; + expectedDesc.forEach(([coordSys, desc]) => { + expect(coordSys.toString()).toEqual(desc); + expect(coordSys.isEquatorial()).toBe(false); + expect(coordSys.isCelestial()).toBe(false); + expect(coordSys.getJsys()).toEqual(NONCELESTIAL); + expect(coordSys.getEquinox()).toEqual(0); + }); + }); +}); + + +describe('Test CoordinateSys.parse()', () => { + + test('parse() using the toString() of each coordinate system', () => { + const parseableCoordSysAry= [ + CoordinateSys.EQ_J2000, CoordinateSys.EQ_B2000, CoordinateSys.EQ_B1950, CoordinateSys.GALACTIC, + CoordinateSys.SUPERGALACTIC, CoordinateSys.ECL_J2000, CoordinateSys.ECL_B1950, CoordinateSys.PIXEL, + CoordinateSys.SCREEN_PIXEL, CoordinateSys.ZEROBASED, CoordinateSys.FITSPIXEL, CoordinateSys.UNDEFINED, + ]; + parseableCoordSysAry.forEach((coordSys) => expect(CoordinateSys.parse(coordSys.toString())).toBe(coordSys)); + }); + + test('parse() using equatorial aliases', () => { + ['EQJ2000', 'EQJ', 'J2000', 'J', 'ICRS', 'eq', 'equatorial', 'equatorial j2000', 'equ j', 'eq_j2000'] + .forEach((desc) => expect(CoordinateSys.parse(desc)).toBe(CoordinateSys.EQ_J2000)); + ['EQB1950', 'EQB', 'B1950', 'b', 'equatorial b1950', 'eq_b1950'] + .forEach((desc) => expect(CoordinateSys.parse(desc)).toBe(CoordinateSys.EQ_B1950)); + expect(CoordinateSys.parse('EQB2000')).toBe(CoordinateSys.EQ_B2000); + }); + + test('parse() using galactic aliases', () => { + ['GAL', 'gal', 'galactic', 'G', ' galactic '] + .forEach((desc) => expect(CoordinateSys.parse(desc)).toBe(CoordinateSys.GALACTIC)); + }); + + test('parse() using ecliptic aliases', () => { + ['ECJ2000', 'ECLJ2000', 'ECJ', 'ecliptic', 'ecl', 'ecl j2000', 'ECL_J2000'] + .forEach((desc) => expect(CoordinateSys.parse(desc)).toBe(CoordinateSys.ECL_J2000)); + ['ECLB1950', 'ECB', 'ecl b1950', 'ecliptic b'] + .forEach((desc) => expect(CoordinateSys.parse(desc)).toBe(CoordinateSys.ECL_B1950)); + }); + + test('parse() ignores case and surrounding spaces', () => { + expect(CoordinateSys.parse('icrs')).toBe(CoordinateSys.EQ_J2000); + expect(CoordinateSys.parse('eq_b2000')).toBe(CoordinateSys.EQ_B2000); + expect(CoordinateSys.parse('SuperGalactic')).toBe(CoordinateSys.SUPERGALACTIC); + expect(CoordinateSys.parse('pixel')).toBe(CoordinateSys.PIXEL); + expect(CoordinateSys.parse('zero-based')).toBe(CoordinateSys.ZEROBASED); + expect(CoordinateSys.parse(' fitspixel ')).toBe(CoordinateSys.FITSPIXEL); + expect(CoordinateSys.parse('screen_pixel')).toBe(CoordinateSys.SCREEN_PIXEL); + }); + + test('parse() using ZERO_BASED alias', () => { + expect(CoordinateSys.parse('ZERO_BASED')).toBe(CoordinateSys.ZEROBASED); + }); + + test('parse() with unrecognized or empty input returns undefined', () => { + expect(CoordinateSys.parse(undefined)).toBeUndefined(); + expect(CoordinateSys.parse('')).toBeUndefined(); + expect(CoordinateSys.parse('nonsense')).toBeUndefined(); + expect(CoordinateSys.parse('equatorial x2000')).toBeUndefined(); + }); + + test('parse() using UNDEFINED returns the UNDEFINED coordinate system', () => { + expect(CoordinateSys.parse('UNDEFINED')).toBe(CoordinateSys.UNDEFINED); + expect(CoordinateSys.parse('undefined')).toBe(CoordinateSys.UNDEFINED); + }); +}); + + +describe('Test CoordSys.findCoordSys()', () => { + + test('findCoordSys() using parameters (jsys, equinox)', () => { + expect(findCoordSys(EQUATORIAL_J, 2000)).toBe(CoordinateSys.EQ_J2000); + expect(findCoordSys(EQUATORIAL_B, 2000)).toBe(CoordinateSys.EQ_B2000); + expect(findCoordSys(EQUATORIAL_B, 1950)).toBe(CoordinateSys.EQ_B1950); + expect(findCoordSys(ECLIPTIC_J, 2000)).toBe(CoordinateSys.ECL_J2000); + expect(findCoordSys(ECLIPTIC_B, 1950)).toBe(CoordinateSys.ECL_B1950); + }); + + test('findCoordSys() galactic and supergalactic ignore equinox', () => { + expect(findCoordSys(GALACTIC_JSYS, 2000)).toBe(CoordinateSys.GALACTIC); + expect(findCoordSys(GALACTIC_JSYS, 1950)).toBe(CoordinateSys.GALACTIC); + expect(findCoordSys(SUPERGALACTIC_JSYS, 0)).toBe(CoordinateSys.SUPERGALACTIC); + }); + + test('findCoordSys() with an unknown combination returns UNDEFINED', () => { + expect(findCoordSys(EQUATORIAL_J, 1950)).toBe(CoordinateSys.UNDEFINED); + expect(findCoordSys(ECLIPTIC_B, 2000)).toBe(CoordinateSys.UNDEFINED); + expect(findCoordSys(NONCELESTIAL, 0)).toBe(CoordinateSys.UNDEFINED); + }); + + test('findCoordSys() round trips getJsys() and getEquinox()', () => { + [CoordinateSys.EQ_J2000, CoordinateSys.EQ_B2000, CoordinateSys.EQ_B1950, CoordinateSys.GALACTIC, + CoordinateSys.SUPERGALACTIC, CoordinateSys.ECL_J2000, CoordinateSys.ECL_B1950] + .forEach((coordSys) => expect(findCoordSys(coordSys.getJsys(), coordSys.getEquinox())).toBe(coordSys)); + }); +}); + + +describe('Test CoordSys.findValidCoordSysStr()', () => { + + test('findValidCoordSysStr() with a combined system and equinox', () => { + expect(findValidCoordSysStr('EQJ2000')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('eq_b1950')).toEqual('EQ_B1950'); + expect(findValidCoordSysStr('ECLIPTICB')).toEqual('EC_B1950'); + expect(findValidCoordSysStr('ecj')).toEqual('EC_J2000'); + }); + + test('findValidCoordSysStr() with a system and equinox separated by a space', () => { + expect(findValidCoordSysStr('equatorial b1950')).toEqual('EQ_B1950'); + expect(findValidCoordSysStr('EQ J')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('ecliptic b')).toEqual('EC_B1950'); + expect(findValidCoordSysStr('ECL J2000')).toEqual('EC_J2000'); + }); + + test('findValidCoordSysStr() with a system only defaults to J2000', () => { + expect(findValidCoordSysStr('equatorial')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('E')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('ecliptic')).toEqual('EC_J2000'); + }); + + test('findValidCoordSysStr() with an equinox only is equatorial', () => { + expect(findValidCoordSysStr('j2000')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('J20')).toEqual('EQ_J2000'); + expect(findValidCoordSysStr('B1950')).toEqual('EQ_B1950'); + expect(findValidCoordSysStr('b19')).toEqual('EQ_B1950'); + }); + + test('findValidCoordSysStr() with galactic', () => { + ['galactic', 'GAL', 'ga', 'G'].forEach((desc) => expect(findValidCoordSysStr(desc)).toEqual('GALACTIC')); + }); + + test('findValidCoordSysStr() with unrecognized input returns undefined', () => { + expect(findValidCoordSysStr('equatorial x')).toBeUndefined(); + expect(findValidCoordSysStr('ecliptic x')).toBeUndefined(); + expect(findValidCoordSysStr('ICRS')).toBeUndefined(); + expect(findValidCoordSysStr('SUPERGALACTIC')).toBeUndefined(); + expect(findValidCoordSysStr('EQ_B2000')).toBeUndefined(); + expect(findValidCoordSysStr('PIXEL')).toBeUndefined(); + }); +}); diff --git a/src/firefly/js/visualize/__tests__/CoordUtil-test.js b/src/firefly/js/visualize/__tests__/CoordUtil-test.js new file mode 100644 index 0000000000..f3c7adc15d --- /dev/null +++ b/src/firefly/js/visualize/__tests__/CoordUtil-test.js @@ -0,0 +1,188 @@ +import CoordUtil, {dd2sex, parseDBIdToCoord} from '../CoordUtil.js'; +import CoordinateSys from '../CoordSys.js'; + +const {convertLonToString, convertLatToString, convertStringToLon, convertStringToLat, validLon}= CoordUtil; + + +describe('Test CoordUtil.dd2sex()', () => { + + test('dd2sex() equatorial longitude is formatted as HMS', () => { + expect(dd2sex(150, false, true)).toEqual('10h00m00.00s'); + expect(dd2sex(157.5, false, true)).toEqual('10h30m00.00s'); + }); + + test('dd2sex() equatorial latitude is formatted as signed DMS', () => { + expect(dd2sex(30.5, true, true)).toEqual('+30d30m00.0s'); + expect(dd2sex(-30.5, true, true)).toEqual('-30d30m00.0s'); + }); + + test('dd2sex() non-equatorial is formatted as decimal degrees', () => { + expect(dd2sex(10.5, false, false)).toEqual('10.50000d'); + expect(dd2sex(10.5, true, false)).toEqual('+10.50000d'); + expect(dd2sex(-10.5, true, false)).toEqual('-10.50000d'); + }); + + test('dd2sex() wraps longitude into [0, 360)', () => { + expect(dd2sex(-30, false, false)).toEqual('330.00000d'); + expect(dd2sex(370, false, false)).toEqual('10.00000d'); + }); + + test('dd2sex() using parameter precision', () => { + expect(dd2sex(10.25, false, false, 2)).toEqual('10.25d'); + expect(dd2sex(10.25, false, false, 0)).toEqual('10d'); + expect(dd2sex(10.25, false, false, 20)).toEqual('10.25000000d'); + expect(dd2sex(150, false, true, 8)).toEqual('10h00m00.00000s'); + }); + + test('dd2sex() rounding carries up through seconds, minutes and hours', () => { + expect(dd2sex(359.99999999, false, true)).toEqual('0h00m00.00s'); + }); +}); + + +describe('Test CoordUtil.convertStringToLon()', () => { + + test('convertStringToLon() equatorial sexagesimal is in hours', () => { + expect(convertStringToLon('10:00:00', CoordinateSys.EQ_J2000)).toBeCloseTo(150, 10); + expect(convertStringToLon('10 30 00', CoordinateSys.EQ_J2000)).toBeCloseTo(157.5, 10); + expect(convertStringToLon('10h30m', CoordinateSys.EQ_J2000)).toBeCloseTo(157.5, 10); + expect(convertStringToLon('10h30m36s', CoordinateSys.EQ_J2000)).toBeCloseTo(157.65, 10); + }); + + test('convertStringToLon() equatorial with degree separators is in degrees', () => { + expect(convertStringToLon('150d30m00s', CoordinateSys.EQ_J2000)).toBeCloseTo(150.5, 10); + expect(convertStringToLon('150.5d', CoordinateSys.EQ_J2000)).toBeCloseTo(150.5, 10); + }); + + test('convertStringToLon() equatorial plain number is in hours', () => { + expect(convertStringToLon('10.5', CoordinateSys.EQ_J2000)).toBeCloseTo(157.5, 10); + }); + + test('convertStringToLon() non-equatorial plain number is in degrees', () => { + expect(convertStringToLon('150.5', CoordinateSys.GALACTIC)).toBeCloseTo(150.5, 10); + }); + + test('convertStringToLon() with out of range values throws', () => { + expect(() => convertStringToLon('24h', CoordinateSys.EQ_J2000)).toThrow('RA is too big'); + expect(() => convertStringToLon('150.5', CoordinateSys.EQ_J2000)).toThrow('RA is too big'); + expect(() => convertStringToLon('360d', CoordinateSys.EQ_J2000)).toThrow('Longitude is too big'); + expect(() => convertStringToLon('-10d', CoordinateSys.EQ_J2000)).toThrow('Longitude cannot be negative'); + expect(() => convertStringToLon('10:60:00', CoordinateSys.EQ_J2000)).toThrow('Greater than 60'); + }); + + test('convertStringToLon() with sexagesimal for a non-equatorial system throws', () => { + expect(() => convertStringToLon('10:00:00', CoordinateSys.GALACTIC)).toThrow('Sexagesimal for non-Equatorial'); + }); + + test('convertStringToLon() with invalid strings throws', () => { + expect(() => convertStringToLon('', CoordinateSys.EQ_J2000)).toThrow(); + expect(() => convertStringToLon('+', CoordinateSys.EQ_J2000)).toThrow('Invalid Input'); + expect(() => convertStringToLon('abc', CoordinateSys.EQ_J2000)).toThrow('Invalid Input'); + expect(() => convertStringToLon('10.5:30', CoordinateSys.EQ_J2000)).toThrow('Invalid input'); + expect(() => convertStringToLon('10:30:00:00', CoordinateSys.EQ_J2000)).toThrow('Invalid Input'); + }); +}); + + +describe('Test CoordUtil.convertStringToLat()', () => { + + test('convertStringToLat() equatorial sexagesimal is in degrees', () => { + expect(convertStringToLat('+30:30:00', CoordinateSys.EQ_J2000)).toBeCloseTo(30.5, 10); + expect(convertStringToLat('-30d30m', CoordinateSys.EQ_J2000)).toBeCloseTo(-30.5, 10); + expect(convertStringToLat('- 30 30 36', CoordinateSys.EQ_J2000)).toBeCloseTo(-30.51, 10); + }); + + test('convertStringToLat() plain number is in degrees', () => { + expect(convertStringToLat('45.25', CoordinateSys.EQ_J2000)).toBeCloseTo(45.25, 10); + expect(convertStringToLat('-45.25', CoordinateSys.GALACTIC)).toBeCloseTo(-45.25, 10); + }); + + test('convertStringToLat() with only arc minutes or arc seconds', () => { + expect(convertStringToLat('30m', CoordinateSys.EQ_J2000)).toBeCloseTo(0.5, 10); + expect(convertStringToLat(`30'`, CoordinateSys.EQ_J2000)).toBeCloseTo(0.5, 10); + expect(convertStringToLat('36"', CoordinateSys.EQ_J2000)).toBeCloseTo(0.01, 10); + }); + + test('convertStringToLat() with invalid values throws', () => { + expect(() => convertStringToLat('91', CoordinateSys.EQ_J2000)).toThrow('Latitude is out of range'); + expect(() => convertStringToLat('-91', CoordinateSys.EQ_J2000)).toThrow('Latitude is out of range'); + expect(() => convertStringToLat('10h', CoordinateSys.EQ_J2000)).toThrow('HMS notation not valid for latitude'); + }); +}); + + +describe('Test CoordUtil.convertLonToString() and convertLatToString()', () => { + + test('convertLonToString() using an equatorial or non-equatorial system', () => { + expect(convertLonToString(150, CoordinateSys.EQ_J2000)).toEqual('10h00m00.00s'); + expect(convertLonToString(150, CoordinateSys.GALACTIC)).toEqual('150.00000d'); + }); + + test('convertLatToString() using an equatorial or non-equatorial system', () => { + expect(convertLatToString(-30.5, CoordinateSys.EQ_J2000)).toEqual('-30d30m00.0s'); + expect(convertLatToString(10.5, CoordinateSys.GALACTIC)).toEqual('+10.50000d'); + }); + + test('convertLonToString() and convertLatToString() round trip with convertStringToLon() and convertStringToLat()', () => { + [CoordinateSys.EQ_J2000, CoordinateSys.GALACTIC].forEach((coordSys) => { + expect(convertStringToLon(convertLonToString(188.73625, coordSys), coordSys)).toBeCloseTo(188.73625, 4); + expect(convertStringToLat(convertLatToString(-12.5822, coordSys), coordSys)).toBeCloseTo(-12.5822, 4); + }); + }); +}); + + +describe('Test CoordUtil.validLon()', () => { + + test('validLon() with valid and invalid longitudes', () => { + expect(validLon('10:00:00', CoordinateSys.EQ_J2000)).toBe(true); + expect(validLon('150.5', CoordinateSys.GALACTIC)).toBe(true); + expect(validLon('25h', CoordinateSys.EQ_J2000)).toBe(false); + expect(validLon('10:00:00', CoordinateSys.GALACTIC)).toBe(false); + expect(validLon('abc', CoordinateSys.EQ_J2000)).toBe(false); + }); +}); + + +describe('Test CoordUtil.parseDBIdToCoord()', () => { + + test('parseDBIdToCoord() with a J2000 id', () => { + const coord= parseDBIdToCoord('J123456.7+123456'); + expect(coord.cSys).toBe(CoordinateSys.EQ_J2000); + expect(coord.lon).toBeCloseTo((12 + 34/60 + 56.7/3600) * 15, 8); + expect(coord.lat).toBeCloseTo(12 + 34/60 + 56/3600, 8); + }); + + test('parseDBIdToCoord() with a negative latitude', () => { + const coord= parseDBIdToCoord('J123456-123456'); + expect(coord.lat).toBeCloseTo(-(12 + 34/60 + 56/3600), 8); + }); + + test('parseDBIdToCoord() inserts a decimal point into long seconds', () => { + const coord= parseDBIdToCoord('J12345678+1234567'); + expect(coord.lon).toBeCloseTo((12 + 34/60 + 56.78/3600) * 15, 8); + expect(coord.lat).toBeCloseTo(12 + 34/60 + 56.7/3600, 8); + }); + + test('parseDBIdToCoord() coordinate system comes from the first character', () => { + expect(parseDBIdToCoord('B123456+123456').cSys).toBe(CoordinateSys.EQ_B1950); + expect(parseDBIdToCoord('f123456+123456').cSys).toBe(CoordinateSys.EQ_J2000); + expect(parseDBIdToCoord('123456+123456').cSys).toBe(CoordinateSys.EQ_J2000); + }); + + test('parseDBIdToCoord() with a galactic id uses decimal degrees', () => { + expect(parseDBIdToCoord('G123.4+05.6')).toEqual({lon: 123.4, lat: 5.6, cSys: CoordinateSys.GALACTIC}); + expect(parseDBIdToCoord('G123.4-05.6')).toEqual({lon: 123.4, lat: -5.6, cSys: CoordinateSys.GALACTIC}); + }); + + test('parseDBIdToCoord() with invalid ids returns false', () => { + expect(parseDBIdToCoord(undefined)).toBe(false); + expect(parseDBIdToCoord('')).toBe(false); + expect(parseDBIdToCoord('X123456+123456')).toBe(false); + expect(parseDBIdToCoord('J123456')).toBe(false); + expect(parseDBIdToCoord('J12 3456+123456')).toBe(false); + expect(parseDBIdToCoord('J123456+abcdef')).toBe(false); + expect(parseDBIdToCoord('J123+123456')).toBe(false); + expect(parseDBIdToCoord('J126056+123456')).toBe(false); + }); +}); diff --git a/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js b/src/firefly/js/visualize/__tests__/PlotViewUtil-test.js index fda8d101eb..00329f7a03 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,1356 @@ 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); + }); + 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/__tests__/Point-test.js b/src/firefly/js/visualize/__tests__/Point-test.js new file mode 100644 index 0000000000..e5b78e3798 --- /dev/null +++ b/src/firefly/js/visualize/__tests__/Point-test.js @@ -0,0 +1,376 @@ +import Point, { + SimplePt, isValidPoint, makeAnyPt, makeDevicePt, makeFitsImagePt, makeImagePt, makeImageWorkSpacePt, + makeOffsetPt, makeProjectionPt, makeResolvedWorldPt, makeScreenPt, makeWorldPt, makeZeroBasedImagePt, + parseAnyPt, parseImagePt, parseImageWorkSpacePt, parsePt, parseScreenPt, parseWorldPt, pointEquals +} from '../Point.js'; +import CoordinateSys from '../CoordSys.js'; +import Resolver from '../../astro/net/Resolver.js'; + + +describe('Test Point.SimplePt', () => { + + test('SimplePt constructor with (x, y)', () => { + const pt= new SimplePt(1.5, 2.5); + expect(pt.x).toEqual(1.5); + expect(pt.y).toEqual(2.5); + }); + + test('SimplePt constructor with (x, y) as strings', () => { + const pt= new SimplePt('1.5', '2.5'); + expect(pt.x).toEqual(1.5); + expect(pt.y).toEqual(2.5); + }); + + test('SimplePt constructor with missing or empty values gives NaN', () => { + expect(new SimplePt(undefined, null).x).toBeNaN(); + expect(new SimplePt(undefined, null).y).toBeNaN(); + expect(new SimplePt('', '').x).toBeNaN(); + expect(new SimplePt('', '').y).toBeNaN(); + }); + + test('SimplePt constructor with (x, y, forceToInt)', () => { + const pt= new SimplePt(1.9, -2.9, true); + expect(pt.x).toEqual(1); + expect(pt.y).toEqual(-2); + }); + + test('SimplePt constructor with ([x, y]) and ([x, y], forceToInt)', () => { + const pt= new SimplePt([3.7, 4.2]); + expect(pt.x).toEqual(3.7); + expect(pt.y).toEqual(4.2); + + const intPt= new SimplePt([3.7, 4.2], true); + expect(intPt.x).toEqual(3); + expect(intPt.y).toEqual(4); + }); + + test('SimplePt constructor with ({x, y}) and ({x, y}, forceToInt)', () => { + const pt= new SimplePt({x: '5.5', y: 6.5}); + expect(pt.x).toEqual(5.5); + expect(pt.y).toEqual(6.5); + + const intPt= new SimplePt({x: 5.5, y: 6.5}, true); + expect(intPt.x).toEqual(5); + expect(intPt.y).toEqual(6); + }); + + test('SimplePt toString()', () => { + expect(new SimplePt(1, 2).toString()).toEqual('1;2'); + }); + + test('SimplePt.make() sets x, y and ignores an unknown type', () => { + const pt= SimplePt.make(1, 2, 'NotAType'); + expect(pt.x).toEqual(1); + expect(pt.y).toEqual(2); + expect(pt.type).toBeUndefined(); + }); + + test('SimplePt.make() sets a known point type', () => { + const pt= SimplePt.make(1, 2, Point.SPT); + expect(pt.x).toEqual(1); + expect(pt.y).toEqual(2); + expect(pt.type).toEqual(Point.SPT); + }); +}); + + +describe('Test Point simple point factory functions', () => { + + test('make*Pt() functions set x, y and type', () => { + const factoryToType= [ + [makeImagePt, Point.IM_PT], + [makeFitsImagePt, Point.FITS_IM_PT], + [makeZeroBasedImagePt, Point.ZERO_BASED_IM_PT], + [makeImageWorkSpacePt, Point.IM_WS_PT], + [makeScreenPt, Point.SPT], + [makeDevicePt, Point.DEV_PT], + [makeProjectionPt, Point.PROJ_PT], + [makeOffsetPt, Point.OFFSET_PT], + ]; + factoryToType.forEach(([makeFunc, expectedType]) => { + const pt= makeFunc(10, '20'); + expect(pt.x).toEqual(10); + expect(pt.y).toEqual(20); + expect(pt.type).toEqual(expectedType); + expect(pt).toBeInstanceOf(SimplePt); + }); + }); +}); + + +describe('Test Point.makeWorldPt()', () => { + + test('makeWorldPt() using parameters (lon, lat) defaults to EQ_J2000', () => { + const wp= makeWorldPt(10.5, -20.25); + expect(wp.x).toEqual(10.5); + expect(wp.y).toEqual(-20.25); + expect(wp.type).toEqual(Point.W_PT); + expect(wp.cSys).toBe(CoordinateSys.EQ_J2000); + expect(wp.getLon()).toEqual(10.5); + expect(wp.getLat()).toEqual(-20.25); + expect(wp.getCoordSys()).toBe(CoordinateSys.EQ_J2000); + expect(wp.getObjName()).toBeUndefined(); + expect(wp.getResolver()).toBeUndefined(); + expect(wp.getObjType()).toEqual(''); + }); + + test('makeWorldPt() using parameters (lon, lat, coordSys) with string numbers', () => { + const wp= makeWorldPt('120', '-5', CoordinateSys.GALACTIC); + expect(wp.x).toEqual(120); + expect(wp.y).toEqual(-5); + expect(wp.cSys).toBe(CoordinateSys.GALACTIC); + }); + + test('makeWorldPt() with non-numeric or missing values returns undefined', () => { + expect(makeWorldPt('abc', 10)).toBeUndefined(); + expect(makeWorldPt(10, undefined)).toBeUndefined(); + expect(makeWorldPt('', '')).toBeUndefined(); + }); + + test('makeWorldPt() with sexagesimal strings requires detectHMS', () => { + expect(makeWorldPt('10:00:00', '+30:00:00', CoordinateSys.EQ_J2000)).toBeUndefined(); + + const wp= makeWorldPt('10:00:00', '+30:00:00', CoordinateSys.EQ_J2000, true); + expect(wp.x).toBeCloseTo(150, 8); + expect(wp.y).toBeCloseTo(30, 8); + }); + + test('makeWorldPt() with sexagesimal strings and a non-equatorial system returns undefined', () => { + expect(makeWorldPt('10:00:00', '+30:00:00', CoordinateSys.GALACTIC, true)).toBeUndefined(); + }); + + test('makeWorldPt() with angleInRadian converts to degrees', () => { + const wp= makeWorldPt(Math.PI, Math.PI/4, CoordinateSys.EQ_J2000, false, true); + expect(wp.x).toBeCloseTo(180, 10); + expect(wp.y).toBeCloseTo(45, 10); + }); + + test('makeWorldPt() returns the same object for repeated identical calls', () => { + const wp1= makeWorldPt(33, 44); + const wp2= makeWorldPt(33, 44); + expect(wp2).toBe(wp1); + }); + + test('WorldPt toString()', () => { + expect(makeWorldPt(10, 20).toString()).toEqual('10;20;EQ_J2000'); + expect(makeWorldPt(10, 20, CoordinateSys.GALACTIC).toString()).toEqual('10;20;GALACTIC'); + }); +}); + + +describe('Test Point.makeResolvedWorldPt()', () => { + + test('makeResolvedWorldPt() using parameters (wp, objName, resolver, objType)', () => { + const wp= makeWorldPt(83.63, 22.01); + const resolvedWp= makeResolvedWorldPt(wp, 'm1', Resolver.NED, 'SNR'); + expect(resolvedWp).not.toBe(wp); + expect(resolvedWp.x).toEqual(83.63); + expect(resolvedWp.y).toEqual(22.01); + expect(resolvedWp.cSys).toBe(CoordinateSys.EQ_J2000); + expect(resolvedWp.getObjName()).toEqual('m1'); + expect(resolvedWp.getResolver()).toBe(Resolver.NED); + expect(resolvedWp.getObjType()).toEqual('SNR'); + expect(resolvedWp.toString()).toEqual('83.63;22.01;EQ_J2000;m1;ned;SNR'); + }); + + test('makeResolvedWorldPt() using parameters (wp, objName) defaults to UNKNOWN resolver', () => { + const resolvedWp= makeResolvedWorldPt(makeWorldPt(1, 2), 'm31'); + expect(resolvedWp.getResolver()).toBe(Resolver.UNKNOWN); + expect(resolvedWp.toString()).toEqual('1;2;EQ_J2000;m31;unknown'); + }); +}); + + +describe('Test Point.makeAnyPt()', () => { + + test('makeAnyPt() with non-celestial CoordinateSys objects', () => { + expect(makeAnyPt(1, 2, CoordinateSys.SCREEN_PIXEL).type).toEqual(Point.SPT); + expect(makeAnyPt(1, 2, CoordinateSys.UNDEFINED).type).toEqual(Point.SPT); + expect(makeAnyPt(1, 2, CoordinateSys.PIXEL).type).toEqual(Point.IM_PT); + expect(makeAnyPt(1, 2, CoordinateSys.ZEROBASED).type).toEqual(Point.ZERO_BASED_IM_PT); + expect(makeAnyPt(1, 2, CoordinateSys.FITSPIXEL).type).toEqual(Point.FITS_IM_PT); + }); + + test('makeAnyPt() with non-celestial coordinate system strings', () => { + expect(makeAnyPt(1, 2, 'SCREEN_PIXEL').type).toEqual(Point.SPT); + expect(makeAnyPt(1, 2, 'PIXEL').type).toEqual(Point.IM_PT); + expect(makeAnyPt(1, 2, 'ZERO-BASED').type).toEqual(Point.ZERO_BASED_IM_PT); + expect(makeAnyPt(1, 2, 'FITSPIXEL').type).toEqual(Point.FITS_IM_PT); + }); + + test('makeAnyPt() with a celestial CoordinateSys makes a WorldPt', () => { + const wp= makeAnyPt(10, 20, CoordinateSys.GALACTIC); + expect(wp.type).toEqual(Point.W_PT); + expect(wp.cSys).toBe(CoordinateSys.GALACTIC); + expect(wp.x).toEqual(10); + expect(wp.y).toEqual(20); + }); + + test('makeAnyPt() with a celestial coordinate system string makes a WorldPt', () => { + const wp= makeAnyPt(10, 20, 'GALACTIC'); + expect(wp.type).toEqual(Point.W_PT); + expect(wp.cSys).toBe(CoordinateSys.GALACTIC); + + const hmsWp= makeAnyPt('10:00:00', '+30:00:00', 'EQ_J2000'); + expect(hmsWp.cSys).toBe(CoordinateSys.EQ_J2000); + expect(hmsWp.x).toBeCloseTo(150, 8); + expect(hmsWp.y).toBeCloseTo(30, 8); + }); + + test('makeAnyPt() with a celestial CoordinateSys detects sexagesimal', () => { + const wp= makeAnyPt('10:00:00', '+30:00:00', CoordinateSys.EQ_J2000); + expect(wp.x).toBeCloseTo(150, 8); + expect(wp.y).toBeCloseTo(30, 8); + }); +}); + + +describe('Test Point.parseWorldPt()', () => { + + test('parseWorldPt() using a (lon;lat;csys) string', () => { + const wp= parseWorldPt('10.5;-20.5;GALACTIC'); + expect(wp.type).toEqual(Point.W_PT); + expect(wp.x).toEqual(10.5); + expect(wp.y).toEqual(-20.5); + expect(wp.cSys).toBe(CoordinateSys.GALACTIC); + }); + + test('parseWorldPt() using a (lon;lat;csys;objName;resolver;objType) string', () => { + const wp= parseWorldPt('83.63;22.01;EQ_J2000;m1;simbad;SNR'); + expect(wp.x).toEqual(83.63); + expect(wp.y).toEqual(22.01); + expect(wp.cSys).toBe(CoordinateSys.EQ_J2000); + expect(wp.getObjName()).toEqual('m1'); + expect(wp.getResolver()).toBe(Resolver.Simbad); + expect(wp.getObjType()).toEqual('SNR'); + }); + + test('parseWorldPt() with an object name and no resolver uses UNKNOWN', () => { + const wp= parseWorldPt('1;2;EQ_J2000;m31'); + expect(wp.getObjName()).toEqual('m31'); + expect(wp.getResolver()).toBe(Resolver.UNKNOWN); + }); + + test('parseWorldPt() round trips WorldPt toString()', () => { + const wp= makeResolvedWorldPt(makeWorldPt(83.63, 22.01), 'm1', Resolver.NED, 'SNR'); + const parsedWp= parseWorldPt(wp.toString()); + expect(pointEquals(parsedWp, wp)).toBe(true); + expect(parsedWp.getObjName()).toEqual('m1'); + expect(parsedWp.getResolver()).toBe(Resolver.NED); + expect(parsedWp.getObjType()).toEqual('SNR'); + }); + + test('parseWorldPt() with PIXEL or SCREEN_PIXEL returns an image or screen point', () => { + expect(parseWorldPt('1;2;PIXEL').type).toEqual(Point.IM_PT); + expect(parseWorldPt('1;2;SCREEN_PIXEL').type).toEqual(Point.SPT); + }); + + test('parseWorldPt() with a valid point object returns that object', () => { + const wp= makeWorldPt(1, 2); + expect(parseWorldPt(wp)).toBe(wp); + }); + + test('parseWorldPt() with invalid input returns undefined', () => { + expect(parseWorldPt(undefined)).toBeUndefined(); + expect(parseWorldPt('')).toBeUndefined(); + expect(parseWorldPt(42)).toBeUndefined(); + expect(parseWorldPt('10')).toBeUndefined(); + expect(parseWorldPt('a;b;EQ_J2000')).toBeUndefined(); + expect(parseWorldPt('1;2;3;4;5;6;7')).toBeUndefined(); + }); +}); + + +describe('Test Point.parseAnyPt()', () => { + + test('parseAnyPt() with a celestial system returns a WorldPt', () => { + const wp= parseAnyPt('10;20;EQ_B1950'); + expect(wp.type).toEqual(Point.W_PT); + expect(wp.cSys).toBe(CoordinateSys.EQ_B1950); + }); + + test('parseAnyPt() with non-celestial systems returns the matching point type', () => { + expect(parseAnyPt('1;2;PIXEL').type).toEqual(Point.IM_PT); + expect(parseAnyPt('1;2;SCREEN_PIXEL').type).toEqual(Point.SPT); + }); + + test('parseAnyPt() with invalid input returns undefined', () => { + expect(parseAnyPt('not a point')).toBeUndefined(); + expect(parseAnyPt(undefined)).toBeUndefined(); + }); +}); + + +describe('Test Point.parsePt() and typed parsers', () => { + + test('parsePt() using parameters (type, inStr)', () => { + const pt= parsePt(Point.DEV_PT, '3.5;4.5'); + expect(pt.x).toEqual(3.5); + expect(pt.y).toEqual(4.5); + expect(pt.type).toEqual(Point.DEV_PT); + }); + + test('parsePt() with invalid input returns undefined', () => { + expect(parsePt(Point.IM_PT, undefined)).toBeUndefined(); + expect(parsePt(Point.IM_PT, '')).toBeUndefined(); + expect(parsePt(Point.IM_PT, '1')).toBeUndefined(); + expect(parsePt(Point.IM_PT, '1;2;3')).toBeUndefined(); + expect(parsePt(Point.IM_PT, 'a;2')).toBeUndefined(); + }); + + test('parseImagePt(), parseImageWorkSpacePt(), parseScreenPt() set the type', () => { + expect(parseImagePt('1;2').type).toEqual(Point.IM_PT); + expect(parseImageWorkSpacePt('1;2').type).toEqual(Point.IM_WS_PT); + expect(parseScreenPt('1;2').type).toEqual(Point.SPT); + }); + + test('parseImagePt() round trips SimplePt toString()', () => { + const pt= makeImagePt(7.25, 8.75); + expect(pointEquals(parseImagePt(pt.toString()), pt)).toBe(true); + }); +}); + + +describe('Test Point.pointEquals()', () => { + + test('pointEquals() with nil points', () => { + expect(pointEquals(undefined, null)).toBe(true); + expect(pointEquals(makeImagePt(1, 2), undefined)).toBe(false); + expect(pointEquals(null, makeImagePt(1, 2))).toBe(false); + }); + + test('pointEquals() compares x, y and type', () => { + expect(pointEquals(makeImagePt(1, 2), makeImagePt(1, 2))).toBe(true); + expect(pointEquals(makeImagePt(1, 2), makeImagePt(1, 3))).toBe(false); + expect(pointEquals(makeImagePt(1, 2), makeScreenPt(1, 2))).toBe(false); + }); + + test('pointEquals() compares the coordinate system of world points', () => { + expect(pointEquals(makeWorldPt(1, 2, CoordinateSys.EQ_J2000), makeWorldPt(1, 2, CoordinateSys.GALACTIC))) + .toBe(false); + }); + + test('pointEquals() ignores object name and resolver of world points', () => { + const wp= makeWorldPt(1, 2); + const resolvedWp= makeResolvedWorldPt(wp, 'm31', Resolver.NED); + expect(pointEquals(wp, resolvedWp)).toBe(true); + }); +}); + + +describe('Test Point.isValidPoint()', () => { + + test('isValidPoint() with valid points', () => { + expect(isValidPoint(makeImagePt(1, 2))).toBe(true); + expect(isValidPoint(makeWorldPt(1, 2))).toBe(true); + expect(isValidPoint({x: 1, y: 2, type: Point.SPT})).toBe(true); + }); + + test('isValidPoint() with invalid values', () => { + expect(isValidPoint(undefined)).toBe(false); + expect(isValidPoint('1;2;EQ_J2000')).toBe(false); + expect(isValidPoint({x: 1, y: 2})).toBe(false); + expect(isValidPoint({x: 1, y: 2, type: 'NotAType'})).toBe(false); + expect(isValidPoint({x: '1', y: 2, type: Point.IM_PT})).toBe(false); + }); +}); diff --git a/src/firefly/js/visualize/__tests__/RangeValues-test.js b/src/firefly/js/visualize/__tests__/RangeValues-test.js new file mode 100644 index 0000000000..9b83fdedad --- /dev/null +++ b/src/firefly/js/visualize/__tests__/RangeValues-test.js @@ -0,0 +1,252 @@ +import RangeValues, { + ABSOLUTE, PERCENTAGE, SIGMA, STRETCH_ASINH, STRETCH_LINEAR, STRETCH_LOG, STRETCH_LOGLOG, STRETCH_POWERLAW_GAMMA, + STRETCH_SQRT, ZSCALE +} from '../RangeValues.js'; + + +describe('Test RangeValues constructor', () => { + + test('constructor with no parameters uses the defaults', () => { + const rv= new RangeValues(); + expect(rv.lowerWhich).toEqual(PERCENTAGE); + expect(rv.lowerValue).toEqual(1); + expect(rv.upperWhich).toEqual(PERCENTAGE); + expect(rv.upperValue).toEqual(99); + expect(rv.asinhQValue).toBeNaN(); + expect(rv.gammaValue).toEqual(2); + expect(rv.algorithm).toEqual(STRETCH_LINEAR); + expect(rv.zscaleContrast).toEqual(25); + expect(rv.zscaleSamples).toEqual(600); + expect(rv.zscaleSamplesPerLine).toEqual(120); + expect(rv.rgbPreserveHue).toEqual(0); + expect(rv.asinhStretch).toBeNaN(); + expect(rv.scalingK).toEqual(1); + expect(rv.bias).toEqual(0.5); + expect(rv.contrast).toEqual(1); + }); + + test('constructor converts string parameters to numbers', () => { + const rv= new RangeValues('90', '2.5', '90', '300.75', '8', '3', '45', '30', '500', '100', '0', '4', '2', '0.4', '1.2'); + expect(rv.lowerWhich).toEqual(ABSOLUTE); + expect(rv.lowerValue).toEqual(2.5); + expect(rv.upperWhich).toEqual(ABSOLUTE); + expect(rv.upperValue).toEqual(300.75); + expect(rv.asinhQValue).toEqual(8); + expect(rv.gammaValue).toEqual(3); + expect(rv.algorithm).toEqual(STRETCH_LOG); + expect(rv.zscaleContrast).toEqual(30); + expect(rv.zscaleSamples).toEqual(500); + expect(rv.zscaleSamplesPerLine).toEqual(100); + expect(rv.rgbPreserveHue).toEqual(0); + expect(rv.asinhStretch).toEqual(4); + expect(rv.scalingK).toEqual(2); + expect(rv.bias).toEqual(0.4); + expect(rv.contrast).toEqual(1.2); + }); + + test('constructor truncates integer parameters', () => { + const rv= new RangeValues(90.7, 1, 88.2, 99, NaN, 2, 45.9, 25.5); + expect(rv.lowerWhich).toEqual(ABSOLUTE); + expect(rv.upperWhich).toEqual(PERCENTAGE); + expect(rv.algorithm).toEqual(STRETCH_LOG); + expect(rv.zscaleContrast).toEqual(25); + }); + + test('constructor with rgbPreserveHue forces asinh algorithm and zscale upper bound', () => { + const rv= new RangeValues(PERCENTAGE, 1, PERCENTAGE, 99, NaN, 2, STRETCH_LOG, 25, 600, 120, 1); + expect(rv.rgbPreserveHue).toEqual(1); + expect(rv.algorithm).toEqual(STRETCH_ASINH); + expect(rv.upperWhich).toEqual(ZSCALE); + expect(rv.lowerWhich).toEqual(PERCENTAGE); + }); +}); + + +describe('Test RangeValues.make()', () => { + + test('make() with no parameters equals the constructor defaults', () => { + expect(RangeValues.make()).toEqual(new RangeValues()); + }); + + test('make() using parameters (lowerWhich, lowerValue, upperWhich, upperValue, asinhQValue, gammaValue, algorithm)', () => { + const rv= RangeValues.make(SIGMA, -2, SIGMA, 10, 5, 2.2, STRETCH_SQRT); + expect(rv).toBeInstanceOf(RangeValues); + expect(rv.lowerWhich).toEqual(SIGMA); + expect(rv.lowerValue).toEqual(-2); + expect(rv.upperWhich).toEqual(SIGMA); + expect(rv.upperValue).toEqual(10); + expect(rv.asinhQValue).toEqual(5); + expect(rv.gammaValue).toEqual(2.2); + expect(rv.algorithm).toEqual(STRETCH_SQRT); + }); +}); + + +describe('Test RangeValues.makeRV()', () => { + + test('makeRV() with an empty object equals the constructor defaults', () => { + expect(RangeValues.makeRV({})).toEqual(new RangeValues()); + }); + + test('makeRV() using which sets both lowerWhich and upperWhich', () => { + const rv= RangeValues.makeRV({which: ABSOLUTE, lowerValue: 10, upperValue: 2000, algorithm: STRETCH_LOGLOG}); + expect(rv.lowerWhich).toEqual(ABSOLUTE); + expect(rv.upperWhich).toEqual(ABSOLUTE); + expect(rv.lowerValue).toEqual(10); + expect(rv.upperValue).toEqual(2000); + expect(rv.algorithm).toEqual(STRETCH_LOGLOG); + }); + + test('makeRV() using lowerWhich and upperWhich overrides which', () => { + const rv= RangeValues.makeRV({which: SIGMA, lowerWhich: ABSOLUTE}); + expect(rv.lowerWhich).toEqual(ABSOLUTE); + expect(rv.upperWhich).toEqual(SIGMA); + }); + + test('makeRV() using bias and contrast', () => { + const rv= RangeValues.makeRV({bias: 0.3, contrast: 2}); + expect(rv.bias).toEqual(0.3); + expect(rv.contrast).toEqual(2); + }); +}); + + +describe('Test RangeValues.makeSimple()', () => { + + test('makeSimple() using parameters (boundsType, lowerValue, upperValue, algorithm)', () => { + const rv= RangeValues.makeSimple('sigma', -2, 10, 'log'); + expect(rv.lowerWhich).toEqual(SIGMA); + expect(rv.upperWhich).toEqual(SIGMA); + expect(rv.lowerValue).toEqual(-2); + expect(rv.upperValue).toEqual(10); + expect(rv.algorithm).toEqual(STRETCH_LOG); + expect(rv.gammaValue).toEqual(2); + expect(rv.asinhQValue).toBeNaN(); + }); + + test('makeSimple() boundsType and algorithm ignore case', () => { + const rv= RangeValues.makeSimple('ABSOLUTE', 0, 500, 'PowerLaw_Gamma'); + expect(rv.lowerWhich).toEqual(ABSOLUTE); + expect(rv.algorithm).toEqual(STRETCH_POWERLAW_GAMMA); + }); + + test('makeSimple() with unknown boundsType and algorithm falls back to percent and linear', () => { + const rv= RangeValues.makeSimple('nonsense', 1, 99, 'nonsense'); + expect(rv.lowerWhich).toEqual(PERCENTAGE); + expect(rv.upperWhich).toEqual(PERCENTAGE); + expect(rv.algorithm).toEqual(STRETCH_LINEAR); + }); + + test('makeSimple() with no parameters', () => { + const rv= RangeValues.makeSimple(); + expect(rv.lowerWhich).toEqual(PERCENTAGE); + expect(rv.lowerValue).toEqual(1); + expect(rv.upperValue).toEqual(99); + expect(rv.algorithm).toEqual(STRETCH_LINEAR); + }); +}); + + +describe('Test RangeValues.make2To10SigmaLinear()', () => { + + test('make2To10SigmaLinear()', () => { + const rv= RangeValues.make2To10SigmaLinear(); + expect(rv.lowerWhich).toEqual(SIGMA); + expect(rv.lowerValue).toEqual(-2); + expect(rv.upperWhich).toEqual(SIGMA); + expect(rv.upperValue).toEqual(10); + expect(rv.algorithm).toEqual(STRETCH_LINEAR); + }); +}); + + +describe('Test RangeValues serialization', () => { + + test('serializeRV(), toJSON() and toString() give the comma separated form', () => { + const rv= RangeValues.make(SIGMA, -2, SIGMA, 10, NaN, 2, STRETCH_LOG); + const expectedStr= '92,-2,92,10,NaN,2,45,25,600,120,0,NaN,1'; + expect(RangeValues.serializeRV(rv)).toEqual(expectedStr); + expect(rv.toJSON()).toEqual(expectedStr); + expect(rv.toString()).toEqual(expectedStr); + expect(JSON.stringify({rv})).toEqual(`{"rv":"${expectedStr}"}`); + }); + + test('serializeSimple() using parameters (boundsType, lowerValue, upperValue, algorithm)', () => { + expect(RangeValues.serializeSimple('absolute', 5, 500, 'sqrt')).toEqual('90,5,90,500,NaN,2,49,25,600,120,0,NaN,1'); + }); + + test('serializeSimple() with no parameters', () => { + expect(RangeValues.serializeSimple()).toEqual('88,1,88,99,NaN,2,44,25,600,120,0,NaN,1'); + }); +}); + + +describe('Test RangeValues.parse()', () => { + + test('parse() round trips toJSON()', () => { + const rv= RangeValues.make(ABSOLUTE, 5, ABSOLUTE, 500, 8, 3, STRETCH_ASINH, 30, 500, 100, 0, 4, 2); + const parsedRv= RangeValues.parse(rv.toJSON()); + expect(parsedRv).toBeInstanceOf(RangeValues); + expect(parsedRv).toEqual(rv); + }); + + test('parse() does not carry bias and contrast, they reset to the defaults', () => { + const rv= RangeValues.makeRV({bias: 0.3, contrast: 2}); + const parsedRv= RangeValues.parse(rv.toJSON()); + expect(parsedRv.bias).toEqual(0.5); + expect(parsedRv.contrast).toEqual(1); + }); + + test('parse() with the older 10 value form uses the defaults for the rest', () => { + const parsedRv= RangeValues.parse('88,1,88,99,NaN,2,44,25,600,120'); + expect(parsedRv.rgbPreserveHue).toEqual(0); + expect(parsedRv.asinhStretch).toBeNaN(); + expect(parsedRv.scalingK).toEqual(1); + }); + + test('parse() with a RangeValues-shaped object returns that object', () => { + const rvObj= {lowerWhich: PERCENTAGE, lowerValue: 1, upperWhich: PERCENTAGE, upperValue: 99, algorithm: STRETCH_LOG}; + expect(RangeValues.parse(rvObj)).toBe(rvObj); + + const rv= RangeValues.make(); + expect(RangeValues.parse(rv)).toBe(rv); + }); + + test('parse() with too few values or non-numeric required values returns undefined', () => { + expect(RangeValues.parse('a,b')).toBeUndefined(); + expect(RangeValues.parse('88,1,88,99')).toBeUndefined(); + expect(RangeValues.parse('88,x,88,99,NaN,2,44,25,600,120')).toBeUndefined(); + expect(RangeValues.parse('88,1,88,99,NaN,2,linear,25,600,120')).toBeUndefined(); + }); + + test('parse() with empty or non-string input returns undefined', () => { + expect(RangeValues.parse(undefined)).toBeUndefined(); + expect(RangeValues.parse('')).toBeUndefined(); + expect(RangeValues.parse(42)).toBeUndefined(); + expect(RangeValues.parse({lowerWhich: PERCENTAGE, lowerValue: 1})).toBeUndefined(); + }); +}); + + +describe('Test RangeValues.clone()', () => { + + test('clone() makes an equal but separate object', () => { + const rv= RangeValues.make(ABSOLUTE, 5, ABSOLUTE, 500, 8, 3, STRETCH_ASINH, 30, 500, 100, 0, 4, 2, 0.3, 2); + const clonedRv= rv.clone(); + expect(clonedRv).not.toBe(rv); + expect(clonedRv).toEqual(rv); + }); +}); + + +describe('Test RangeValues.isAlgorithmSupported()', () => { + + test('isAlgorithmSupported() with supported and unsupported names', () => { + ['linear', 'log', 'loglog', 'equal', 'squared', 'sqrt', 'asinh', 'powerlaw_gamma'] + .forEach((algorithm) => expect(RangeValues.isAlgorithmSupported(algorithm)).toBe(true)); + expect(RangeValues.isAlgorithmSupported('LINEAR')).toBe(true); + expect(RangeValues.isAlgorithmSupported('Asinh')).toBe(true); + expect(RangeValues.isAlgorithmSupported('nonsense')).toBe(false); + expect(RangeValues.isAlgorithmSupported(undefined)).toBe(false); + }); +}); diff --git a/src/firefly/js/visualize/__tests__/VisUtil-test.js b/src/firefly/js/visualize/__tests__/VisUtil-test.js new file mode 100644 index 0000000000..7f0c3ee3ca --- /dev/null +++ b/src/firefly/js/visualize/__tests__/VisUtil-test.js @@ -0,0 +1,1208 @@ +import { + calculatePosition, calculatePositionFromLocalOffsets, computeCentralPointAndRadius, computeCentralPtRadiusAverage, + computeDistance, computeScreenDistance, computeSimpleDistance, computeSimpleSlope, computeSimpleSlopeAngle, + contains, containsCircle, containsEllipse, containsRec, convertAngle, distanceToPolygon, distToLine, + doSegmentsCross, findIntersectionPt, getAngleInDeg, getArrowCoords, getBoundingBox, getEllipseArcEndPoints, + getLatDist, getLinePointAry, getLonDist, getPointOnEllipse, getPositionAngle, getTranslateAndRotatePosition, + intersects, isAngleUnit, lineCrossesRect, lineIntersect2, normalizeRotation, pointInRec, segmentIntersectRect, + toDegrees, toRadians, transformToEastNorth +} from '../VisUtil.js'; +import Point, {makeImagePt, makeScreenPt, makeWorldPt} from '../Point.js'; +import CoordinateSys from '../CoordSys.js'; + + +describe('Test VisUtil.computeScreenDistance()', () => { + + test('computeScreenDistance() using parameters (x1, y1, x2, y2)', () => { + expect(computeScreenDistance(0, 0, 3, 4)).toEqual(5); + expect(computeScreenDistance(3, 4, 0, 0)).toEqual(5); + expect(computeScreenDistance(-1, -1, 2, 3)).toEqual(5); + expect(computeScreenDistance(7, 7, 7, 7)).toEqual(0); + }); +}); + + +describe('Test VisUtil.computeDistance()', () => { + + // computeDistance() is the exported way to reach the private computeDistanceAngularDistance() + + test('computeDistance() along the equator and along a meridian', () => { + expect(computeDistance(makeWorldPt(0, 0), makeWorldPt(90, 0))).toBeCloseTo(90, 10); + expect(computeDistance(makeWorldPt(10, 0), makeWorldPt(11, 0))).toBeCloseTo(1, 10); + expect(computeDistance(makeWorldPt(0, 0), makeWorldPt(0, 90))).toBeCloseTo(90, 10); + expect(computeDistance(makeWorldPt(50, -30), makeWorldPt(50, 30))).toBeCloseTo(60, 10); + }); + + test('computeDistance() is the same in both directions', () => { + const wp1= makeWorldPt(83.63, 22.01); + const wp2= makeWorldPt(10.68, 41.27); + expect(computeDistance(wp1, wp2)).toBeCloseTo(computeDistance(wp2, wp1), 10); + }); + + test('computeDistance() across the 0/360 longitude line', () => { + expect(computeDistance(makeWorldPt(359, 0), makeWorldPt(1, 0))).toBeCloseTo(2, 10); + }); + + test('computeDistance() longitude separation shrinks with latitude', () => { + // at latitude 60, 2 degrees of longitude is about 1 degree on the sky + expect(computeDistance(makeWorldPt(0, 60), makeWorldPt(2, 60))).toBeCloseTo(1, 3); + }); + + test('computeDistance() between the poles and antipodal points is 180', () => { + expect(computeDistance(makeWorldPt(0, 90), makeWorldPt(0, -90))).toBeCloseTo(180, 10); + expect(computeDistance(makeWorldPt(0, 0), makeWorldPt(180, 0))).toBeCloseTo(180, 10); + }); + + test('computeDistance() of a point to itself is 0', () => { + expect(computeDistance(makeWorldPt(83.63, 22.01), makeWorldPt(83.63, 22.01))).toBeCloseTo(0, 5); + expect(computeDistance(makeWorldPt(10, 90), makeWorldPt(200, 90))).toBeCloseTo(0, 5); + }); + + test('computeDistance() returns the same value for repeated calls', () => { + const firstDist= computeDistance(makeWorldPt(1, 2), makeWorldPt(3, 4)); + expect(computeDistance(makeWorldPt(1, 2), makeWorldPt(3, 4))).toEqual(firstDist); + }); + + test('computeDistance() with a missing point returns 0', () => { + expect(computeDistance(undefined, makeWorldPt(1, 2))).toEqual(0); + expect(computeDistance(makeWorldPt(1, 2), null)).toEqual(0); + }); +}); + + +describe('Test VisUtil.computeSimpleDistance()', () => { + + test('computeSimpleDistance() using parameters (p1, p2)', () => { + expect(computeSimpleDistance(makeScreenPt(0, 0), makeScreenPt(3, 4))).toEqual(5); + expect(computeSimpleDistance({x: 2, y: 3}, {x: -1, y: -1})).toEqual(5); + expect(computeSimpleDistance(makeScreenPt(5, 5), makeScreenPt(5, 5))).toEqual(0); + }); +}); + + +describe('Test VisUtil.computeSimpleSlopeAngle()', () => { + + test('computeSimpleSlopeAngle() returns the angle in radians from fromPt to toPt', () => { + const origin= makeScreenPt(0, 0); + expect(computeSimpleSlopeAngle(origin, makeScreenPt(1, 0))).toEqual(0); + expect(computeSimpleSlopeAngle(origin, makeScreenPt(1, 1))).toBeCloseTo(Math.PI/4, 10); + expect(computeSimpleSlopeAngle(origin, makeScreenPt(0, 1))).toBeCloseTo(Math.PI/2, 10); + expect(computeSimpleSlopeAngle(origin, makeScreenPt(-1, 0))).toBeCloseTo(Math.PI, 10); + expect(computeSimpleSlopeAngle(origin, makeScreenPt(0, -1))).toBeCloseTo(-Math.PI/2, 10); + }); + + test('computeSimpleSlopeAngle() reversing the points adds PI', () => { + const fromPt= makeScreenPt(1, 1); + const toPt= makeScreenPt(4, 5); + const forwardAngle= computeSimpleSlopeAngle(fromPt, toPt); + const backwardAngle= computeSimpleSlopeAngle(toPt, fromPt); + expect(forwardAngle - backwardAngle).toBeCloseTo(Math.PI, 10); + }); +}); + + +describe('Test VisUtil.computeSimpleSlope()', () => { + + test('computeSimpleSlope() using parameters (fromPt, toPt)', () => { + expect(computeSimpleSlope(makeScreenPt(0, 0), makeScreenPt(2, 1))).toEqual(.5); + expect(computeSimpleSlope(makeScreenPt(1, 1), makeScreenPt(3, -3))).toEqual(-2); + expect(computeSimpleSlope(makeScreenPt(0, 5), makeScreenPt(4, 5))).toEqual(0); + }); + + test('computeSimpleSlope() is the same in both directions', () => { + const fromPt= makeScreenPt(1, 1); + const toPt= makeScreenPt(4, 7); + expect(computeSimpleSlope(fromPt, toPt)).toEqual(computeSimpleSlope(toPt, fromPt)); + }); + + test('computeSimpleSlope() of a vertical line is infinite', () => { + expect(computeSimpleSlope(makeScreenPt(2, 0), makeScreenPt(2, 3))).toEqual(Infinity); + expect(computeSimpleSlope(makeScreenPt(2, 0), makeScreenPt(2, -3))).toEqual(-Infinity); + }); +}); + + +describe('Test VisUtil.getLonDist()', () => { + + test('getLonDist() returns the signed longitude change from lon1 to lon2', () => { + expect(getLonDist(10, 20)).toEqual(10); + expect(getLonDist(20, 10)).toEqual(-10); + expect(getLonDist(100, 100)).toEqual(0); + }); + + test('getLonDist() with a change of exactly 180', () => { + expect(getLonDist(0, 180)).toEqual(180); + expect(getLonDist(180, 0)).toEqual(-180); + }); + + test('getLonDist() takes the shorter way across the 0/360 line', () => { + expect(getLonDist(350, 10)).toEqual(20); + expect(getLonDist(10, 350)).toEqual(-20); + expect(getLonDist(340, 10)).toEqual(30); + expect(getLonDist(359, 1)).toEqual(2); + expect(getLonDist(190, 0)).toEqual(170); + expect(getLonDist(0, 190)).toEqual(-170); + }); +}); + + +describe('Test VisUtil.getLatDist()', () => { + + test('getLatDist() returns the signed latitude change from lat1 to lat2', () => { + expect(getLatDist(10, 30)).toEqual(20); + expect(getLatDist(30, 10)).toEqual(-20); + expect(getLatDist(-45, 45)).toEqual(90); + expect(getLatDist(12.5, 12.5)).toBeCloseTo(0, 10); // returns -0 + }); +}); + + +describe('Test VisUtil.computeCentralPointAndRadius()', () => { + + test('computeCentralPointAndRadius() center is the middle of the lon and lat range', () => { + const {centralPoint, maxRadius}= computeCentralPointAndRadius( + [makeWorldPt(10, 20), makeWorldPt(14, 20), makeWorldPt(12, 24), makeWorldPt(12, 16)]); + expect(centralPoint.x).toBeCloseTo(12, 10); + expect(centralPoint.y).toBeCloseTo(20, 10); + expect(centralPoint.cSys).toBe(CoordinateSys.EQ_J2000); + expect(maxRadius).toBeCloseTo(4, 10); + }); + + test('computeCentralPointAndRadius() maxRadius is the distance to the farthest point', () => { + const {centralPoint, maxRadius}= computeCentralPointAndRadius([makeWorldPt(0, 0), makeWorldPt(0, 10)]); + expect(centralPoint.x).toBeCloseTo(0, 10); + expect(centralPoint.y).toBeCloseTo(5, 10); + expect(maxRadius).toBeCloseTo(5, 10); + }); + + test('computeCentralPointAndRadius() with one point', () => { + const {centralPoint, maxRadius}= computeCentralPointAndRadius([makeWorldPt(83.63, 22.01)]); + expect(centralPoint.x).toBeCloseTo(83.63, 10); + expect(centralPoint.y).toBeCloseTo(22.01, 10); + expect(maxRadius).toBeCloseTo(0, 5); + }); + + test('computeCentralPointAndRadius() with two points across the 0/360 line', () => { + const {centralPoint, maxRadius}= computeCentralPointAndRadius([makeWorldPt(355, 0), makeWorldPt(15, 0)]); + expect(centralPoint.x).toBeCloseTo(5, 10); + expect(maxRadius).toBeCloseTo(10, 10); + }); + + test('computeCentralPointAndRadius() with several points across the 0/360 line', () => { + const centerOf= (lons) => computeCentralPointAndRadius(lons.map( (lon) => makeWorldPt(lon, 0))); + expect(centerOf([350, 10]).centralPoint.x).toBeCloseTo(0, 10); + expect(centerOf([350, 10]).maxRadius).toBeCloseTo(10, 10); + expect(centerOf([350, 0, 10]).centralPoint.x).toBeCloseTo(0, 10); + expect(centerOf([350, 0, 10]).maxRadius).toBeCloseTo(10, 10); + expect(centerOf([350, 10, 20]).centralPoint.x).toBeCloseTo(5, 10); + expect(centerOf([350, 10, 20]).maxRadius).toBeCloseTo(15, 10); + }); + + test('computeCentralPointAndRadius() with points spread more than 180 degrees', () => { + // the largest gap is 10 to 200, so the points cover 200 through 300 to 10, centered on 285 + const {centralPoint, maxRadius}= computeCentralPointAndRadius( + [makeWorldPt(10, 0), makeWorldPt(200, 0), makeWorldPt(300, 0)]); + expect(centralPoint.x).toBeCloseTo(285, 10); + expect(maxRadius).toBeCloseTo(85, 10); + }); + + test('computeCentralPointAndRadius() with two points exactly 180 apart keeps the 0 to 180 range', () => { + expect(computeCentralPointAndRadius([makeWorldPt(0, 0), makeWorldPt(180, 0)]).centralPoint.x) + .toBeCloseTo(90, 10); + }); + + test('computeCentralPointAndRadius() converts points to J2000', () => { + // the galactic center in J2000 + const {centralPoint}= computeCentralPointAndRadius([makeWorldPt(0, 0, CoordinateSys.GALACTIC)]); + expect(centralPoint.cSys).toBe(CoordinateSys.EQ_J2000); + expect(centralPoint.x).toBeCloseTo(266.405, 3); + expect(centralPoint.y).toBeCloseTo(-28.936, 3); + }); + + test('computeCentralPointAndRadius() with no points has no central point', () => { + expect(computeCentralPointAndRadius([]).centralPoint).toBeUndefined(); + expect(computeCentralPointAndRadius([undefined]).centralPoint).toBeUndefined(); + }); +}); + + +describe('Test VisUtil.computeCentralPtRadiusAverage()', () => { + + test('computeCentralPtRadiusAverage() with no points', () => { + const emptyResult= {centralPoint: undefined, fovSize: 0, avgOfCenters: undefined}; + expect(computeCentralPtRadiusAverage([])).toEqual(emptyResult); + expect(computeCentralPtRadiusAverage([[], []])).toEqual(emptyResult); + }); + + test('computeCentralPtRadiusAverage() with only one distinct point uses minSizeDeg', () => { + const wp= makeWorldPt(10, 20); + expect(computeCentralPtRadiusAverage([[wp], [makeWorldPt(10, 20), wp]])) + .toEqual({centralPoint: wp, fovSize: .11, avgOfCenters: wp}); + expect(computeCentralPtRadiusAverage([[wp]], .5).fovSize).toEqual(.5); + }); + + test('computeCentralPtRadiusAverage() with one group', () => { + const {centralPoint, fovSize, avgOfCenters}= + computeCentralPtRadiusAverage([[makeWorldPt(10, 0), makeWorldPt(12, 0)]]); + expect(centralPoint.x).toBeCloseTo(11, 10); + expect(centralPoint.y).toBeCloseTo(0, 10); + expect(fovSize).toBeCloseTo(1, 10); + expect(avgOfCenters).toBe(centralPoint); + }); + + test('computeCentralPtRadiusAverage() fovSize is at least minSizeDeg', () => { + const smallGroup= [[makeWorldPt(10, 0), makeWorldPt(10.01, 0)]]; + expect(computeCentralPtRadiusAverage(smallGroup).fovSize).toEqual(.11); + expect(computeCentralPtRadiusAverage(smallGroup, .3).fovSize).toEqual(.3); + }); + + test('computeCentralPtRadiusAverage() avgOfCenters uses the center of each group', () => { + // all points: lon 10 to 22, center 16. group centers: 10 and 21, center 15.5 + const {centralPoint, fovSize, avgOfCenters}= computeCentralPtRadiusAverage([ + [makeWorldPt(10, 0)], + [makeWorldPt(20, 0), makeWorldPt(21, 0), makeWorldPt(22, 0)], + ]); + expect(centralPoint.x).toBeCloseTo(16, 10); + expect(fovSize).toBeCloseTo(6, 10); + expect(avgOfCenters.x).toBeCloseTo(15.5, 10); + expect(avgOfCenters.y).toBeCloseTo(0, 10); + }); + + test('computeCentralPtRadiusAverage() skips empty groups when averaging centers', () => { + const {centralPoint, avgOfCenters}= computeCentralPtRadiusAverage([ + [makeWorldPt(10, 0), makeWorldPt(12, 0)], + [], + ]); + expect(centralPoint.x).toBeCloseTo(11, 10); + expect(avgOfCenters.x).toBeCloseTo(11, 10); + }); +}); + + +describe('Test VisUtil.getPositionAngle()', () => { + + test('getPositionAngle() is measured from north through east', () => { + expect(getPositionAngle(10, 10, 10, 20)).toBeCloseTo(0, 10); + expect(getPositionAngle(10, 0, 11, 0)).toBeCloseTo(90, 10); + expect(getPositionAngle(10, 10, 10, 0)).toBeCloseTo(180, 10); + expect(getPositionAngle(10, 0, 9, 0)).toBeCloseTo(270, 10); + }); + + test('getPositionAngle() between the compass directions', () => { + expect(getPositionAngle(0, 0, 1, 1)).toBeCloseTo(45, 1); + expect(getPositionAngle(0, 0, 1, -1)).toBeCloseTo(135, 1); + expect(getPositionAngle(0, 0, 359, -1)).toBeCloseTo(225, 1); + expect(getPositionAngle(0, 0, 359, 1)).toBeCloseTo(315, 1); + }); + + test('getPositionAngle() along a parallel away from the equator bends toward the pole', () => { + // the great circle from (10,60) to (40,60) starts out heading north of east + expect(getPositionAngle(10, 60, 40, 60)).toBeCloseTo(76.936, 3); + expect(getPositionAngle(10, -60, 40, -60)).toBeCloseTo(180-76.936, 3); + }); + + test('getPositionAngle() across the 0/360 RA line', () => { + expect(getPositionAngle(359, 0, 1, 0)).toBeCloseTo(90, 10); + expect(getPositionAngle(1, 0, 359, 0)).toBeCloseTo(270, 10); + }); + + test('getPositionAngle() reversed on the equator differs by 180', () => { + expect(getPositionAngle(20, 0, 25, 0) - getPositionAngle(25, 0, 20, 0)).toBeCloseTo(-180, 10); + }); + + test('getPositionAngle() toward the north pole is 0', () => { + expect(getPositionAngle(0, 0, 123, 90)).toBeCloseTo(0, 10); + expect(getPositionAngle(0, 0, 200, 90)).toBeCloseTo(0, 10); + }); + + test('getPositionAngle() keeps precision for very short separations', () => { + // .0001 deg is .36 arcsec + expect(getPositionAngle(10, 0, 10, .0001)).toBeCloseTo(0, 10); + expect(getPositionAngle(10, 0, 10.0001, 0)).toBeCloseTo(90, 10); + expect(getPositionAngle(10, 0, 10, -.0001)).toBeCloseTo(180, 10); + expect(getPositionAngle(10, 0, 10.0001, .0001)).toBeCloseTo(45, 6); + }); + + test('getPositionAngle() from a pole is fixed', () => { + expect(getPositionAngle(0, 90, 10, 0)).toEqual(180); + expect(getPositionAngle(0, 90, 250, 45)).toEqual(180); + expect(getPositionAngle(0, -90, 10, 0)).toEqual(0); + expect(getPositionAngle(0, -90, 250, -45)).toEqual(0); + }); + + test('getPositionAngle() of a point to itself is 0', () => { + expect(getPositionAngle(10, 20, 10, 20)).toEqual(0); + expect(getPositionAngle(10, 0, 10, .00001)).toEqual(0); + }); +}); + + +describe('Test VisUtil.getTranslateAndRotatePosition()', () => { + + test('getTranslateAndRotatePosition() moves referencePosition to rotatedReferencePosition', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(10, 20), makeWorldPt(50, -30), makeWorldPt(10, 20)); + expect(movedWp.x).toBeCloseTo(50, 10); + expect(movedWp.y).toBeCloseTo(-30, 10); + expect(movedWp.cSys).toBe(CoordinateSys.EQ_J2000); + }); + + test('getTranslateAndRotatePosition() with the same reference positions does not move the point', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(10, 20), makeWorldPt(10, 20), makeWorldPt(33, 44)); + expect(movedWp.x).toBeCloseTo(33, 10); + expect(movedWp.y).toBeCloseTo(44, 10); + }); + + test('getTranslateAndRotatePosition() a change in RA only shifts RA at any dec', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(10, 40), makeWorldPt(30, 40), makeWorldPt(12, 50)); + expect(movedWp.x).toBeCloseTo(32, 10); + expect(movedWp.y).toBeCloseTo(50, 10); + }); + + test('getTranslateAndRotatePosition() a change in dec along a meridian shifts dec', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(100, 0), makeWorldPt(100, 20), makeWorldPt(100, 10)); + expect(movedWp.x).toBeCloseTo(100, 10); + expect(movedWp.y).toBeCloseTo(30, 10); + }); + + test('getTranslateAndRotatePosition() result RA is in [0, 360)', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(350, 0), makeWorldPt(10, 0), makeWorldPt(355, 0)); + expect(movedWp.x).toBeCloseTo(15, 10); + expect(movedWp.y).toBeCloseTo(0, 10); + }); + + test('getTranslateAndRotatePosition() can move a point to the pole', () => { + const movedWp= getTranslateAndRotatePosition(makeWorldPt(0, 0), makeWorldPt(0, 90), makeWorldPt(0, 0)); + expect(movedWp.y).toBeCloseTo(90, 10); + }); + + test('getTranslateAndRotatePosition() keeps the shape: distances between moved points do not change', () => { + const refWp= makeWorldPt(100, 0); + const rotatedRefWp= makeWorldPt(250, 45); + const shapeWps= [makeWorldPt(100.5, .2), makeWorldPt(99.7, -.4), makeWorldPt(100.1, .6)]; + const movedWps= shapeWps.map( (wp) => getTranslateAndRotatePosition(refWp, rotatedRefWp, wp)); + + shapeWps.forEach( (wp, idx) => { + expect(computeDistance(rotatedRefWp, movedWps[idx])).toBeCloseTo(computeDistance(refWp, wp), 8); + const nextIdx= (idx+1) % shapeWps.length; + expect(computeDistance(movedWps[idx], movedWps[nextIdx])) + .toBeCloseTo(computeDistance(wp, shapeWps[nextIdx]), 8); + }); + }); +}); + + +describe('Test VisUtil.getAngleInDeg()', () => { + + test('getAngleInDeg() along the axes', () => { + expect(getAngleInDeg(0, 0, 10, 0)).toEqual(0); + expect(getAngleInDeg(0, 0, 0, 10)).toEqual(90); + expect(getAngleInDeg(0, 0, -10, 0)).toEqual(180); + expect(getAngleInDeg(0, 0, 0, -10)).toEqual(270); + }); + + test('getAngleInDeg() in each quadrant', () => { + expect(getAngleInDeg(0, 0, 10, 10)).toBeCloseTo(45, 10); + expect(getAngleInDeg(0, 0, -10, 10)).toBeCloseTo(135, 10); + expect(getAngleInDeg(0, 0, -10, -10)).toBeCloseTo(225, 10); + expect(getAngleInDeg(0, 0, 10, -10)).toBeCloseTo(315, 10); + expect(getAngleInDeg(0, 0, 3, 1)).toBeCloseTo(18.4349, 4); + expect(getAngleInDeg(0, 0, -1, -3)).toBeCloseTo(251.5651, 4); + }); + + test('getAngleInDeg() is measured from the center point (cx, cy)', () => { + expect(getAngleInDeg(5, 5, 15, 5)).toEqual(0); + expect(getAngleInDeg(100, 200, 90, 210)).toBeCloseTo(135, 10); + }); + + test('getAngleInDeg() rounds all coordinates to whole pixels first', () => { + expect(getAngleInDeg(0, 0, .4, 10)).toEqual(90); + expect(getAngleInDeg(.4, .4, 10.2, 9.8)).toBeCloseTo(45, 10); + // both points round to the same pixel + expect(getAngleInDeg(0, 0, .4, .3)).toEqual(0); + }); +}); + + +describe('Test VisUtil.intersects()', () => { + + test('intersects() with overlapping rectangles', () => { + expect(intersects(0, 0, 10, 10, 5, 5, 10, 10)).toBe(true); + expect(intersects(5, 5, 10, 10, 0, 0, 10, 10)).toBe(true); + expect(intersects(0, 0, 10, 10, -5, 2, 30, 3)).toBe(true); + }); + + test('intersects() with one rectangle inside the other', () => { + expect(intersects(0, 0, 100, 100, 40, 40, 1, 1)).toBe(true); + expect(intersects(40, 40, 1, 1, 0, 0, 100, 100)).toBe(true); + }); + + test('intersects() with separate rectangles', () => { + expect(intersects(0, 0, 10, 10, 20, 0, 5, 5)).toBe(false); + expect(intersects(0, 0, 10, 10, 0, 20, 5, 5)).toBe(false); + expect(intersects(0, 0, 10, 10, -10, -10, 5, 5)).toBe(false); + }); + + test('intersects() rectangles that only share an edge do not intersect', () => { + expect(intersects(0, 0, 10, 10, 10, 0, 10, 10)).toBe(false); + expect(intersects(0, 0, 10, 10, 0, 10, 10, 10)).toBe(false); + }); + + test('intersects() with a zero or negative size is false', () => { + expect(intersects(0, 0, 0, 10, 0, 0, 10, 10)).toBe(false); + expect(intersects(0, 0, 10, 10, 2, 2, 5, 0)).toBe(false); + expect(intersects(0, 0, 10, -10, 0, 0, 10, 10)).toBe(false); + }); +}); + + +describe('Test VisUtil.lineIntersect2()', () => { + + test('lineIntersect2() with crossing segments', () => { + expect(lineIntersect2(0, 0, 10, 10, 0, 10, 10, 0)).toBe(true); + expect(lineIntersect2(10, 10, 0, 0, 10, 0, 0, 10)).toBe(true); + expect(lineIntersect2(0, 0, 10, 0, 3, -1, 3, 1)).toBe(true); + expect(lineIntersect2(5, 0, 5, 10, 0, 3, 10, 7)).toBe(true); + }); + + test('lineIntersect2() vertical and horizontal segments with fractional coordinates', () => { + expect(lineIntersect2(.7, 0, .7, 1, .1, .2, .9, .5)).toBe(true); + expect(lineIntersect2(1.1, 0, 1.1, 2, .3, .4, 1.9, 1.3)).toBe(true); + expect(lineIntersect2(0, .7, 1, .7, .2, .1, .5, .9)).toBe(true); + expect(lineIntersect2(0, 1.1, 2, 1.1, .4, .3, 1.3, 1.9)).toBe(true); + }); + + test('lineIntersect2() segments that touch at an end point intersect', () => { + expect(lineIntersect2(0, 0, 10, 10, 10, 10, 20, 0)).toBe(true); + expect(lineIntersect2(0, 0, 10, 0, 5, 0, 5, 10)).toBe(true); + }); + + test('lineIntersect2() segments whose lines cross outside the segments do not intersect', () => { + expect(lineIntersect2(0, 0, 5, 5, 6, 0, 10, -4)).toBe(false); + expect(lineIntersect2(0, 0, 10, 0, 3, 1, 3, 5)).toBe(false); + }); + + test('lineIntersect2() parallel or collinear segments do not intersect', () => { + expect(lineIntersect2(0, 0, 10, 0, 0, 5, 10, 5)).toBe(false); + expect(lineIntersect2(0, 0, 1, 1, 2, 2, 3, 3)).toBe(false); + // collinear and overlapping is also false + expect(lineIntersect2(0, 0, 10, 0, 5, 0, 15, 0)).toBe(false); + }); +}); + + +describe('Test VisUtil.contains()', () => { + + test('contains() with points inside and outside the rectangle', () => { + expect(contains(0, 0, 10, 20, 5, 5)).toBe(true); + expect(contains(-10, -10, 5, 5, -7, -8)).toBe(true); + expect(contains(0, 0, 10, 20, 15, 5)).toBe(false); + expect(contains(0, 0, 10, 20, 5, 25)).toBe(false); + expect(contains(0, 0, 10, 20, -1, 5)).toBe(false); + }); + + test('contains() includes the top and left edges but not the bottom and right edges', () => { + expect(contains(0, 0, 10, 20, 0, 0)).toBe(true); + expect(contains(0, 0, 10, 20, 0, 19.999)).toBe(true); + expect(contains(0, 0, 10, 20, 10, 5)).toBe(false); + expect(contains(0, 0, 10, 20, 5, 20)).toBe(false); + }); +}); + + +describe('Test VisUtil.containsRec()', () => { + + test('containsRec() with a rectangle inside', () => { + expect(containsRec(0, 0, 100, 100, 10, 10, 20, 30)).toBe(true); + expect(containsRec(0, 0, 100, 100, 0, 0, 50, 50)).toBe(true); + }); + + test('containsRec() with a rectangle partly or fully outside', () => { + expect(containsRec(0, 0, 100, 100, 90, 10, 20, 20)).toBe(false); + expect(containsRec(0, 0, 100, 100, -5, 10, 20, 20)).toBe(false); + expect(containsRec(0, 0, 100, 100, 200, 200, 5, 5)).toBe(false); + expect(containsRec(10, 10, 5, 5, 0, 0, 100, 100)).toBe(false); + }); + + test('containsRec() a rectangle that reaches the edges is contained', () => { + expect(containsRec(0, 0, 100, 100, 50, 50, 50, 10)).toBe(true); + expect(containsRec(0, 0, 100, 100, 50, 50, 10, 50)).toBe(true); + expect(containsRec(0, 0, 100, 100, 0, 0, 100, 100)).toBe(true); + expect(containsRec(0, 0, 100, 100, 50, 50, 50.1, 10)).toBe(false); + }); +}); + + +describe('Test VisUtil.containsCircle()', () => { + + test('containsCircle() with points inside and outside the circle', () => { + expect(containsCircle(10, 10, 10, 10, 5)).toBe(true); + expect(containsCircle(13, 14, 10, 10, 5.1)).toBe(true); + expect(containsCircle(14, 14, 10, 10, 5)).toBe(false); + expect(containsCircle(-20, 10, 10, 10, 5)).toBe(false); + }); + + test('containsCircle() a point on the circle is not contained', () => { + expect(containsCircle(13, 14, 10, 10, 5)).toBe(false); + expect(containsCircle(15, 10, 10, 10, 5)).toBe(false); + }); +}); + + +describe('Test VisUtil.containsEllipse()', () => { + + test('containsEllipse() radius1 is along x and radius2 is along y', () => { + expect(containsEllipse(18, 10, 10, 10, 10, 2)).toBe(true); + expect(containsEllipse(10, 18, 10, 10, 10, 2)).toBe(false); + expect(containsEllipse(10, 11.5, 10, 10, 10, 2)).toBe(true); + }); + + test('containsEllipse() with points inside and outside the ellipse', () => { + expect(containsEllipse(0, 0, 0, 0, 4, 3)).toBe(true); + expect(containsEllipse(2, 2, 0, 0, 4, 3)).toBe(true); + expect(containsEllipse(3, 3, 0, 0, 4, 3)).toBe(false); + expect(containsEllipse(-3, -2.5, 0, 0, 4, 3)).toBe(false); + }); + + test('containsEllipse() a point on the ellipse is not contained', () => { + expect(containsEllipse(4, 0, 0, 0, 4, 3)).toBe(false); + expect(containsEllipse(0, -3, 0, 0, 4, 3)).toBe(false); + }); + + test('containsEllipse() with equal radii is the same as containsCircle()', () => { + [[3, 4], [3.5, 3.5], [0, 4.9], [5, 0]].forEach( ([x, y]) => + expect(containsEllipse(x, y, 0, 0, 5, 5)).toBe(containsCircle(x, y, 0, 0, 5))); + }); +}); + + +describe('Test VisUtil.getArrowCoords()', () => { + + const sin20= Math.sin(20 * Math.PI / 180); + const cos20= Math.cos(20 * Math.PI / 180); + + test('getArrowCoords() passes through the shaft and puts the first barb point at the head', () => { + const arrow= getArrowCoords(1, 2, 30, 40); + expect(arrow).toMatchObject({x1: 1, y1: 2, x2: 30, y2: 40, barbX1: 30, barbY1: 40, textBaseline: 'top'}); + }); + + test('getArrowCoords() barb is 10 long and 20 degrees off the shaft', () => { + const rightArrow= getArrowCoords(0, 0, 10, 0); + expect(rightArrow.barbX2).toBeCloseTo(10 - 10*cos20, 10); + expect(rightArrow.barbY2).toBeCloseTo(10*sin20, 10); + + const leftArrow= getArrowCoords(10, 0, 0, 0); + expect(leftArrow.barbX2).toBeCloseTo(10*cos20, 10); + expect(leftArrow.barbY2).toBeCloseTo(-10*sin20, 10); + + const downArrow= getArrowCoords(0, 0, 0, 10); + expect(downArrow.barbX2).toBeCloseTo(-10*sin20, 10); + expect(downArrow.barbY2).toBeCloseTo(10 - 10*cos20, 10); + }); + + test('getArrowCoords() barb length does not depend on the shaft length', () => { + [[0, 0, 3, 4], [5, 5, -100, 37], [0, 0, 0, -1]].forEach( ([x1, y1, x2, y2]) => { + const {barbX2, barbY2}= getArrowCoords(x1, y1, x2, y2); + expect(computeScreenDistance(x2, y2, barbX2, barbY2)).toBeCloseTo(10, 10); + }); + }); + + test('getArrowCoords() text for a vertical or steep arrow is past the head', () => { + expect(getArrowCoords(0, 0, 0, 10)).toMatchObject({textX: 0, textY: 24}); + expect(getArrowCoords(0, 10, 0, 0)).toMatchObject({textX: 0, textY: -14}); + expect(getArrowCoords(0, 0, 1, 3)).toMatchObject({textX: 1, textY: 17}); + expect(getArrowCoords(0, 0, 1, -3)).toMatchObject({textX: 1, textY: -17}); + }); + + test('getArrowCoords() text for a shallow arrow is beside the head', () => { + expect(getArrowCoords(0, 0, 10, 0)).toMatchObject({textX: 12, textY: -6}); + expect(getArrowCoords(10, 0, 0, 0)).toMatchObject({textX: -8, textY: -6}); + expect(getArrowCoords(0, 0, 10, 29)).toMatchObject({textX: 12, textY: 23}); + }); +}); + + +describe('Test VisUtil.getBoundingBox()', () => { + + test('getBoundingBox() of several points', () => { + expect(getBoundingBox([{x: 3, y: 7}, {x: -2, y: 4}, {x: 10, y: -1}, {x: 5, y: 5}])) + .toEqual({x: -2, y: -1, w: 12, h: 8}); + }); + + test('getBoundingBox() does not depend on point order', () => { + const pts= [makeScreenPt(1, 9), makeScreenPt(4, 2), makeScreenPt(-3, 6)]; + const expectedBox= {x: -3, y: 2, w: 7, h: 7}; + expect(getBoundingBox(pts)).toEqual(expectedBox); + expect(getBoundingBox([...pts].reverse())).toEqual(expectedBox); + }); + + test('getBoundingBox() of one point or a line has zero size', () => { + expect(getBoundingBox([{x: 4, y: 5}])).toEqual({x: 4, y: 5, w: 0, h: 0}); + expect(getBoundingBox([{x: 1, y: 5}, {x: 9, y: 5}])).toEqual({x: 1, y: 5, w: 8, h: 0}); + }); + + test('getBoundingBox() sorts numerically, not as strings', () => { + expect(getBoundingBox([{x: 100, y: 9}, {x: 9, y: 100}, {x: 20, y: 30}])) + .toEqual({x: 9, y: 9, w: 91, h: 91}); + }); +}); + + +describe('Test VisUtil.calculatePosition()', () => { + + test('calculatePosition() with no offset returns the same position', () => { + const movedWp= calculatePosition(makeWorldPt(10, 20), 0, 0); + expect(movedWp.x).toBeCloseTo(10, 10); + expect(movedWp.y).toBeCloseTo(20, 10); + expect(movedWp.cSys).toBe(CoordinateSys.EQ_J2000); + }); + + test('calculatePosition() offsetDec is north in arcsec', () => { + const northWp= calculatePosition(makeWorldPt(10, 20), 0, 3600); + expect(northWp.x).toBeCloseTo(10, 10); + expect(northWp.y).toBeCloseTo(21, 10); + + const southWp= calculatePosition(makeWorldPt(10, 20), 0, -3600); + expect(southWp.x).toBeCloseTo(10, 10); + expect(southWp.y).toBeCloseTo(19, 10); + }); + + test('calculatePosition() offsetRa is east in arcsec', () => { + const eastWp= calculatePosition(makeWorldPt(10, 0), 3600, 0); + expect(eastWp.x).toBeCloseTo(11, 10); + expect(eastWp.y).toBeCloseTo(0, 10); + + const westWp= calculatePosition(makeWorldPt(10, 0), -3600, 0); + expect(westWp.x).toBeCloseTo(9, 10); + expect(westWp.y).toBeCloseTo(0, 10); + }); + + test('calculatePosition() offsetRa is a distance on the sky, not a change in RA', () => { + // at dec 60, 1 degree east on the sky is about 2 degrees of RA + const centerWp= makeWorldPt(10, 60); + const eastWp= calculatePosition(centerWp, 3600, 0); + expect(computeDistance(centerWp, eastWp)).toBeCloseTo(1, 10); + expect(eastWp.x).toBeCloseTo(12, 2); + expect(eastWp.y).toBeLessThan(60); + }); + + test('calculatePosition() distance from the center combines both offsets', () => { + const centerWp= makeWorldPt(10, 30); + const movedWp= calculatePosition(centerWp, 1800, 2400); + const expectedDist= Math.acos(Math.cos(.5 * Math.PI/180) * Math.cos(2/3 * Math.PI/180)) * 180/Math.PI; + expect(computeDistance(centerWp, movedWp)).toBeCloseTo(expectedDist, 10); + expect(movedWp.x).toBeGreaterThan(10); + expect(movedWp.y).toBeGreaterThan(30); + }); + + test('calculatePosition() result RA is in [0, 360) across the 0/360 line', () => { + expect(calculatePosition(makeWorldPt(359.5, 0), 3600, 0).x).toBeCloseTo(.5, 10); + expect(calculatePosition(makeWorldPt(.5, 0), -3600, 0).x).toBeCloseTo(359.5, 10); + }); +}); + + +describe('Test VisUtil.transformToEastNorth()', () => { + + test('transformToEastNorth() with no rotation, x is east and y is north', () => { + expect(transformToEastNorth(3, 4)).toEqual({east: 3, north: 4}); + expect(transformToEastNorth(-2, 5, 0)).toEqual({east: -2, north: 5}); + }); + + test('transformToEastNorth() rotated 90, y is east and x is south', () => { + const yAxis= transformToEastNorth(0, 1, 90); + expect(yAxis.east).toBeCloseTo(1, 10); + expect(yAxis.north).toBeCloseTo(0, 10); + const xAxis= transformToEastNorth(1, 0, 90); + expect(xAxis.east).toBeCloseTo(0, 10); + expect(xAxis.north).toBeCloseTo(-1, 10); + }); + + test('transformToEastNorth() rotated 180 reverses both axes', () => { + const {east, north}= transformToEastNorth(3, 4, 180); + expect(east).toBeCloseTo(-3, 10); + expect(north).toBeCloseTo(-4, 10); + }); + + test('transformToEastNorth() keeps the length of the offset', () => { + const {east, north}= transformToEastNorth(3, 4, 30); + expect(east).toBeCloseTo(3*Math.cos(Math.PI/6) + 4*Math.sin(Math.PI/6), 10); + expect(north).toBeCloseTo(-3*Math.sin(Math.PI/6) + 4*Math.cos(Math.PI/6), 10); + expect(Math.hypot(east, north)).toBeCloseTo(5, 10); + }); +}); + + +describe('Test VisUtil.calculatePositionFromLocalOffsets()', () => { + + test('calculatePositionFromLocalOffsets() with no rotation is the same as calculatePosition()', () => { + const centerWp= makeWorldPt(10, 30); + const movedWp= calculatePositionFromLocalOffsets(centerWp, 1800, 2400); + const expectedWp= calculatePosition(centerWp, 1800, 2400); + expect(movedWp.x).toBeCloseTo(expectedWp.x, 10); + expect(movedWp.y).toBeCloseTo(expectedWp.y, 10); + expect(movedWp.cSys).toBe(CoordinateSys.EQ_J2000); + }); + + test('calculatePositionFromLocalOffsets() rotated 90, the y offset goes east', () => { + const movedWp= calculatePositionFromLocalOffsets(makeWorldPt(10, 0), 0, 3600, 90); + expect(movedWp.x).toBeCloseTo(11, 10); + expect(movedWp.y).toBeCloseTo(0, 10); + }); + + test('calculatePositionFromLocalOffsets() rotated 90, the x offset goes south', () => { + const movedWp= calculatePositionFromLocalOffsets(makeWorldPt(10, 20), 3600, 0, 90); + expect(movedWp.x).toBeCloseTo(10, 10); + expect(movedWp.y).toBeCloseTo(19, 10); + }); + + test('calculatePositionFromLocalOffsets() returns the center coordinate system', () => { + // the offsets are along equatorial east and north, then the result is converted back to galactic + const centerWp= makeWorldPt(10, 20, CoordinateSys.GALACTIC); + const movedWp= calculatePositionFromLocalOffsets(centerWp, 3600, 0); + expect(movedWp.cSys).toBe(CoordinateSys.GALACTIC); + expect(computeDistance(centerWp, movedWp)).toBeCloseTo(1, 5); + }); +}); + + +describe('Test VisUtil.isAngleUnit()', () => { + + test('isAngleUnit() with angle units, ignoring case', () => { + ['deg', 'degree', 'arcmin', 'arcsec', 'radian', 'rad', 'DEG', 'ArcSec'] + .forEach( (unit) => expect(isAngleUnit(unit)).toBe(true)); + }); + + test('isAngleUnit() with other strings', () => { + ['', 'degrees', 'arcminute', 'mas', 'hour', 'meter'] + .forEach( (unit) => expect(isAngleUnit(unit)).toBe(false)); + }); +}); + + +describe('Test VisUtil.convertAngle()', () => { + + test('convertAngle() between degree, arcmin and arcsec', () => { + expect(convertAngle('deg', 'arcmin', 2)).toEqual(120); + expect(convertAngle('degree', 'arcsec', 1)).toEqual(3600); + expect(convertAngle('arcmin', 'arcsec', 1.5)).toEqual(90); + expect(convertAngle('arcsec', 'deg', 36)).toBeCloseTo(.01, 12); + expect(convertAngle('arcmin', 'degree', 30)).toBeCloseTo(.5, 12); + }); + + test('convertAngle() to and from radians', () => { + expect(convertAngle('rad', 'deg', Math.PI)).toBeCloseTo(180, 10); + expect(convertAngle('deg', 'radian', 90)).toBeCloseTo(Math.PI/2, 12); + expect(convertAngle('arcmin', 'rad', 60)).toBeCloseTo(Math.PI/180, 12); + expect(convertAngle('arcsec', 'rad', 3600)).toBeCloseTo(Math.PI/180, 12); + expect(convertAngle('rad', 'arcsec', 1, false)).toBeCloseTo(206264.806, 3); + expect(convertAngle('radian', 'rad', 2)).toBeCloseTo(2, 12); + }); + + test('convertAngle() units ignore case', () => { + expect(convertAngle('DEG', 'ArcMin', 2)).toEqual(120); + expect(convertAngle('Rad', 'DEGREE', Math.PI)).toBeCloseTo(180, 10); + }); + + test('convertAngle() parses a string angle', () => { + expect(convertAngle('deg', 'arcmin', '2.5')).toEqual(150); + expect(convertAngle('degree', 'deg', '2.5')).toEqual(2.5); + }); + + test('convertAngle() limits arcsec to 1 decimal place and arcmin to 4 by rounding', () => { + expect(convertAngle('deg', 'arcsec', .0001)).toEqual(.4); + expect(convertAngle('deg', 'arcmin', .123456789)).toEqual(7.4074); + expect(convertAngle('deg', 'arcsec', .0001, false)).toBeCloseTo(.36, 12); + expect(convertAngle('deg', 'arcmin', .123456789, false)).toBeCloseTo(7.40740734, 8); + }); + + test('convertAngle() rounding ignores the case of the target unit', () => { + expect(convertAngle('deg', 'ARCSEC', .0001)).toEqual(.4); + expect(convertAngle('deg', 'ArcMin', .123456789)).toEqual(7.4074); + expect(convertAngle('deg', 'ARCSEC', .0001, false)).toBeCloseTo(.36, 12); + }); + + test('convertAngle() with an unknown unit returns the angle unchanged', () => { + expect(convertAngle('foo', 'deg', 5)).toEqual(5); + expect(convertAngle('deg', 'foo', '5')).toEqual(5); + }); +}); + + +describe('Test VisUtil.getLinePointAry()', () => { + + const toXYAry= (ptAry) => ptAry.map( ({x, y}) => [x, y]); + + test('getLinePointAry() returns one image point per pixel along the longer axis', () => { + const ptAry= getLinePointAry({x: 0, y: 5}, {x: 4, y: 5}); + expect(toXYAry(ptAry)).toEqual([[0, 5], [1, 5], [2, 5], [3, 5], [4, 5]]); + ptAry.forEach( (pt) => expect(pt.type).toEqual(Point.IM_PT)); + }); + + test('getLinePointAry() with a vertical line', () => { + expect(toXYAry(getLinePointAry({x: 2, y: 1}, {x: 2, y: 4}))).toEqual([[2, 1], [2, 2], [2, 3], [2, 4]]); + }); + + test('getLinePointAry() with shallow, diagonal and steep lines', () => { + expect(toXYAry(getLinePointAry({x: 0, y: 0}, {x: 4, y: 1}))) + .toEqual([[0, 0], [1, 0], [2, 0], [3, 0], [4, 1]]); + expect(toXYAry(getLinePointAry({x: 0, y: 0}, {x: 3, y: 3}))) + .toEqual([[0, 0], [1, 1], [2, 2], [3, 3]]); + expect(toXYAry(getLinePointAry({x: 0, y: 0}, {x: 1, y: 4}))) + .toEqual([[0, 0], [0, 1], [0, 2], [0, 3], [1, 4]]); + }); + + test('getLinePointAry() points are in increasing order whatever the direction of the line', () => { + expect(getLinePointAry({x: 1, y: 4}, {x: 0, y: 0})).toEqual(getLinePointAry({x: 0, y: 0}, {x: 1, y: 4})); + expect(getLinePointAry({x: 4, y: 5}, {x: 0, y: 5})).toEqual(getLinePointAry({x: 0, y: 5}, {x: 4, y: 5})); + }); + + test('getLinePointAry() fractional end points are rounded down to whole pixels', () => { + expect(toXYAry(getLinePointAry({x: .7, y: 2.2}, {x: 3.2, y: 2.9}))) + .toEqual([[0, 2], [1, 2], [2, 2], [3, 2]]); + }); + + test('getLinePointAry() negative coordinates are rounded down, not toward zero', () => { + // at x=-2 and x=-1 the line is at y=-0.67 and y=-0.33, which are in pixel row -1 + expect(toXYAry(getLinePointAry({x: -3, y: -1}, {x: 0, y: 0}))) + .toEqual([[-3, -1], [-2, -1], [-1, -1], [0, 0]]); + // a line starting at x=-0.5 starts in pixel column -1 + expect(toXYAry(getLinePointAry({x: -.5, y: 2}, {x: 3, y: 2}))) + .toEqual([[-1, 2], [0, 2], [1, 2], [2, 2], [3, 2]]); + }); + + test('getLinePointAry() with the same point twice returns undefined', () => { + expect(getLinePointAry({x: 2, y: 2}, {x: 2, y: 2})).toBeUndefined(); + }); +}); + + +describe('Test VisUtil.getPointOnEllipse()', () => { + + test('getPointOnEllipse() at the four axis angles', () => { + // rx is along x and ry is along y, angle is in radians counterclockwise from +x + const expectedPts= [[0, 15, 20], [Math.PI/2, 10, 22], [Math.PI, 5, 20], [3*Math.PI/2, 10, 18]]; + expectedPts.forEach( ([angle, expectedX, expectedY]) => { + const {x, y}= getPointOnEllipse(10, 20, 5, 2, angle); + expect(x).toBeCloseTo(expectedX, 10); + expect(y).toBeCloseTo(expectedY, 10); + }); + }); + + test('getPointOnEllipse() points satisfy the ellipse equation', () => { + [.3, 1.1, 2.5, 4, 5.9].forEach( (angle) => { + const {x, y}= getPointOnEllipse(10, 20, 5, 2, angle); + expect(((x-10)/5)**2 + ((y-20)/2)**2).toBeCloseTo(1, 10); + }); + }); + + test('getPointOnEllipse() uses the absolute value of the radii', () => { + const {x, y}= getPointOnEllipse(0, 0, -5, -2, Math.PI/4); + const expectedPt= getPointOnEllipse(0, 0, 5, 2, Math.PI/4); + expect(x).toBeCloseTo(expectedPt.x, 10); + expect(y).toBeCloseTo(expectedPt.y, 10); + }); + + test('getPointOnEllipse() with equal radii is a circle', () => { + const {x, y}= getPointOnEllipse(0, 0, 3, 3, Math.PI/6); + expect(x).toBeCloseTo(3*Math.cos(Math.PI/6), 10); + expect(y).toBeCloseTo(1.5, 10); + }); +}); + + +describe('Test VisUtil.getEllipseArcEndPoints()', () => { + + // getEllipseArcEndPoints() is the exported way to reach the private getEllipsePointForAngle() with a rotation + + test('getEllipseArcEndPoints() without rotation matches getPointOnEllipse()', () => { + const {x1, y1, x2, y2}= getEllipseArcEndPoints(10, 20, 5, 2, .4, 1.2); + const startPt= getPointOnEllipse(10, 20, 5, 2, .4); + const endPt= getPointOnEllipse(10, 20, 5, 2, 1.6); + expect(x1).toBeCloseTo(startPt.x, 10); + expect(y1).toBeCloseTo(startPt.y, 10); + expect(x2).toBeCloseTo(endPt.x, 10); + expect(y2).toBeCloseTo(endPt.y, 10); + }); + + test('getEllipseArcEndPoints() rotating the ellipse 90 degrees swaps its axes', () => { + const {x1, y1, x2, y2}= getEllipseArcEndPoints(0, 0, 5, 2, 0, Math.PI/2, Math.PI/2); + expect(x1).toBeCloseTo(0, 10); + expect(y1).toBeCloseTo(5, 10); + expect(x2).toBeCloseTo(-2, 10); + expect(y2).toBeCloseTo(0, 10); + }); + + test('getEllipseArcEndPoints() large arc and sweep flags', () => { + expect(getEllipseArcEndPoints(0, 0, 5, 2, 0, Math.PI/2)).toMatchObject({fa: 0, fs: 1}); + expect(getEllipseArcEndPoints(0, 0, 5, 2, 0, -3*Math.PI/2)).toMatchObject({fa: 1, fs: 0}); + }); +}); + + +describe('Test VisUtil.normalizeRotation()', () => { + + test('normalizeRotation() keeps the angle between -360 and 360', () => { + expect(normalizeRotation(45.5)).toEqual(45.5); + expect(normalizeRotation(370)).toEqual(10); + expect(normalizeRotation(-370)).toEqual(-10); + expect(normalizeRotation(720)).toEqual(0); + expect(normalizeRotation(360)).toEqual(0); + }); + + test('normalizeRotation() parses a string', () => { + expect(normalizeRotation('45')).toEqual(45); + expect(normalizeRotation('400')).toEqual(40); + }); + + test('normalizeRotation() with a missing or non-finite value returns 0', () => { + [undefined, null, NaN, 'abc', Infinity, -Infinity] + .forEach( (rotation) => expect(normalizeRotation(rotation)).toEqual(0)); + expect(normalizeRotation()).toEqual(0); + }); +}); + + +// a stand-in for a CysConverter: screen points pass through, other points are scaled by 2 +const scaleByTwoConverter= { + getScreenCoords: (pt) => pt.type===Point.SPT ? pt : makeScreenPt(pt.x*2, pt.y*2) +}; + + +// like scaleByTwoConverter, but a point with a negative x can't be converted, like a point off the projection +const partialConverter= { + getScreenCoords: (pt) => pt.x<0 ? null : scaleByTwoConverter.getScreenCoords(pt) +}; + +describe('Test VisUtil.distToLine()', () => { + + const segment= [{x: 0, y: 0}, {x: 10, y: 0}]; + + test('distToLine() with a point beside the segment is the perpendicular distance', () => { + expect(distToLine(segment, undefined, {x: 5, y: 3})).toBeCloseTo(3, 10); + expect(distToLine(segment, undefined, {x: 2, y: -4})).toBeCloseTo(4, 10); + expect(distToLine([{x: 0, y: 0}, {x: 10, y: 10}], undefined, {x: 0, y: 10})).toBeCloseTo(Math.sqrt(50), 10); + }); + + test('distToLine() with a point past either end is the distance to that end', () => { + expect(distToLine(segment, undefined, {x: 13, y: 4})).toBeCloseTo(5, 10); + expect(distToLine(segment, undefined, {x: -3, y: -4})).toBeCloseTo(5, 10); + expect(distToLine(segment, undefined, {x: 0, y: 3})).toBeCloseTo(3, 10); + }); + + test('distToLine() with a point on the segment is 0', () => { + expect(distToLine(segment, undefined, {x: 4, y: 0})).toBeCloseTo(0, 10); + expect(distToLine(segment, undefined, {x: 10, y: 0})).toBeCloseTo(0, 10); + }); + + test('distToLine() with a zero length segment is the distance to that point', () => { + expect(distToLine([{x: 2, y: 2}, {x: 2, y: 2}], undefined, {x: 5, y: 6})).toBeCloseTo(5, 10); + }); + + test('distToLine() with a converter measures in screen coordinates', () => { + const imSegment= [makeImagePt(0, 0), makeImagePt(10, 0)]; + expect(distToLine(imSegment, scaleByTwoConverter, makeImagePt(5, 3))).toBeCloseTo(6, 10); + }); + + test('distToLine() with a point that cannot be converted to screen coordinates returns NaN', () => { + const imSegment= [makeImagePt(0, 0), makeImagePt(10, 0)]; + expect(distToLine(imSegment, partialConverter, makeImagePt(-5, 3))).toBeNaN(); + expect(distToLine([makeImagePt(-1, 0), makeImagePt(10, 0)], partialConverter, makeImagePt(5, 3))).toBeNaN(); + expect(distToLine([makeImagePt(0, 0), makeImagePt(-10, 0)], partialConverter, makeImagePt(5, 3))).toBeNaN(); + }); + + test('distToLine() with a missing point returns NaN', () => { + expect(distToLine(segment, undefined, undefined)).toBeNaN(); + expect(distToLine(undefined, undefined, {x: 1, y: 1})).toBeNaN(); + expect(distToLine([{x: 0, y: 0}], undefined, {x: 1, y: 1})).toBeNaN(); + }); +}); + + +describe('Test VisUtil.distanceToPolygon()', () => { + + const square= [{x: 0, y: 0}, {x: 10, y: 0}, {x: 10, y: 10}, {x: 0, y: 10}]; + + test('distanceToPolygon() with a point inside is the distance to the nearest edge', () => { + expect(distanceToPolygon(square, undefined, {x: 5, y: 5})).toBeCloseTo(5, 10); + expect(distanceToPolygon(square, undefined, {x: 5, y: 2})).toBeCloseTo(2, 10); + expect(distanceToPolygon(square, undefined, {x: 9, y: 4})).toBeCloseTo(1, 10); + }); + + test('distanceToPolygon() with a point outside', () => { + expect(distanceToPolygon(square, undefined, {x: 15, y: 5})).toBeCloseTo(5, 10); + expect(distanceToPolygon(square, undefined, {x: 13, y: 14})).toBeCloseTo(5, 10); + }); + + test('distanceToPolygon() with a point on an edge is 0', () => { + expect(distanceToPolygon(square, undefined, {x: 0, y: 4})).toBeCloseTo(0, 10); + }); + + test('distanceToPolygon() includes the edge from the last point back to the first', () => { + const triangle= [{x: 0, y: 0}, {x: 10, y: 0}, {x: 0, y: 10}]; + // nearest to the closing edge (0,10)-(0,0) + expect(distanceToPolygon(triangle, undefined, {x: 1, y: 5})).toBeCloseTo(1, 10); + }); + + test('distanceToPolygon() with a converter measures in screen coordinates', () => { + const imSquare= square.map( ({x, y}) => makeImagePt(x, y)); + expect(distanceToPolygon(imSquare, scaleByTwoConverter, makeImagePt(5, 2))).toBeCloseTo(4, 10); + }); + + test('distanceToPolygon() with fewer than 3 points returns Number.MAX_VALUE', () => { + expect(distanceToPolygon([{x: 0, y: 0}, {x: 10, y: 0}], undefined, {x: 5, y: 5})).toEqual(Number.MAX_VALUE); + }); +}); + + +describe('Test VisUtil.toRadians() and toDegrees()', () => { + + test('toRadians() converts degrees to radians', () => { + expect(toRadians(0)).toEqual(0); + expect(toRadians(180)).toBeCloseTo(Math.PI, 12); + expect(toRadians(90)).toBeCloseTo(Math.PI/2, 12); + expect(toRadians(-45)).toBeCloseTo(-Math.PI/4, 12); + expect(toRadians(720)).toBeCloseTo(4*Math.PI, 12); + }); + + test('toDegrees() converts radians to degrees', () => { + expect(toDegrees(0)).toEqual(0); + expect(toDegrees(Math.PI)).toBeCloseTo(180, 12); + expect(toDegrees(-Math.PI/2)).toBeCloseTo(-90, 12); + expect(toDegrees(1)).toBeCloseTo(57.29577951, 8); + }); + + test('toDegrees() undoes toRadians()', () => { + [0, 12.5, -270, 359.999].forEach( (angle) => expect(toDegrees(toRadians(angle))).toBeCloseTo(angle, 10)); + }); +}); + + +describe('Test VisUtil.findIntersectionPt()', () => { + + test('findIntersectionPt() with crossing segments', () => { + expect(findIntersectionPt(0, 0, 10, 10, 0, 10, 10, 0)).toEqual({x: 5, y: 5, onSeg1: true, onSeg2: true}); + const result= findIntersectionPt(0, 0, 4, 0, 1, -1, 1, 3); + expect(result.x).toBeCloseTo(1, 10); + expect(result.y).toBeCloseTo(0, 10); + expect(result).toMatchObject({onSeg1: true, onSeg2: true}); + }); + + test('findIntersectionPt() when the lines cross outside one or both segments', () => { + // lines cross at (5,0), past the end of the first segment but on the second + expect(findIntersectionPt(0, 0, 1, 0, 5, -1, 5, 1)).toEqual({x: 5, y: 0, onSeg1: false, onSeg2: true}); + // lines cross at (5,0), past the end of both segments + expect(findIntersectionPt(0, 0, 1, 0, 5, 3, 5, 4)).toMatchObject({x: 5, y: 0, onSeg1: false, onSeg2: false}); + }); + + test('findIntersectionPt() segments that touch at an end point are on both segments', () => { + expect(findIntersectionPt(0, 0, 10, 0, 10, 0, 10, 10)).toEqual({x: 10, y: 0, onSeg1: true, onSeg2: true}); + }); + + test('findIntersectionPt() with parallel or collinear segments returns false', () => { + expect(findIntersectionPt(0, 0, 10, 0, 0, 5, 10, 5)).toBe(false); + expect(findIntersectionPt(0, 0, 10, 0, 5, 0, 15, 0)).toBe(false); + }); +}); + + +describe('Test VisUtil.doSegmentsCross()', () => { + + test('doSegmentsCross() with crossing segments', () => { + expect(doSegmentsCross(0, 0, 10, 10, 0, 10, 10, 0)).toBe(true); + expect(doSegmentsCross(0, 0, 10, 0, 5, -5, 5, 5)).toBe(true); + }); + + test('doSegmentsCross() when the lines cross outside both segments', () => { + expect(doSegmentsCross(0, 0, 1, 0, 5, 3, 5, 4)).toBe(false); + }); + + test('doSegmentsCross() when the lines cross on only one segment', () => { + // the lines meet at (5,0), which is on the second segment but past the end of the first + expect(doSegmentsCross(0, 0, 1, 0, 5, -1, 5, 1)).toBe(false); + expect(doSegmentsCross(5, -1, 5, 1, 0, 0, 1, 0)).toBe(false); + }); + + test('doSegmentsCross() with parallel segments', () => { + expect(doSegmentsCross(0, 0, 10, 0, 0, 5, 10, 5)).toBe(false); + }); +}); + + +describe('Test VisUtil.lineCrossesRect()', () => { + + // the rectangle is x 10 to 20, y 0 to 10 + + test('lineCrossesRect() with an end point inside the rectangle', () => { + expect(lineCrossesRect(12, 5, 14, 6, 10, 0, 10, 10)).toBe(true); + expect(lineCrossesRect(0, 5, 15, 5, 10, 0, 10, 10)).toBe(true); + expect(lineCrossesRect(10, 0, -5, -5, 10, 0, 10, 10)).toBe(true); + }); + + test('lineCrossesRect() with a line through the rectangle', () => { + expect(lineCrossesRect(0, 5, 30, 5, 10, 0, 10, 10)).toBe(true); + expect(lineCrossesRect(15, -5, 15, 15, 10, 0, 10, 10)).toBe(true); + expect(lineCrossesRect(5, -5, 25, 15, 10, 0, 10, 10)).toBe(true); + }); + + test('lineCrossesRect() with a line that does not point toward the rectangle', () => { + expect(lineCrossesRect(0, 20, 2, 30, 10, 0, 10, 10)).toBe(false); + }); + + test('lineCrossesRect() with a line that points toward the rectangle but stops short', () => { + expect(lineCrossesRect(0, 5, 2, 5, 10, 0, 10, 10)).toBe(false); + expect(lineCrossesRect(0, 20, 30, 20, 10, 0, 10, 10)).toBe(false); + expect(lineCrossesRect(15, 30, 15, 12, 10, 0, 10, 10)).toBe(false); + }); +}); + + +describe('Test VisUtil.segmentIntersectRect()', () => { + + const corners= [{x: 10, y: 0}, {x: 20, y: 0}, {x: 20, y: 10}, {x: 10, y: 10}]; + + test('segmentIntersectRect() with an end point inside the rectangle', () => { + expect(segmentIntersectRect({x: 12, y: 5}, {x: 14, y: 6}, corners)).toBe(true); + expect(segmentIntersectRect({x: 0, y: 5}, {x: 15, y: 5}, corners)).toBe(true); + }); + + test('segmentIntersectRect() with a segment through the rectangle', () => { + expect(segmentIntersectRect({x: 0, y: 5}, {x: 30, y: 5}, corners)).toBe(true); + expect(segmentIntersectRect({x: 5, y: -5}, {x: 25, y: 15}, corners)).toBe(true); + }); + + test('segmentIntersectRect() with a segment that touches an edge or corner', () => { + expect(segmentIntersectRect({x: 5, y: 0}, {x: 10, y: 0}, corners)).toBe(true); + expect(segmentIntersectRect({x: 0, y: -10}, {x: 20, y: 10}, corners)).toBe(true); + }); + + test('segmentIntersectRect() with a segment outside the rectangle', () => { + expect(segmentIntersectRect({x: 0, y: 5}, {x: 2, y: 5}, corners)).toBe(false); + expect(segmentIntersectRect({x: 0, y: 0}, {x: 10, y: -5}, corners)).toBe(false); + expect(segmentIntersectRect({x: 0, y: 20}, {x: 30, y: 20}, corners)).toBe(false); + }); + + test('segmentIntersectRect() corner order does not matter', () => { + const reorderedCorners= [corners[2], corners[0], corners[3], corners[1]]; + expect(segmentIntersectRect({x: 12, y: 5}, {x: 14, y: 6}, reorderedCorners)).toBe(true); + expect(segmentIntersectRect({x: 0, y: 5}, {x: 2, y: 5}, reorderedCorners)).toBe(false); + }); +}); + + +describe('Test VisUtil.pointInRec()', () => { + + const corners= [{x: 10, y: 0}, {x: 20, y: 0}, {x: 20, y: 10}, {x: 10, y: 10}]; + + test('pointInRec() with points inside, on the edge and outside', () => { + expect(pointInRec({x: 15, y: 5}, corners)).toBe(true); + expect(pointInRec({x: 10, y: 0}, corners)).toBe(true); + expect(pointInRec({x: 20, y: 7}, corners)).toBe(true); + expect(pointInRec({x: 9.9, y: 5}, corners)).toBe(false); + expect(pointInRec({x: 15, y: 10.1}, corners)).toBe(false); + }); + + test('pointInRec() uses the bounding box of the corners in any order', () => { + expect(pointInRec({x: 15, y: 5}, [corners[2], corners[0]])).toBe(true); + expect(pointInRec({x: 25, y: 5}, [corners[3], corners[1], corners[0]])).toBe(false); + }); +}); diff --git a/src/firefly/js/visualize/__tests__/ZoomUtil-test.js b/src/firefly/js/visualize/__tests__/ZoomUtil-test.js new file mode 100644 index 0000000000..b7bcc58044 --- /dev/null +++ b/src/firefly/js/visualize/__tests__/ZoomUtil-test.js @@ -0,0 +1,240 @@ +import { + convertZoomToString, getArcSecPerPix, getEstimatedFullZoomFactor, getNextZoomLevel, getZoomDesc, getZoomLevelForScale, + getZoomMax, hipsLevelsReversed, imageLevels, imageLevelsReversed, makeFoVString +} from '../ZoomUtil.js'; +import {PlotAttribute} from '../PlotAttribute.js'; +import * as PlotViewUtil from '../PlotViewUtil.js'; +import {HIPS_AITOFF} from '../projection/Projection.js'; +import {FullType, UserZoomTypes} from '../VisConst.js'; + + +describe('Test ZoomUtil zoom level lists', () => { + + test('imageLevels is ascending and imageLevelsReversed is its reverse', () => { + imageLevels.slice(1).forEach((level, idx) => expect(level).toBeGreaterThan(imageLevels[idx])); + expect(imageLevels[0]).toEqual(1/64); + expect(imageLevels[imageLevels.length-1]).toEqual(32); + expect(imageLevels).toContain(1); + expect(imageLevelsReversed).toEqual([...imageLevels].reverse()); + }); + + test('hipsLevelsReversed is descending, from 2.25 by steps of 1.5 then .75', () => { + hipsLevelsReversed.slice(1).forEach((level, idx) => expect(level).toBeLessThan(hipsLevelsReversed[idx])); + expect(hipsLevelsReversed.slice(0, 5)).toEqual([2.25, 1.5, 1, .75, .75*.75]); + }); +}); + + +describe('Test ZoomUtil.getNextZoomLevel()', () => { + + test('getNextZoomLevel() with ONE always returns 1', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 4}, UserZoomTypes.ONE)).toEqual(1); + expect(getNextZoomLevel({plotType: 'hips', zoomFactor: .2}, UserZoomTypes.ONE)).toEqual(1); + }); + + test('getNextZoomLevel() image UP goes to the next level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 1}, UserZoomTypes.UP)).toEqual(1.189); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 1.1}, UserZoomTypes.UP)).toEqual(1.189); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: .001}, UserZoomTypes.UP)).toEqual(1/64); + }); + + test('getNextZoomLevel() image UP stops at the max level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 32}, UserZoomTypes.UP)).toEqual(32); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 40}, UserZoomTypes.UP)).toEqual(32); + }); + + test('getNextZoomLevel() image UP using upDownPercent moves part of the way to the next level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 1}, UserZoomTypes.UP, .5)).toBeCloseTo(1.0945, 10); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: .001}, UserZoomTypes.UP, .5)).toEqual(1/64); + }); + + test('getNextZoomLevel() image DOWN goes to the previous level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 1}, UserZoomTypes.DOWN)).toEqual(.841); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: .9}, UserZoomTypes.DOWN)).toEqual(.841); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 40}, UserZoomTypes.DOWN)).toEqual(32); + }); + + test('getNextZoomLevel() image DOWN stops at the min level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 1/64}, UserZoomTypes.DOWN)).toEqual(1/64); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: .001}, UserZoomTypes.DOWN)).toEqual(1/64); + }); + + test('getNextZoomLevel() image DOWN using upDownPercent at the ends returns the level', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 40}, UserZoomTypes.DOWN, .5)).toEqual(32); + expect(getNextZoomLevel({plotType: 'image', zoomFactor: .02}, UserZoomTypes.DOWN, .5)).toEqual(1/64); + }); + + test('getNextZoomLevel() hips uses the hips levels', () => { + expect(getNextZoomLevel({plotType: 'hips', zoomFactor: 1}, UserZoomTypes.UP)).toEqual(1.5); + expect(getNextZoomLevel({plotType: 'hips', zoomFactor: 2.25}, UserZoomTypes.UP)).toEqual(2.25); + expect(getNextZoomLevel({plotType: 'hips', zoomFactor: 1}, UserZoomTypes.DOWN)).toEqual(.75); + expect(getNextZoomLevel({plotType: 'hips', zoomFactor: .75}, UserZoomTypes.DOWN)).toEqual(.75*.75); + }); + + test('getNextZoomLevel() with an unsupported zoom type returns 1', () => { + expect(getNextZoomLevel({plotType: 'image', zoomFactor: 4}, UserZoomTypes.FIT)).toEqual(1); + }); +}); + + +describe('Test ZoomUtil.getEstimatedFullZoomFactor()', () => { + + // zoom to fit width is 400/1000= .4, zoom to fit height is 800/500= 1.6 + const imagePlot= {plotType: 'image', attributes: {}, dataWidth: 1000, dataHeight: 500}; + const viewDim= {width: 400, height: 800}; + + test('getEstimatedFullZoomFactor() image using each FullType', () => { + expect(getEstimatedFullZoomFactor(imagePlot, viewDim, FullType.WIDTH_HEIGHT)).toBeCloseTo(.4, 10); + expect(getEstimatedFullZoomFactor(imagePlot, viewDim, FullType.ONLY_WIDTH)).toBeCloseTo(.4, 10); + expect(getEstimatedFullZoomFactor(imagePlot, viewDim, FullType.ONLY_HEIGHT)).toBeCloseTo(1.6, 10); + }); + + test('getEstimatedFullZoomFactor() image with a zero view dimension uses the other one', () => { + expect(getEstimatedFullZoomFactor(imagePlot, {width: 400, height: 0}, FullType.WIDTH_HEIGHT)).toBeCloseTo(.4, 10); + expect(getEstimatedFullZoomFactor(imagePlot, {width: 0, height: 800}, FullType.WIDTH_HEIGHT)).toBeCloseTo(1.6, 10); + }); + + test('getEstimatedFullZoomFactor() EXPANDED_TO_FIT_TYPE attribute overrides fullType', () => { + const plot= {...imagePlot, attributes: {[PlotAttribute.EXPANDED_TO_FIT_TYPE]: 'ONLY_HEIGHT'}}; + expect(getEstimatedFullZoomFactor(plot, viewDim, FullType.WIDTH_HEIGHT)).toBeCloseTo(1.6, 10); + }); + + test('getEstimatedFullZoomFactor() unknown EXPANDED_TO_FIT_TYPE attribute is ignored', () => { + const plot= {...imagePlot, attributes: {[PlotAttribute.EXPANDED_TO_FIT_TYPE]: 'nonsense'}}; + expect(getEstimatedFullZoomFactor(plot, viewDim, FullType.ONLY_HEIGHT)).toBeCloseTo(1.6, 10); + }); + + test('getEstimatedFullZoomFactor() hips aitoff', () => { + // width is scaled by 2.7, so zoom to fit width is 2700/(1000*2.7)= 1, height is .7 * 800/500= 1.12 + const aitoffPlot= {plotType: 'hips', attributes: {}, dataWidth: 1000, dataHeight: 500, + projection: {header: {maptype: HIPS_AITOFF}}}; + const aitoffViewDim= {width: 2700, height: 800}; + expect(getEstimatedFullZoomFactor(aitoffPlot, aitoffViewDim, FullType.ONLY_WIDTH)).toBeCloseTo(1, 10); + expect(getEstimatedFullZoomFactor(aitoffPlot, aitoffViewDim, FullType.WIDTH_HEIGHT)).toBeCloseTo(1.12, 10); + expect(getEstimatedFullZoomFactor(aitoffPlot, aitoffViewDim, FullType.ONLY_HEIGHT)).toBeCloseTo(1.12, 10); + }); + + test('getEstimatedFullZoomFactor() hips not aitoff is the same as an image', () => { + const hipsPlot= {plotType: 'hips', attributes: {}, dataWidth: 1000, dataHeight: 500, + projection: {header: {maptype: 0}}}; + expect(getEstimatedFullZoomFactor(hipsPlot, viewDim, FullType.WIDTH_HEIGHT)).toBeCloseTo(.4, 10); + }); +}); + + +describe('Test ZoomUtil.getZoomMax()', () => { + + test('getZoomMax() for image and hips', () => { + expect(getZoomMax({plotType: 'image'})).toEqual(32); + expect(getZoomMax({plotType: 'hips'})).toEqual(2.25); + }); +}); + + +describe('Test ZoomUtil.getArcSecPerPix() and getZoomLevelForScale()', () => { + + // one arcsec per image pixel + const plot= {plotType: 'image', projection: {getPixelScaleDegree: () => 1/3600}}; + + test('getArcSecPerPix() using parameters (plot, zoomFact)', () => { + expect(getArcSecPerPix(plot, 1)).toBeCloseTo(1, 10); + expect(getArcSecPerPix(plot, 2)).toBeCloseTo(.5, 10); + expect(getArcSecPerPix(plot, .25)).toBeCloseTo(4, 10); + }); + + test('getZoomLevelForScale() using parameters (plot, arcsecPerPix)', () => { + expect(getZoomLevelForScale(plot, .25)).toBeCloseTo(4, 10); + expect(getZoomLevelForScale(plot, 2)).toBeCloseTo(.5, 10); + }); + + test('getZoomLevelForScale() is the inverse of getArcSecPerPix()', () => { + expect(getZoomLevelForScale(plot, getArcSecPerPix(plot, 3.364))).toBeCloseTo(3.364, 10); + }); +}); + + +describe('Test ZoomUtil.convertZoomToString()', () => { + + test('convertZoomToString() at 1 or more', () => { + expect(convertZoomToString(1)).toEqual('1x'); + expect(convertZoomToString(2)).toEqual('2x'); + expect(convertZoomToString(1.95)).toEqual('2x'); + expect(convertZoomToString(1.05)).toEqual('1x'); + expect(convertZoomToString(1.5)).toEqual('1.500x'); + expect(convertZoomToString(1.189)).toEqual('1.189x'); + }); + + test('convertZoomToString() with common fractions', () => { + expect(convertZoomToString(.75)).toEqual('¾x'); + expect(convertZoomToString(.5)).toEqual('½x'); + expect(convertZoomToString(.25)).toEqual('¼x'); + expect(convertZoomToString(1/8)).toEqual('1/8x'); + expect(convertZoomToString(1/16)).toEqual('1/16x'); + expect(convertZoomToString(1/32)).toEqual('1/32x'); + expect(convertZoomToString(1/64)).toEqual('1/64x'); + expect(convertZoomToString(1/128)).toEqual('1/128x'); + expect(convertZoomToString(1/256)).toEqual('1/256x'); + }); + + test('convertZoomToString() with other values below 1', () => { + expect(convertZoomToString(.707)).toEqual('0.7x'); + expect(convertZoomToString(.297)).toEqual('0.3x'); + expect(convertZoomToString(.1)).toEqual('0.100x'); + }); +}); + + +describe('Test ZoomUtil.makeFoVString()', () => { + + test('makeFoVString() above 1 degree is in degrees', () => { + expect(makeFoVString(2.5)).toEqual('2.5°'); + expect(makeFoVString(15)).toEqual('15°'); + }); + + test('makeFoVString() above 1 arcmin is in arcmin', () => { + expect(makeFoVString(1)).toEqual(`60'`); + expect(makeFoVString(.5)).toEqual(`30'`); + expect(makeFoVString(.05)).toEqual(`3.0'`); + }); + + test('makeFoVString() at or below 1 arcmin is in arcsec', () => { + expect(makeFoVString(.01)).toEqual('36"'); + expect(makeFoVString(.0005)).toEqual('1.8"'); + }); + + test('makeFoVString() with NaN returns an empty string', () => { + expect(makeFoVString(NaN)).toEqual(''); + }); +}); + + +describe('Test ZoomUtil.getZoomDesc()', () => { + + afterEach(() => jest.restoreAllMocks()); + + // the fields primePlot() needs to recognize a PlotView + const pvFields= {primeIdx: 0, viewDim: {width: 100, height: 100}, overlayPlotViews: [], plotViewCtx: {}}; + + test('getZoomDesc() image returns zoom level and field of view', () => { + jest.spyOn(PlotViewUtil, 'getFoV').mockReturnValue(.5); + const pv= {...pvFields, plotId: 'zoomDescImage', + plots: [{plotId: 'zoomDescImage', plotType: 'image', zoomFactor: 2, viewDim: {width: 100, height: 100}}]}; + expect(getZoomDesc(pv)).toEqual({zoomLevelFormatted: '2x', fovFormatted: `30'`}); + }); + + test('getZoomDesc() hips returns only field of view', () => { + jest.spyOn(PlotViewUtil, 'getFoV').mockReturnValue(180); + const pv= {...pvFields, plotId: 'zoomDescHiPS', + plots: [{plotId: 'zoomDescHiPS', plotType: 'hips', zoomFactor: 1, viewDim: {width: 100, height: 100}}]}; + expect(getZoomDesc(pv)).toEqual({zoomLevelFormatted: '', fovFormatted: '180°'}); + }); + + test('getZoomDesc() with no plot or no viewDim returns empty strings', () => { + const emptyDesc= {fovFormatted: '', zoomLevelFormatted: ''}; + expect(getZoomDesc(undefined)).toEqual(emptyDesc); + expect(getZoomDesc({...pvFields, plotId: 'zoomDescNoPlot', plots: []})).toEqual(emptyDesc); + const noViewDimPv= {...pvFields, plotId: 'zoomDescNoViewDim', + plots: [{plotId: 'zoomDescNoViewDim', plotType: 'image', zoomFactor: 1}]}; + expect(getZoomDesc(noViewDimPv)).toEqual(emptyDesc); + }); +}); 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/region/RegionDrawer.js b/src/firefly/js/visualize/region/RegionDrawer.js index f645e1bdf9..cb09638f84 100644 --- a/src/firefly/js/visualize/region/RegionDrawer.js +++ b/src/firefly/js/visualize/region/RegionDrawer.js @@ -286,7 +286,7 @@ function drawOneCircle(r, wp, options, propChkAry) { var radius; if (unit === ShapeDataObj.UnitType.ARCSEC) { - radius = convertAngle(r.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r.value); + radius = convertAngle(r.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r.value, false); } else { radius = r.value; } @@ -353,8 +353,8 @@ function drawOneBox(w, h, a, wp, options, propChkAry, isOnWorld) { var width, height, angle; if (unit === ShapeDataObj.UnitType.ARCSEC) { - width = convertAngle(w.unit.key, ShapeDataObj.UnitType.ARCSEC.key, w.value); - height = convertAngle(h.unit.key, ShapeDataObj.UnitType.ARCSEC.key, h.value); + width = convertAngle(w.unit.key, ShapeDataObj.UnitType.ARCSEC.key, w.value, false); + height = convertAngle(h.unit.key, ShapeDataObj.UnitType.ARCSEC.key, h.value, false); /* var angleInfo = calculateRectAngleInScreen(wp, width, height, unit); width = angleInfo.width; @@ -370,7 +370,7 @@ function drawOneBox(w, h, a, wp, options, propChkAry, isOnWorld) { // DS9 angle is counterclockwise, canvas angle is clockwise if (angleUnit === ShapeDataObj.UnitType.ARCSEC) { - angle = -convertAngle(a.unit.key, angleUnit.key, a.value); + angle = -convertAngle(a.unit.key, angleUnit.key, a.value, false); } else { // treat as radian angle = -convertAngle('radian', 'arcsec', a.value); angleUnit = ShapeDataObj.UnitType.ARCSEC; @@ -510,8 +510,8 @@ function drawOneEllipse(r1, r2, a, wp, options, propChkAry, isOnWorld) { var radius1, radius2, angle; if (unit === ShapeDataObj.UnitType.ARCSEC) { - radius1 = convertAngle(r1.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r1.value); - radius2 = convertAngle(r2.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r2.value); + radius1 = convertAngle(r1.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r1.value, false); + radius2 = convertAngle(r2.unit.key, ShapeDataObj.UnitType.ARCSEC.key, r2.value, false); /* var angleInfo = calculateRectAngleInScreen(wp, radius1, radius2, unit); radius1 = angleInfo.width; // in screen point unit @@ -526,7 +526,7 @@ function drawOneEllipse(r1, r2, a, wp, options, propChkAry, isOnWorld) { // DS9 angle is counterclockwise, canvas angle is clockwise if (angleUnit === ShapeDataObj.UnitType.ARCSEC) { - angle = -convertAngle(a.unit.key, angleUnit.key, a.value); + angle = -convertAngle(a.unit.key, angleUnit.key, a.value, false); } else { // treat as radian angle = -convertAngle('radian', 'arcsec', a.value); angleUnit = ShapeDataObj.UnitType.ARCSEC; 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); + }); +});