Skip to content

Commit

Permalink
feat: add support of SkyCoord for the target property (cds-astro#80)
Browse files Browse the repository at this point in the history
* ✨ Implement SkyCoord support for js side

* ✨ Implement SkyCoord support for python side

* 🐛 Fix target support for jslink using shared_target traitlets

* 📝 Update example 6 to fix target jslink behavior

* 🐛 Fix multi-instances flickering

* 🐛 Fix useless target when used in constructor

* ✨ Implement coordinate string parsing using new coordinate_parser function

* 📝 Add minor documentation for widget.js

* 🚀 Add missing target getter type annotation

* 🐛 Fix coordinate_parser.py splitter

* ✅ Add tests for coordinate_parser.py and aladin target getter and setter

* 👷 Add CI for automated python code testing

* ✅ Add more testing cases for existing tests

* 🐛 Fix initial target when frame is galactic

* 🎨 Use a regex to detect if a string is an object name or a coordinate

* 📝 Update CHANGELOG.md

* 📝 Add _target and shared_target trait help & remove useless error in trait getter

* 🎨 Fix python import order

* 🐛 Fix missing event unsubscribe for shared_target

* 🎨 Improve conditional structure for the target setter

* 📝 Improve the meaning of a sentence in the changelog

* 🎨 Improve target setter conditions

* ✨ Add parsing support for coordinate string starting by J, G and B

* ✅ Improve testing for new coordinate parsing functions

* 📝 Change docstring format from sphinx to numpy

* 🎨 Improve conditional structure for parse_coordinate_string function

* fix: type annotations union for python <3.9

* docs: fix doctring style
  • Loading branch information
Xen0Xys authored and ManonMarchand committed Apr 22, 2024
1 parent 731bf99 commit 981833f
Show file tree
Hide file tree
Showing 8 changed files with 528 additions and 39 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: python-tests
on:
push:
branches:
- master
pull_request:
branches:
- master
# Allows to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
python-tests:
runs-on: ubuntu-latest
steps:
- name: "Checkout branch"
uses: actions/checkout@v4
- name: "Set up Python on Ubuntu"
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: "Python codestyle"
run: |
pip install ".[dev]"
pip install pytest
pytest .
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fixed** for any bug fixes.
- **Security** in case of vulnerabilities.

## [Unreleased]

### Added

- Support for `astropy.coordinates.SkyCoord` for assigning and reading the `target` property (#80)

### Fixed

- Fix asynchronous update for the `target` property (#80)

### Changed

- Change the jslink target trait from `target` to `shared_target` (#80)

## [0.3.0]

### Changed
Expand Down
4 changes: 2 additions & 2 deletions examples/6_Linked-widgets.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"c = Aladin(layout=Layout(width=\"33.33%\"), survey=\"P/2MASS/color\", **cosmetic_options)\n",
"\n",
"# synchronize target between 3 widgets\n",
"widgets.jslink((a, \"target\"), (b, \"target\"))\n",
"widgets.jslink((b, \"target\"), (c, \"target\"))\n",
"widgets.jslink((a, \"shared_target\"), (b, \"shared_target\"))\n",
"widgets.jslink((b, \"shared_target\"), (c, \"shared_target\"))\n",
"\n",
"# synchronize FoV (zoom level) between 3 widgets\n",
"widgets.jslink((a, \"fov\"), (b, \"fov\"))\n",
Expand Down
42 changes: 29 additions & 13 deletions js/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "./widget.css";
let idxView = 0;

function convert_pyname_to_jsname(pyname) {
if (pyname.charAt(0) === "_") pyname = pyname.slice(1);
let temp = pyname.split("_");
for (let i = 1; i < temp.length; i++) {
temp[i] = temp[i].charAt(0).toUpperCase() + temp[i].slice(1);
Expand Down Expand Up @@ -34,6 +35,9 @@ function render({ model, el }) {
let aladin = new A.aladin(aladinDiv, init_options);
idxView += 1;

const ra_dec = init_options["target"].split(" ");
aladin.gotoRaDec(ra_dec[0], ra_dec[1]);

el.appendChild(aladinDiv);

/* ------------------- */
Expand All @@ -46,27 +50,34 @@ function render({ model, el }) {
// the gotoObject call should only happen once. The two booleans prevent the two
// listeners from triggering each other and creating a buggy loop. The same trick
// is also necessary for the field of view.

/* Target control */
let target_js = false;
let target_py = false;

aladin.on("positionChanged", (position) => {
if (!target_py) {
target_js = true;
model.set("target", `${position.ra} ${position.dec}`);
model.save_changes();
} else {
// Event triggered when the user moves the map in Aladin Lite
aladin.on("positionChanged", () => {
if (target_py) {
target_py = false;
return;
}
target_js = true;
const ra_dec = aladin.getRaDec();
model.set("_target", `${ra_dec[0]} ${ra_dec[1]}`);
model.set("shared_target", `${ra_dec[0]} ${ra_dec[1]}`);
model.save_changes();
});

model.on("change:target", () => {
if (!target_js) {
target_py = true;
let target = model.get("target");
aladin.gotoObject(target);
} else {
// Event triggered when the target is changed from the Python side using jslink
model.on("change:shared_target", () => {
if (target_js) {
target_js = false;
return;
}
target_py = true;
const target = model.get("shared_target");
const [ra, dec] = target.split(" ");
aladin.gotoRaDec(ra, dec);
});

/* Field of View control */
Expand Down Expand Up @@ -182,6 +193,11 @@ function render({ model, el }) {
model.on("msg:custom", (msg) => {
let options = {};
switch (msg["event_name"]) {
case "goto_ra_dec":
const ra = msg["ra"];
const dec = msg["dec"];
aladin.gotoRaDec(ra, dec);
break;
case "add_catalog_from_URL":
aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], msg["options"]));
break;
Expand Down Expand Up @@ -235,7 +251,7 @@ function render({ model, el }) {

return () => {
// need to unsubscribe the listeners
model.off("change:target");
model.off("change:shared_target");
model.off("change:fov");
model.off("change:height");
model.off("change:coo_frame");
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "ipyaladin"
dynamic = ["version"]
dependencies = ["anywidget"]
dependencies = ["anywidget", "astropy"]
readme = "README.md"

[project.optional-dependencies]
Expand Down
129 changes: 106 additions & 23 deletions src/ipyaladin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import importlib.metadata
import pathlib
from typing import ClassVar
from typing import ClassVar, Union
import warnings

import anywidget
from astropy.coordinates import SkyCoord
from traitlets import (
Float,
Int,
Expand All @@ -17,6 +18,8 @@
Undefined,
)

from .coordinate_parser import parse_coordinate_string

try:
__version__ = importlib.metadata.version("ipyaladin")
except importlib.metadata.PackageNotFoundError:
Expand All @@ -29,7 +32,17 @@ class Aladin(anywidget.AnyWidget):

# Options for the view initialization
height = Int(400).tag(sync=True, init_option=True)
target = Unicode("0 0").tag(sync=True, init_option=True)
_target = Unicode(
"0 0",
help="A private trait that stores the current target of the widget in a string."
" Its public version is the 'target' property that returns an "
"`~astropy.coordinates.SkyCoord` object",
).tag(sync=True, init_option=True)
shared_target = Unicode(
"0 0",
help="A trait that can be used with `~ipywidgets.widgets.jslink`"
"to link two Aladin Lite widgets targets together",
).tag(sync=True)
fov = Float(60.0).tag(sync=True, init_option=True)
survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag(
sync=True, init_option=True
Expand Down Expand Up @@ -85,6 +98,7 @@ def _init_options(self):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.target = kwargs.get("target", "0 0")
self.on_msg(self._handle_custom_message)

def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG002
Expand All @@ -105,11 +119,52 @@ def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG0
elif event_type == "select" and "select" in self.listener_callback:
self.listener_callback["select"](message_content)

@property
def target(self) -> SkyCoord:
"""The target of the Aladin Lite widget.
It can be set with either a string of an `~astropy.coordinates.SkyCoord` object.
Returns
-------
SkyCoord
An astropy.coordinates.SkyCoord object representing the target.
"""
ra, dec = self._target.split(" ")
return SkyCoord(
ra=ra,
dec=dec,
frame="icrs",
unit="deg",
)

@target.setter
def target(self, target: Union[str, SkyCoord]):
if isinstance(target, str): # If the target is str, parse it
target = parse_coordinate_string(target)
elif not isinstance(target, SkyCoord): # If the target is not str or SkyCoord
raise ValueError(
"target must be a string or an astropy.coordinates.SkyCoord object"
)
self._target = f"{target.icrs.ra.deg} {target.icrs.dec.deg}"
self.send(
{
"event_name": "goto_ra_dec",
"ra": target.icrs.ra.deg,
"dec": target.icrs.dec.deg,
}
)

def add_catalog_from_URL(self, votable_URL, votable_options=None):
"""load a VOTable table from an url and load its data into the widget
Args:
votable_URL: string url
votable_options: dictionary object"""
"""Load a VOTable table from an url and load its data into the widget.
Parameters
----------
votable_URL: str
votable_options: dict
"""
if votable_options is None:
votable_options = {}
self.send(
Expand All @@ -123,6 +178,17 @@ def add_catalog_from_URL(self, votable_URL, votable_options=None):
# MOCs

def add_moc(self, moc, **moc_options):
"""Add a MOC to the Aladin-Lite widget.
Parameters
----------
moc : `~mocpy.MOC` or str or dict
The MOC can be provided as a `mocpy.MOC` object, as a string containing an
URL where the MOC can be retrieved, or as a dictionary where the keys are
the HEALPix orders and the values are the pixel indices
(ex: {"1":[1,2,4], "2":[12,13,14,21,23,25]}).
"""
if isinstance(moc, dict):
self.send(
{
Expand Down Expand Up @@ -159,10 +225,15 @@ def add_moc(self, moc, **moc_options):
) from imp

def add_moc_from_URL(self, moc_URL, moc_options=None):
"""load a MOC from a URL and display it in Aladin Lite widget
Arguments:
moc_URL: string url
moc_options: dictionary object"""
"""Load a MOC from a URL and display it in Aladin Lite widget.
Parameters
----------
moc_URL: str
An URL to retrieve the MOC from
moc_options: dict
"""
warnings.warn(
"add_moc_from_URL is replaced by add_moc that detects automatically"
"that the MOC was given as an URL.",
Expand All @@ -174,12 +245,16 @@ def add_moc_from_URL(self, moc_URL, moc_options=None):
self.add_moc(moc_URL, **moc_options)

def add_moc_from_dict(self, moc_dict, moc_options=None):
"""load a MOC from a dict object and display it in Aladin Lite widget
Arguments:
moc_dict: the dict containing the MOC cells. Key are the HEALPix orders,
values are the pixel indexes,
eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]}
moc_options: dictionary object"""
"""Load a MOC from a dict object and display it in Aladin Lite widget.
Parameters
----------
moc_dict: dict
It contains the MOC cells. Key are the HEALPix orders, values are the pixel
indexes, eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]}
moc_options: dict
"""
warnings.warn(
"add_moc_from_dict is replaced by add_moc that detects automatically"
"that the MOC was given as a dictionary.",
Expand Down Expand Up @@ -217,8 +292,8 @@ def add_table(self, table, **table_options):
meta={"name": "my sample table"})
>>> aladin.add_table(table)
And the table should appear in the output of Cell 1!
"""
"""
# this library must be installed, and is used in votable operations
# http://www.astropy.org/
import io
Expand All @@ -234,9 +309,10 @@ def add_overlay_from_stcs(self, stc_string, **overlay_options):
Parameters
----------
stc_string: str
The STC-S string.
The STC-S string.
overlay_options: keyword arguments
TODO: documentation"""
"""
self.send(
{
"event_name": "add_overlay_from_stcs",
Expand All @@ -261,10 +337,17 @@ def rectangular_selection(self):
# Adding a listener

def add_listener(self, listener_type, callback):
"""add a listener to the widget
Args:
listener_type: string that can either be 'objectHovered' or 'objClicked'
callback: python function"""
"""Add a listener to the widget.
Parameters
----------
listener_type: str
Can either be 'objectHovered' or 'objClicked'
callback: Callable
A python function to be called when the event corresponding to the
listener_type is detected
"""
if listener_type in {"objectHovered", "object_hovered"}:
self.listener_callback["object_hovered"] = callback
elif listener_type in {"objectClicked", "object_clicked"}:
Expand Down
Loading

0 comments on commit 981833f

Please sign in to comment.