diff --git a/.github/workflows/build_publish.yml b/.github/workflows/build_publish.yml index abdb1d4101..09035d9587 100644 --- a/.github/workflows/build_publish.yml +++ b/.github/workflows/build_publish.yml @@ -4,11 +4,11 @@ on: workflow_dispatch: inputs: git_tag: - description: "Git tag to use(e.g. 'release-xxxx.x.x' or a full git tag)" + description: "Git tag or branch to use (e.g. 'release-xxxx.x.x' or a full git tag)" required: true type: string img_tag: - description: "Optional: Docker image tag(e.g. 'xxxx.x.x'); If omitted, `git_tag` will be used." + description: "Optional: Docker image tag (e.g. 'xxxx.x.x'); If omitted, `git_tag` will be used." required: false type: string push_image: @@ -20,6 +20,11 @@ on: description: "Optional: Firefly release tag for standalone.zip artifact (e.g. release-xxxx.x.x)" required: false type: string + standalone_only: + description: "standalone.zip only (no docker push, no GitHub release)" + required: false + default: false + type: boolean release: types: [published] @@ -90,7 +95,7 @@ jobs: # Login to GHCR (only if pushing) # ------------------------------------------------------------ - name: Login to GHCR - if: github.event_name == 'release' || inputs.push_image + if: (github.event_name == 'release' || inputs.push_image) && !inputs.standalone_only uses: docker/login-action@v3 with: registry: ghcr.io @@ -106,7 +111,7 @@ jobs: context: . file: firefly/docker/Dockerfile platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name == 'release' || inputs.push_image }} + push: ${{ (github.event_name == 'release' || inputs.push_image) && !inputs.standalone_only}} # docker buildx does not allow uppercase letters in tags, so we convert to lowercase here tags: ghcr.io/caltech-ipac/firefly:${{ steps.resolve_tags.outputs.tag }} build-args: | @@ -120,7 +125,7 @@ jobs: # `standalone-builder` layer (gradle standalone:zip) runs. # ------------------------------------------------------------ - name: Build and export standalone.zip - if: github.event_name == 'release' || inputs.release_tag != '' + if: github.event_name == 'release' || inputs.release_tag != '' || inputs.standalone_only uses: docker/build-push-action@v6 with: context: . @@ -140,13 +145,31 @@ jobs: # using `gh release upload` requires setting `contents: write` # ------------------------------------------------------------ - name: Add standalone.zip to release artifacts - if: github.event_name == 'release' || inputs.release_tag != '' + if: (github.event_name == 'release' || inputs.release_tag != '') && !inputs.standalone_only env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}" gh release upload "$TAG" --repo "${{ github.repository }}" ./dist-zip/standalone.zip --clobber + # ------------------------------------------------------------ + # standalone_only: publish standalone.zip as a workflow + # artifact instead of a release asset. Ephemeral, private to the run. + # ------------------------------------------------------------ + - name: Upload standalone.zip as workflow artifact + id: upload_standalone_artifact + if: inputs.standalone_only + uses: actions/upload-artifact@v4 + with: + name: standalone-zip + path: ./dist-zip/standalone.zip + + - name: Print artifact URL + if: inputs.standalone_only + run: | + echo "Artifact URL: ${{ steps.upload_standalone_artifact.outputs.artifact-url }}" >> $GITHUB_STEP_SUMMARY + echo "nightly.link (no login required): https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/standalone-zip.zip" >> $GITHUB_STEP_SUMMARY + # ------------------------------------------------------------ # Package and push Helm chart to GHCR # ------------------------------------------------------------ @@ -154,7 +177,7 @@ jobs: uses: azure/setup-helm@v4 - name: Publish Helm chart - if: github.event_name == 'release' || inputs.push_image + if: (github.event_name == 'release' || inputs.push_image) && !inputs.standalone_only run: | if [[ ! -f firefly/helm/Chart.yaml ]]; then echo "No Helm chart found, skipping." diff --git a/bin/get-firefly b/bin/get-firefly index 78d60b402f..2bb3239803 100644 --- a/bin/get-firefly +++ b/bin/get-firefly @@ -1,9 +1,81 @@ #!/bin/bash -INSTALL_SCRIPT="https://raw.githubusercontent.com/Caltech-IPAC/firefly/refs/heads/dev/bin/install.sh" -# use the following line for testing the code from a PR, modify for your PR -# INSTALL_SCRIPT="https://raw.githubusercontent.com/Caltech-IPAC/firefly/refs/heads/FIREFLY-1980-standalone/bin/install.sh" - -curl -s ${INSTALL_SCRIPT} > ./install.sh -chmod +x ./install.sh -./install.sh -dontConfirm "$@" -/bin/rm -f ./install.sh \ No newline at end of file + +# -------------------------- +# This script start the Firefly install on an end-user machine +# -------------------------- + +# -------------------------- +# How to use +# +# Production: +# curl -L https://raw.githubusercontent.com/Caltech-IPAC/firefly/refs/heads/dev/bin/get-firefly | bash +# +# Testing in a branch: +# curl -L https://raw.githubusercontent.com/Caltech-IPAC/firefly/refs/heads/FIREFLY-2099-stand-hard/bin/get-firefly | FIREFLY_BRANCH=Firefly-xxx bash +# -------------------------- + +ref="${FIREFLY_BRANCH:-dev}" +INSTALL_SCRIPT="https://raw.githubusercontent.com/Caltech-IPAC/firefly/refs/heads/${ref}/bin/install.sh" + +downloadedInstallScript="" +jobArtifactZip="" +jobExtractDir="" +trap '[ -n "$downloadedInstallScript" ] && rm -f "$downloadedInstallScript"; [ -n "$jobArtifactZip" ] && rm -f "$jobArtifactZip"; [ -n "$jobExtractDir" ] && rm -rf "$jobExtractDir"' EXIT + +downloadedInstallScript=$(mktemp -t firefly-install.XXXXXX) || { echo "Failed to create a temporary file"; exit 1; } + +curl -fsSL "${INSTALL_SCRIPT}" -o "$downloadedInstallScript" +curlStatus=$? +if [ $curlStatus -ne 0 ] || [ ! -s "$downloadedInstallScript" ]; then + echo "Failed to download the Firefly installer from $INSTALL_SCRIPT" + exit 1 +fi +chmod +x "$downloadedInstallScript" + +# -------------------------- +# The following two environment variables are only for testing. + +# FIREFLY_RUN_ID: when set, install the build of standalone.zip from that +# specific GitHub Actions run (via nightly.link) instead of the latest formal GitHub +# release. If testing is set up correctly then FIREFLY_RUN_ID should not be necessary +# +# FIREFLY_BRANCH: if FIREFLY_RUN_ID isn't set but this is, auto-resolve the latest +# successful workflow_dispatch run on that branch. +# Note this can't tell whether that run was actually a standalone_only +# build - if not, nightly.link will just report no artifact found for it. +# +# -------------------------- + +installUrlArgs=() +if [ -z "$FIREFLY_RUN_ID" ] && [ -n "$FIREFLY_BRANCH" ]; then + if ! command -v jq > /dev/null 2>&1; then + echo "jq is required to resolve FIREFLY_BRANCH to a run id; install jq or set FIREFLY_RUN_ID directly" + exit 1 + fi + runApiUrl="https://api.github.com/repos/Caltech-IPAC/firefly/actions/workflows/build_publish.yml/runs?branch=${FIREFLY_BRANCH}&status=success&event=workflow_dispatch" + FIREFLY_RUN_ID=$(curl -fsSL "$runApiUrl" | jq -r '.workflow_runs[0].id // empty') + if [ -z "$FIREFLY_RUN_ID" ]; then + echo "No successful workflow_dispatch run found for branch $FIREFLY_BRANCH" + exit 1 + fi +fi + +if [ -n "$FIREFLY_RUN_ID" ]; then + runLinkUrl="https://nightly.link/Caltech-IPAC/firefly/actions/runs/${FIREFLY_RUN_ID}/standalone-zip.zip" + jobArtifactZip=$(mktemp -t firefly-build-artifact.XXXXXX) || { echo "Failed to create a temporary file"; exit 1; } + curl -fSL "$runLinkUrl" -o "$jobArtifactZip" + curlStatus=$? + if [ $curlStatus -ne 0 ] || [ ! -s "$jobArtifactZip" ]; then + echo "Failed to download the build from $runLinkUrl" + exit 1 + fi + jobExtractDir=$(mktemp -d -t firefly-build-extract.XXXXXX) || { echo "Failed to create a temporary directory"; exit 1; } + if ! unzip -oq "$jobArtifactZip" -d "$jobExtractDir" || [ ! -f "$jobExtractDir/standalone.zip" ]; then + echo "Failed to expand the build artifact from $runLinkUrl" + exit 1 + fi + installUrlArgs=(-url "$jobExtractDir/standalone.zip") +fi + +# now execute the real install script +"$downloadedInstallScript" -dontConfirm "${installUrlArgs[@]}" "$@" diff --git a/bin/install.sh b/bin/install.sh index 1fe1440e9e..4ac22fa517 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -36,6 +36,48 @@ isTrue() { if [[ "$v" == "true" || "$v" == "t" ]]; then return 0; else return 1; fi } +# -------------------------- +# checkRequiredCommands: verify the basic tools needed to install are present +# -------------------------- + +checkRequiredCommands() { + missing="" + for cmd in curl unzip realpath; do + if ! command -v "$cmd" > /dev/null 2>&1; then + missing="$missing $cmd" + fi + done + if [ -n "$missing" ]; then + echo "Cannot install: the following required command(s) are missing:$missing" + echo "Please install them and re-run this script." + exit 1 + fi +} + +# -------------------------- +# checkOsCompatibility: warn if the OS does not meet the documented requirements +# (see docs/using-firefly-standalone.md) +# -------------------------- + +checkOsCompatibility() { + name=$(uname) + if [[ "$name" == "Darwin" ]]; then + osVersion=$(sw_vers -productVersion 2> /dev/null) + majorVersion=${osVersion%%.*} + if [[ "$majorVersion" =~ ^[0-9]+$ ]] && [ "$majorVersion" -lt 15 ]; then + echo "Warning: Firefly requires macOS 15 or greater, detected macOS ${osVersion:-unknown}" + fi + elif [[ "$name" == "Linux" ]]; then + command -v ldconfig > /dev/null 2>&1 && hasLdconfig="TRUE" + if isTrue $hasLdconfig && ! ldconfig -p 2> /dev/null | grep -q libssl.so.3; then + echo "Warning: libssl.so.3 was not found. Firefly requires libssl.so.3 (Debian 12+, RHEL 9+, Ubuntu 22.04+, Fedora)." + fi + fi +} + +checkRequiredCommands +checkOsCompatibility + # -------------------------- # get the parameters # -------------------------- @@ -91,7 +133,11 @@ fi # -------------------------- if isTrue $confirm && isTrue $initialInstall && [ "$enteredPath" == "" ]; then - read -p "Enter installation directory [${enteredPath:-$defaultInstallRelativePath}]: " enteredPath + read -p "Enter installation directory [${defaultInstallRelativePath}]: " enteredPath + if [ -n "$enteredPath" ]; then + mkdir -p "$enteredPath" + INSTALL_DIR=$(realpath "$enteredPath") + fi fi @@ -130,19 +176,24 @@ rm -f "$applicationDir"/complete JQ=$(which jq) if [[ "$JQ" == '' ]]; then name=$(uname) - if [[ "$name" == "Darwin" ]]; then - echo jq is is missing from mac os, install failed - exit 1 - fi arch=$(uname -m) - if [[ "$arch" == "x86_64" ]]; then + if [[ "$name" == "Darwin" ]]; then + if [[ "$arch" == "arm64" ]]; then + jqUrl="https://github.com/jqlang/jq/releases/latest/download/jq-macos-arm64" + else + jqUrl="https://github.com/jqlang/jq/releases/latest/download/jq-macos-amd64" + fi + elif [[ "$arch" == "x86_64" ]]; then jqUrl="https://github.com/jqlang/jq/releases/latest/download/jq-linux-amd64" else jqUrl="https://github.com/jqlang/jq/releases/latest/download/jq-linux-arm64" fi echo "installing local jq..." - curl -sL "$jqUrl" -o "$binDir/jq" + if ! curl -fsSL "$jqUrl" -o "$binDir/jq" || [[ ! -s "$binDir/jq" ]]; then + echo "Failed to download jq from $jqUrl, install failed" + exit 1 + fi chmod +x "$binDir/jq" JQ="$binDir/jq" fi @@ -155,22 +206,21 @@ fi targetPackageFile="${applicationDir}/standalone.zip" -packageUrl=$(curl -s "https://api.github.com/repos/Caltech-IPAC/firefly/releases/latest" | \ -$JQ -r '.assets[] | [.name, .browser_download_url] | @tsv' | \ -while IFS=$'\t' read -r asset_name download_url; do - if [ "$asset_name" == $PACKAGE_ASSET_NAME ]; then - echo "$download_url" - fi -done) if [ -z "$altUrl" ]; then - url=$packageUrl + releaseJson=$(curl -s "https://api.github.com/repos/Caltech-IPAC/firefly/releases/latest") + apiError=$(echo "$releaseJson" | $JQ -r '.message // empty' 2> /dev/null) + if [ -n "$apiError" ]; then + echo "Error contacting the GitHub API: $apiError" + exit 1 + fi + url=$(echo "$releaseJson" | $JQ -r --arg name "$PACKAGE_ASSET_NAME" '.assets[]? | select(.name == $name) | .browser_download_url') else url=$altUrl fi if [ -z "$url" ]; then echo "No package defined to download, could not find it as a github asset https://github.com/Caltech-IPAC/firefly/releases" - exit 0 + exit 1 fi @@ -180,25 +230,65 @@ fi echo "install from: $url" if [[ "$url" == http* ]]; then - curl -sL "$url" > "${targetPackageFile}" + httpStatus=$(curl -sL -w "%{http_code}" "$url" -o "${targetPackageFile}") + if [[ "$httpStatus" != "200" ]]; then + echo "Failed to download $url (HTTP status $httpStatus)" + exit 1 + fi else + if [ ! -f "$url" ]; then + echo "Package file not found: $url" + exit 1 + fi cp "$url" "${targetPackageFile}" fi +if [[ ! -s "${targetPackageFile}" ]]; then + echo "Downloaded package is empty: ${targetPackageFile}" + exit 1 +fi + echo "expanding firefly $targetPackageFile..." (cd "$applicationDir" && unzip -o "${targetPackageFile}" &> "${applicationDir}/standalone-expand.log") +if [ $? -ne 0 ]; then + echo "Failed to expand $targetPackageFile, see ${applicationDir}/standalone-expand.log" + exit 1 +fi +if [ ! -f "$applicationDir/firefly.war" ]; then + echo "firefly.war not found after expanding $targetPackageFile, see ${applicationDir}/standalone-expand.log" + exit 1 +fi mkdir -p "$applicationDir/firefly-war" echo "expanding firefly.war..." (cd "$applicationDir/firefly-war" && unzip -o "${applicationDir}/firefly.war" &> "${applicationDir}/war-expand.log") +if [ $? -ne 0 ]; then + echo "Failed to expand firefly.war, see ${applicationDir}/war-expand.log" + exit 1 +fi # -------------------------- # make the script executable, put some in correct place # -------------------------- +requiredFiles=("standalone_cleanup.sh" "$startScript" "startFireflyServer.sh" \ + "stopFireflyServer.sh" "statusFireflyServer.sh" "javaInstaller.sh" "updater.sh" "common.sh") +missingFiles="" +for f in "${requiredFiles[@]}"; do + if [ ! -f "$applicationDir/$f" ]; then + missingFiles="$missingFiles $f" + fi +done +if [ -n "$missingFiles" ]; then + echo "Expected file(s) missing after expanding the package:$missingFiles" + exit 1 +fi + scriptPath=$(realpath "$0") cp "$scriptPath" "$applicationDir/install.sh" chmod 775 "$applicationDir/standalone_cleanup.sh" \ "$applicationDir/$startScript" \ "$applicationDir/startFireflyServer.sh" \ + "$applicationDir/stopFireflyServer.sh" \ + "$applicationDir/statusFireflyServer.sh" \ "$applicationDir/javaInstaller.sh" \ "$applicationDir/updater.sh" \ "$applicationDir/install.sh" @@ -207,6 +297,15 @@ chmod 775 "$applicationDir/standalone_cleanup.sh" \ cp "$applicationDir/$startScript" "$binDir" chmod +x "$binDir/$startScript" +# -------------------------- +# link ff into ~/.local/bin, creating it if needed, so ff is available +# without editing PATH on systems where ~/.local/bin is already on it +# -------------------------- + +localBinDir="${HOME}/.local/bin" +mkdir -p "$localBinDir" +ln -sf "$binDir/$startScript" "$localBinDir/$startScript" + # -------------------------- # setup default port # -------------------------- @@ -226,6 +325,10 @@ fi if isTrue $installJre; then echo "installing java..." JAVA=$("$applicationDir"/javaInstaller.sh) + if [ $? -ne 0 ] || [ -z "$JAVA" ]; then + echo "Failed to install Java, see error(s) above" + exit 1 + fi fi # -------------------------- @@ -238,7 +341,7 @@ if isTrue $initialInstall; then echo echo ">>>>>>>>>>>>>>>>>>>>>> ${binDir#$PWD/}/ff start" echo - echo "You might want to add the bin dir to your PATH: $binDir" + echo "You might want to add the bin dir to your PATH: $binDir or ~/.local/bin" fi diff --git a/docs/using-firefly-standalone.md b/docs/using-firefly-standalone.md index 4fa8f211de..dc9843fb55 100644 --- a/docs/using-firefly-standalone.md +++ b/docs/using-firefly-standalone.md @@ -5,7 +5,12 @@ Firefly can be installed directly on your macOS or Linux desktop machine. This is a full-featured installation that performs very well when working with local files. - +### Requirements + +- `curl` and `unzip` must be available on your system; the installer checks for these up front and stops with a clear message if either is missing. +- Java is not required beforehand — the installer downloads a compatible Java runtime automatically unless you configure your own (see [Advanced Configuration](#advanced-configuration)). +- See [Confirming firefly will run on your OS](#confirming-firefly-will-run-on-your-os) for OS-specific requirements. + ## Installing Firefly ### Quick install @@ -170,3 +175,13 @@ Check with the following command #### Windows Standalone Firefly is not supported on Windows +--- + +## Troubleshooting + +- **The installer stops with a missing command error**: install the missing tool (`curl` and/or `unzip`) with your system's package manager and re-run the install. +- **"Error contacting the GitHub API" or "No package defined to download"**: this usually means a network issue or that GitHub's API rate limit was hit. Wait a few minutes and try again, or pass a direct URL/path to a `standalone.zip` with `./install.sh -url `. +- **The installer reports a failed download or a failure expanding a package**: re-run the install; if it persists, check your network connection and firewall, or download the release manually from the [Firefly releases page](https://github.com/Caltech-IPAC/firefly/releases) and reinstall with `./install.sh -url `. +- **`ff start` reports the port is in use**: another application is using the configured port. Change the port in `~/.firefly/config.json` or start with `firefly/bin/ff start --port `. +- **Java fails to install automatically**: install Java 21+ yourself and set its path in the `java` field of `~/.firefly/config.json`, replacing `"auto"`. + diff --git a/src/firefly/js/externalSource/aladinProj/HealpixIndex.js b/src/firefly/js/externalSource/aladinProj/HealpixIndex.js index 4fbab955f1..da4858167b 100644 --- a/src/firefly/js/externalSource/aladinProj/HealpixIndex.js +++ b/src/firefly/js/externalSource/aladinProj/HealpixIndex.js @@ -25,6 +25,24 @@ // - https://healpix.jpl.nasa.gov/html/java/healpix/core/HealpixIndex.html // - https://healpix.jpl.nasa.gov/html/java/healpix/tools/SpatialVector.html +/** + * HEALPix terms used throughout this file: + * - nside: the resolution parameter. The sphere is divided into 12 base faces, each subdivided + * into nside x nside pixels, so npix = 12 * nside^2. nside must be a power of 2. + * - order (norder): log2(nside); the two are used interchangeably by different parts of this API. + * - ipix: the integer index identifying a single pixel, in one of two schemes: + * - NEST (nested): pixel numbers are organized as a hierarchical quad-tree, so a pixel at one + * order splits into exactly 4 child pixels at the next order via simple bit operations. + * This is the scheme most callers in this codebase use, since it supports jumping between + * resolutions (norders) cheaply. + * - RING: pixel numbers increase ring by ring from the North Pole to the South Pole. Some + * algorithms here (e.g. queryDisc/inRing) work in RING because pixels at a given latitude + * form a single contiguous range of indexes, then convert the result to NEST if requested. + * - theta, phi: spherical coordinates in radians used internally by the HEALPix math - + * theta is colatitude (0 at the North Pole, PI at the South Pole), phi is longitude (0 to 2*PI). + * - ra, dec: the more familiar equatorial coordinates in degrees. radecToPolar/polarToRadec and + * SpatialVector convert between ra/dec and theta/phi. + */ const Constants = { PI : Math.PI, C_PR : Math.PI / 180, @@ -40,13 +58,38 @@ const Constants = { const toInt= (n) => Math.trunc(n); const powerOf2= Array.from({length:53}, (v,idx) => 2**idx); + +// bitwise shifts that work on values beyond the 32-bit range that JS's native << and >> support, +// up to the 53 bits a JS number can hold without losing precision. const shiftRight= (v,bits) => toInt(v / powerOf2[bits]); const shiftLeft= (v,bits) => toInt(v * powerOf2[bits]); + +/** + * @param {number} nside + * @return {number} the HEALPix order (log2(nside)) if nside is a valid power of 2, otherwise -1 + */ const nside2order= (nside) => (nside & nside - 1) > 0 ? -1 : toInt(Math.log2(nside)); +/** + * Convert equatorial coordinates to the (theta, phi) spherical coordinates used by the HEALPix + * algorithms in this file. + * @param {number} ra right ascension in degrees + * @param {number} dec declination in degrees + * @return {{theta:number, phi:number}} theta (colatitude) and phi (longitude), both in radians + */ export const radecToPolar= (ra, dec) => ({ theta: Math.PI / 2 - dec / 180 * Math.PI, phi: ra / 180 * Math.PI }); + +/** + * Convert HEALPix (theta, phi) spherical coordinates back to equatorial coordinates. + * @param {number} t theta (colatitude) in radians + * @param {number} s phi (longitude) in radians + * @return {{ra:number, dec:number}} right ascension and declination, both in degrees + */ export const polarToRadec= (t, s) => ({ ra: 180 * s / Math.PI, dec: 180 * (Math.PI / 2 - t) / Math.PI }); +// bigAnd/bigOr/orAll: bitwise AND/OR that work correctly on values up to 53 bits, by splitting +// each operand into a high 32nd bit and the remaining low 31 bits, since JS's native &/| operators +// only work correctly on 32-bit integers. function bigAnd(v1, v2) { const hi = 0x80000000; const low = 0x7fffffff; @@ -73,6 +116,16 @@ function bigOr(v1, v2) { const orAll= (...args) => args.reduce( (prev,curr) => bigOr(prev,curr) ,0); +/** + * Find which pixel, at a given nside resolution, contains a point on the sphere, using the NEST + * pixel numbering scheme. This is the primary entry point for looking up "which HEALPix pixel is + * this position in", and is used throughout firefly to map ra/dec (via radecToPolar) to a HiPS + * tile/pixel number at a given order. + * @param {number} theta colatitude in radians, in [0, PI] (0 = North Pole) + * @param {number} phi longitude in radians, in [0, 2*PI) + * @param {number} nside the resolution (must be a power of 2) + * @return {number} the NEST pixel index, in [0, 12*nside^2) + */ export function ang2pixNest(theta, phi, nside) { const order = nside2order(nside); let tp, o, c, jp, jm, ntt, face_num, ix, iy; @@ -134,6 +187,16 @@ export function ang2pixNest(theta, phi, nside) { return xyf2nest(ix, iy, face_num, order); } +/** + * Combine a pixel's (x, y) position within a HEALPix base face with the face number into a single + * NEST pixel index, by interleaving the bits of x and y (via the UTAB lookup table) and prefixing + * the face number. + * @param {number} ix x coordinate within the face, in [0, nside) + * @param {number} iy y coordinate within the face, in [0, nside) + * @param {number} face_num the base face number, in [0, 12) + * @param {number} order the HEALPix order (log2(nside)) + * @return {number} the NEST pixel index + */ function xyf2nest(ix, iy, face_num, order) { const nest= shiftLeft(face_num, 2 * order) + orAll( @@ -148,6 +211,14 @@ function xyf2nest(ix, iy, face_num, order) { return nest; } +/** + * The inverse of xyf2nest: split a NEST pixel index back into its base face number and (x, y) + * position within that face (by de-interleaving bits via the CTAB lookup table). + * @param {number} ipix the NEST pixel index + * @param {number} order the HEALPix order (log2(nside)) + * @param {number} npface number of pixels per face (nside^2) + * @return {{face_num:number, ix:number, iy:number}} + */ function nest2xyf(ipix, order, npface) { // if (ipix>0x7FFFFFF) { // console.log('nest2xyf: ipix greater'); @@ -182,7 +253,18 @@ function nest2xyf(ipix, order, npface) { } +/** + * A 3D unit-sphere position, usable either as an (x, y, z) Cartesian vector or as ra/dec + * equatorial coordinates - the two representations are kept in sync lazily via updateXYZ()/ + * updateRaDec(). This is the coordinate type accepted by HealpixIndex.queryDisc() and returned + * by HealpixIndex.corners_nest()/corners_ring() and HealpixIndex.vector(). + */ export class SpatialVector { + /** + * @param {number} [x] Cartesian x + * @param {number} [y] Cartesian y + * @param {number} [z] Cartesian z + */ constructor(x, y, z) { this.x = x; this.y = y; @@ -192,9 +274,12 @@ export class SpatialVector { this.okRaDec_ = false; } + /** @return {number} the length (magnitude) of the vector */ length() { return Math.sqrt(this.lengthSquared()); } + /** @return {number} the squared length of the vector, cheaper than length() when only comparing magnitudes */ lengthSquared() { return this.x * this.x + this.y * this.y + this.z * this.z; } + /** Rescale this vector in place to unit length (magnitude 1), leaving its direction unchanged. */ normalized() { const vectorLength = this.length(); this.x /= vectorLength; @@ -202,6 +287,11 @@ export class SpatialVector { this.z /= vectorLength; } + /** + * Set this vector's position from equatorial coordinates, recomputing its (x, y, z). + * @param {number} lon right ascension in degrees + * @param {number} lat declination in degrees + */ set(lon, lat) { this.ra_ = lon; this.dec_ = lat; @@ -209,6 +299,10 @@ export class SpatialVector { this.updateXYZ(); } + /** + * @param {SpatialVector} v1 + * @return {number} the angle, in radians, between this vector and v1 + */ angle(v1) { const xx = this.y * v1.z - this.z * v1.y; const yy = this.z * v1.x - this.x * v1.z; @@ -217,20 +311,49 @@ export class SpatialVector { return Math.abs(Math.atan2(cross, this.dot(v1))); } + /** @return {number[]} this vector's Cartesian coordinates as [x, y, z] */ get() { return [this.x, this.y, this.z]; } toString() { return 'SpatialVector[' + this.x + ', ' + this.y + ', ' + this.z + ']'; } + /** + * @param {SpatialVector} v + * @return {SpatialVector} the cross product of this vector and v + */ cross(v) { return new SpatialVector(this.y * v.z - v.y * this.z, this.z * v.x - v.z * this.x, this.x * v.y - v.x() * this.y); } + /** + * @param {SpatialVector} other + * @return {boolean} true if this vector's Cartesian coordinates exactly equal other's + */ equal(other) { return Boolean(this.x===other.x && this.y===other.y && this.z===other.z); } + /** + * @param {number} s scale factor + * @return {SpatialVector} a new vector scaled by s + */ mult(s) { return new SpatialVector(s * this.x, s * this.y, s * this.z); } + /** + * @param {SpatialVector} v1 + * @return {number} the dot product of this vector and v1 + */ dot(v1) { return this.x * v1.x + this.y * v1.y + this.z * v1.z; } + /** + * @param {SpatialVector} s + * @return {SpatialVector} a new vector, the sum of this vector and s + */ add(s) { return new SpatialVector(this.x + s.x, this.y + s.y, this.z + s.z); } + /** + * @param {SpatialVector} s + * @return {SpatialVector} a new vector, this vector minus s + */ sub(s) { return new SpatialVector(this.x - s.x, this.y - s.y, this.z - s.z); } + /** + * @return {number} declination, in degrees, of this position (normalizing the vector first if + * it was set via x/y/z rather than via set()) + */ dec() { if (this.okRaDec_) return this.dec_; this.normalized(); @@ -238,6 +361,10 @@ export class SpatialVector { return this.dec_; } + /** + * @return {number} right ascension, in degrees, of this position (normalizing the vector first + * if it was set via x/y/z rather than via set()) + */ ra() { if (this.okRaDec_) return this.ra_; this.normalized(); @@ -245,6 +372,7 @@ export class SpatialVector { return this.ra_; } + /** Recompute (x, y, z) from the currently stored ra_/dec_ (degrees). */ updateXYZ() { const t = Math.cos(this.dec_ * Constants.C_PR); this.x = Math.cos(this.ra_ * Constants.C_PR) * t; @@ -252,6 +380,7 @@ export class SpatialVector { this.z = Math.sin(this.dec_ * Constants.C_PR); } + /** Recompute ra_/dec_ (degrees) from the currently stored (x, y, z); assumes a unit vector. */ updateRaDec() { this.dec_ = Math.asin(this.z) / Constants.C_PR; const t = Math.cos(this.dec_ * Constants.C_PR); @@ -274,6 +403,7 @@ function addRangeToSet(s,first,last) { for (let i = first; last >= i; i++) s.add(i); } +/** highest HEALPix order (norder) supported by this implementation; nside can go up to 2^ORDER_MAX */ export const ORDER_MAX = 25; const NSIDE_LIST = new Array(ORDER_MAX).fill(0).map((val,idx) => 2**idx); const JPLL = [1, 3, 5, 7, 0, 2, 4, 6, 1, 3, 5, 7]; @@ -281,6 +411,9 @@ const JRLL = [2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]; const NS_MAX = NSIDE_LIST[NSIDE_LIST.length-1]; const Z0 = Constants.TWOTHIRD; +// CTAB/UTAB: lookup tables used to interleave/de-interleave the bits of an (x, y) pair with a +// single table lookup per byte, rather than bit-by-bit, when converting between NEST pixel +// indexes and (x, y, face) coordinates (see xyf2nest/nest2xyf). const TAB_SIZE = 256; const CTAB= new Array(TAB_SIZE).fill(0).map( (v,i) => 1 & i | (2 & i) << 7 | (4 & i) >>> 1 | (8 & i) << 6 | (16 & i) >>> 2 | (32 & i) << 5 | (64 & i) >>> 3 | (128 & i) << 4); @@ -288,25 +421,37 @@ const UTAB= new Array(TAB_SIZE).fill(0).map( (v,i) => 1 & i | (2 & i) << 1 | (4 & i) << 2 | (8 & i) << 3 | (16 & i) << 4 | (32 & i) << 5 | (64 & i) << 6 | (128 & i) << 7); +/** + * A HEALPix pixelization at a fixed resolution (nside). An instance precomputes the values + * derived from nside (number of pixels, cap sizes, etc.) that its methods need repeatedly, so a + * new HealpixIndex should be created once per nside and reused rather than per lookup. + * + * Provides pixel <-> coordinate conversions in both the NEST and RING numbering schemes (see the + * file-level comment above for what those mean), lookup of the pixels covering a disc on the sky + * (queryDisc), and lookup of a pixel's corner coordinates (corners_nest/corners_ring). + */ export class HealpixIndex { + /** @param {number} nside the resolution to build this index for; must be a power of 2 */ constructor(nside) { this.nside = nside; this.nl2 = 2 * nside; this.nl3 = 3 * nside; this.nl4 = 4 * nside; - this.npface = nside * nside; - this.ncap = 2 * nside * (nside - 1); - this.npix = 12 * this.npface; + this.npface = nside * nside; // pixels per base face + this.ncap = 2 * nside * (nside - 1); // number of pixels in the north polar cap + this.npix = 12 * this.npface; // total number of pixels at this nside this.fact2 = 4 / this.npix; this.fact1 = (nside << 1) * this.fact2; this.order = nside2order(nside); } /** - * - * @param {number} pixsize - * @return {number} + * Find the nside whose pixels most closely match a desired angular pixel size. Used to pick + * a HEALPix/HiPS resolution appropriate for a given on-screen or on-sky size, e.g. to choose + * which HiPS tile order to load for the current zoom level. + * @param {number} pixsize desired pixel size, in arcseconds + * @return {number} the nside (power of 2, <= 2^ORDER_MAX) giving pixels closest to that size */ static calculateNSide(pixsize) { let i = 0; @@ -333,6 +478,10 @@ export class HealpixIndex { return i; } + /** + * @param {number} ipix a NEST pixel index at this index's nside + * @return {{theta:number, phi:number}} spherical coordinates (radians) of the pixel's center + */ pix2ang_nest(ipix) { if (0 > ipix || ipix > this.npix - 1) { throw { @@ -369,6 +518,10 @@ export class HealpixIndex { const phi = (jp - .5 * (kshift + 1)) * (Constants.PIOVER2 / nr); return { theta, phi }; } + /** + * @param {number} nside must be a power of 2, > 0 and <= NS_MAX (2^(ORDER_MAX-1)) + * @return {number} the total number of pixels (npix) covering the whole sphere at that nside + */ static nside2Npix(nside) { if (0 > nside || (nside & -nside) !==nside || nside > NS_MAX) { throw { @@ -379,6 +532,14 @@ export class HealpixIndex { const i = 12 * nside * nside; return i; } + /** + * The RING-scheme equivalent of xyf2nest: combine a pixel's (x, y) position within a base + * face with the face number into a RING pixel index. + * @param {number} ix x coordinate within the face + * @param {number} iy y coordinate within the face + * @param {number} face_num the base face number, in [0, 12) + * @return {number} the RING pixel index + */ xyf2ring(ix, iy, face_num) { let nr, kshift, startpix; const r = JRLL[face_num] * this.nside - ix - iy - 1; @@ -408,14 +569,28 @@ export class HealpixIndex { } return startpix + jp - 1; } + /** + * @param {number} ipnest a NEST pixel index + * @return {number} the same pixel's RING pixel index + */ nest2ring(ipnest) { const {ix,iy,face_num} = nest2xyf(ipnest,this.order,this.npface); return this.xyf2ring(ix, iy, face_num); } + /** + * @param {number} ipix a NEST pixel index + * @param {number} step number of extra points to compute along each pixel edge, in addition + * to the 2 pole-facing corners; e.g. step=1 returns the 4 corners of the pixel + * @return {SpatialVector[]} points along the boundary of the pixel, on the unit sphere + */ corners_nest(ipix, step) { const i = this.nest2ring(ipix); return this.corners_ring(i, step); } + /** + * @param {number} ipix a RING pixel index at this index's nside + * @return {[number, number]} [theta, phi] spherical coordinates (radians) of the pixel's center + */ pix2ang_ring(ipix) { let theta, phi, iring, iphi, ip, fodd, hip, fihip; if (0 > ipix || ipix > this.npix - 1) { @@ -457,6 +632,10 @@ export class HealpixIndex { return [theta, phi]; } + /** + * @param {number} ipix a RING pixel index + * @return {number} the ring number (1 = the ring closest to the north pole) that pixel lies on + */ ring(ipix) { const {npix,nside,ncap,nl2,nl4}= this; const ipixPlus1 = ipix + 1; @@ -479,6 +658,12 @@ export class HealpixIndex { } } + /** + * Helper used by corners_ring() to locate a ring's north/center/south edges in terms of + * cos(theta), so the pixel's corner points can be positioned along those latitude lines. + * @param {number} i_th a ring number + * @return {[number, number, number]} [north, center, south] cos(theta) values bounding the ring + */ integration_limits_in_costh(i_th) { const {nside,npface,nl3,nl4}= this; let s, i, n; @@ -506,6 +691,15 @@ export class HealpixIndex { return [n, i, s]; } + /** + * Helper used by corners_ring() to find the left/right phi (longitude) boundaries of a pixel + * at a given latitude line (cos_theta) within the pixel. + * @param {number} i_th ring number of the pixel + * @param {number} i_phi phi index of the pixel within its ring + * @param {number} i_zone which of the 4 longitude quadrants (base-face column) the pixel is in + * @param {number} cos_theta cosine of the latitude line to find the boundary at + * @return {[number, number]} [phi_left, phi_right] in radians + */ pixel_boundaries(i_th, i_phi, i_zone, cos_theta) { let sq3th, factor, jd, ju, ku, kd, phi_l, phi_r; const r_n_nside = 1 * this.nside; @@ -547,6 +741,11 @@ export class HealpixIndex { } return [phi_l, phi_r]; } + /** + * @param {number} theta colatitude in radians + * @param {number} phi longitude in radians + * @return {SpatialVector} the unit-sphere position for those spherical coordinates + */ static vector(theta, phi) { const x = Math.sin(theta) * Math.cos(phi); const y = Math.sin(theta) * Math.sin(phi); @@ -554,6 +753,12 @@ export class HealpixIndex { return new SpatialVector(x, y, z); } + /** + * @param {number} pix a RING pixel index + * @param {number} step number of extra points to compute along each pixel edge, in addition + * to the 2 pole-facing corners; e.g. step=1 returns the 4 corners of the pixel + * @return {SpatialVector[]} points along the boundary of the pixel, on the unit sphere + */ corners_ring(pix, step) { const n = 2 * step + 2; const res = Array(n); @@ -594,6 +799,10 @@ export class HealpixIndex { } return res; } + /** + * @param {SpatialVector} spatialVector a position on (or direction toward) the unit sphere + * @return {[number, number]} [theta, phi] spherical coordinates (radians) of that position + */ static vec2Ang(spatialVector) { const s = spatialVector.z / spatialVector.length(); const i = Math.acos(s); @@ -608,14 +817,19 @@ export class HealpixIndex { } /** - * Returns a range set of pixels whose centers lie within a given disk.

- * This method is more efficient in the RING scheme. - * @param {SpatialVector} spatialVector the angular coordinates of the disk center - * @param {number} radius the radius (in radians) of the disk - * @param {boolean} nest true if nest, false if ring - * @param {boolean} inclusive If False, return the exact set of pixels whose pixel centers lie - * within the disk; if True, return all pixels that overlap with the disk, - * @return {Array. }the requested set of pixel number ranges + * Find all the pixels covering a circular disc (cone) on the sky. This is the main way + * firefly answers "which HiPS/HEALPix pixels overlap this region of the sky" - e.g. to find + * which tiles need to be fetched to cover the visible field of view. Internally this always + * works in the RING scheme, since pixels at a given latitude form contiguous ranges there, + * then converts to NEST pixel indexes if requested. + * @param {SpatialVector} spatialVector the angular coordinates of the disk center + * @param {number} radius the radius (in radians) of the disk, in [0, PI] + * @param {boolean} nest true to return NEST pixel indexes, false for RING + * @param {boolean} inclusive If False, return the exact set of pixels whose pixel centers lie + * within the disk; if True, return all pixels that overlap with the disk (found by + * slightly enlarging the disk before the search), which may include some false positives + * near the edge. + * @return {number[]} the pixel indexes covering (or overlapping, if inclusive) the disk */ queryDisc(spatialVector, radius, nest, inclusive) { if (0 > radius || radius > Constants.PI) { @@ -672,6 +886,15 @@ export class HealpixIndex { } } + /** + * Add to pixSet all RING-scheme pixels on a given ring whose centers fall within the + * longitude range [phi0-dphi, phi0+dphi]. Used by queryDisc() to accumulate, ring by ring, + * the pixels that intersect a disc on the sky. + * @param {number} ring ring number to search + * @param {number} phi0 center longitude of the range, in radians + * @param {number} dphi half-width of the longitude range, in radians (PI selects the whole ring) + * @param {Set} pixSet set to add matching RING pixel indexes to + */ inRing(ring, phi0, dphi, pixSet) { let e, ringPix, startpix, hi, nr; const verySmall = 1e-12; @@ -731,6 +954,11 @@ export class HealpixIndex { } } + /** + * @param {number} z cos(theta) of a latitude line + * @return {number} the ring number of the ring just north of (i.e. with a smaller theta than) + * that latitude, used by queryDisc() to bound which rings to search + */ ringAbove(z) { const az = Math.abs(z); if (az > Constants.TWOTHIRD) { @@ -740,11 +968,21 @@ export class HealpixIndex { return toInt(this.nside * (2 - 1.5 * z)); } + /** + * @param {number} ipRing a RING pixel index + * @return {number} the same pixel's NEST pixel index + */ ring2nest(ipRing) { const xyf = this.ring2xyf(ipRing); return xyf2nest(xyf.ix, xyf.iy, xyf.face_num, this.order); } + /** + * The inverse of xyf2ring: split a RING pixel index back into its base face number and + * (x, y) position within that face. + * @param {number} pix a RING pixel index + * @return {{face_num:number, ix:number, iy:number}} + */ ring2xyf(pix) { let iring, iphi, kshift, nr; const ret = {}; // Xyf diff --git a/src/firefly/js/rpc/CoreServices.js b/src/firefly/js/rpc/CoreServices.js index b3121e6dc4..1f2188c468 100644 --- a/src/firefly/js/rpc/CoreServices.js +++ b/src/firefly/js/rpc/CoreServices.js @@ -75,7 +75,7 @@ function buildUploadParam(item, params={}) { const overrideUploadParamKey= Object.keys(params).find( (k) => overrideKeys.includes(k)); if (overrideUploadParamKey) return {[overrideUploadParamKey]:params[overrideUploadParamKey]}; if (isString(item)) { - return (item.startsWith('${') || item.startsWith('/')) ? {fileOnServer:item} : {URL: item}; + return (item.startsWith('${') || item.startsWith('/') || item.startsWith('file://')) ? {fileOnServer:item} : {URL: item}; } else if (WebPlotRequest.isWPR(item)) return {webPlotRequest: item.toString()}; else if (item instanceof Blob) return {file:item}; // handles blob or file diff --git a/src/firefly/js/ui/FileUpload.jsx b/src/firefly/js/ui/FileUpload.jsx index 665b44f47f..a6d9b9463a 100644 --- a/src/firefly/js/ui/FileUpload.jsx +++ b/src/firefly/js/ui/FileUpload.jsx @@ -2,9 +2,10 @@ import {Button, CircularProgress, Input, Stack, Tooltip, Typography} from '@mui/ import React, {memo, useEffect} from 'react'; import {object, bool, func, string, shape} from 'prop-types'; import {has, isFunction, isNil, isString} from 'lodash'; +import {getAppOptions} from '../core/AppDataCntlr'; import {getHttpErrorMessage} from '../util/HttpErrorMessage.js'; import {validateUrl} from '../util/Validate.js'; -import {getStatusFromFetchError} from '../util/WebUtil.js'; +import {getStatusFromFetchError, toBoolean} from '../util/WebUtil.js'; import {InputFieldView} from './InputFieldView.jsx'; import {useFieldGroupConnector} from './FieldGroupConnector.jsx'; import {upload} from '../rpc/CoreServices.js'; @@ -51,7 +52,11 @@ const ChooseUploadFile= ({onChange, value, fileName, canDragDrop}) => (