diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 1034a745..8e13d711 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -11,13 +11,16 @@ on:
jobs:
python-tests:
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: [3.8, 3.12]
steps:
- name: "Checkout branch"
uses: actions/checkout@v4
- name: "Set up Python on Ubuntu"
uses: actions/setup-python@v5
with:
- python-version: 3.12
+ python-version: ${{ matrix.python-version }}
- name: "Python codestyle"
run: |
pip install ".[dev]"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddb56d74..2e6c8409 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Change the jslink target trait from `target` to `shared_target` (#80)
- Change the jslink fov trait from `fov` to `shared_fov` (#83)
+- Deprecate the `add_listener` method for a preferred use of `set_listener` method (#82)
## [0.3.0]
diff --git a/examples/10_Advanced-GUI.ipynb b/examples/10_Advanced-GUI.ipynb
index dfdab0bc..8ce02836 100644
--- a/examples/10_Advanced-GUI.ipynb
+++ b/examples/10_Advanced-GUI.ipynb
@@ -36,7 +36,14 @@
")\n",
"\n",
"\n",
- "def on_survey_value_change(change):\n",
+ "def on_survey_value_change(change: dict) -> None:\n",
+ " \"\"\"Survey change callback.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " change : dict\n",
+ " The change dictionary.\n",
+ " \"\"\"\n",
" aladin.survey = change[\"new\"]\n",
"\n",
"\n",
@@ -63,7 +70,14 @@
")\n",
"\n",
"\n",
- "def on_survey_overlay_value_change(change):\n",
+ "def on_survey_overlay_value_change(change: dict) -> None:\n",
+ " \"\"\"Survey overlay change callback.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " change : dict\n",
+ " The change dictionary.\n",
+ " \"\"\"\n",
" aladin.overlay_survey = change[\"new\"]\n",
" aladin.overlay_survey_opacity = aladin.overlay_survey_opacity + 0.00000001\n",
"\n",
@@ -84,7 +98,14 @@
")\n",
"\n",
"\n",
- "def on_surveyoverlay_opacity_value_change(change):\n",
+ "def on_surveyoverlay_opacity_value_change(change: dict) -> None:\n",
+ " \"\"\"Survey overlay opacity change callback.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " change : dict\n",
+ " The change dictionary.\n",
+ " \"\"\"\n",
" aladin.overlay_survey_opacity = change[\"new\"]\n",
"\n",
"\n",
@@ -105,7 +126,14 @@
")\n",
"\n",
"\n",
- "def on_zoom_slider_value_change(change):\n",
+ "def on_zoom_slider_value_change(change: dict) -> None:\n",
+ " \"\"\"Zoom slider change callback.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " change : dict\n",
+ " The change dictionary.\n",
+ " \"\"\"\n",
" aladin.fov = 180 / change[\"new\"]\n",
"\n",
"\n",
diff --git a/examples/2_Base_Commands.ipynb b/examples/2_Base_Commands.ipynb
index 9b174384..855f6e25 100644
--- a/examples/2_Base_Commands.ipynb
+++ b/examples/2_Base_Commands.ipynb
@@ -71,6 +71,13 @@
"aladin.target = \"sgr a*\""
]
},
+ {
+ "metadata": {},
+ "cell_type": "code",
+ "outputs": [],
+ "execution_count": null,
+ "source": "aladin.target"
+ },
{
"cell_type": "code",
"execution_count": null,
@@ -122,6 +129,32 @@
"source": [
"aladin.coo_frame"
]
+ },
+ {
+ "metadata": {},
+ "cell_type": "markdown",
+ "source": "Some commands can be used with astropy objects"
+ },
+ {
+ "metadata": {},
+ "cell_type": "code",
+ "outputs": [],
+ "execution_count": null,
+ "source": "from astropy.coordinates import Angle, SkyCoord"
+ },
+ {
+ "metadata": {},
+ "cell_type": "code",
+ "outputs": [],
+ "execution_count": null,
+ "source": "aladin.target = SkyCoord(\"12h00m00s\", \"-30d00m00s\", frame=\"icrs\")"
+ },
+ {
+ "metadata": {},
+ "cell_type": "code",
+ "outputs": [],
+ "execution_count": null,
+ "source": "aladin.fov = Angle(5, \"deg\")"
}
],
"metadata": {
diff --git a/examples/3_Functions.ipynb b/examples/3_Functions.ipynb
index cb1f3ae3..2f25433a 100644
--- a/examples/3_Functions.ipynb
+++ b/examples/3_Functions.ipynb
@@ -53,7 +53,7 @@
" \"http://vizier.u-strasbg.fr/viz-bin/votable?-source=HIP2&-c=LMC&-out.add=_RAJ,_\"\n",
" \"DEJ&-oc.form=dm&-out.meta=DhuL&-out.max=9999&-c.rm=180\"\n",
")\n",
- "options = {\"sourceSize\": 12, \"color\": \"#f08080\", \"onClick\": \"showTable\"}\n",
+ "options = {\"source_size\": 12, \"color\": \"#f08080\", \"on_click\": \"showTable\"}\n",
"aladin.add_catalog_from_URL(url, options)"
]
},
@@ -79,12 +79,26 @@
"metadata": {},
"outputs": [],
"source": [
- "def getObjectData(data):\n",
+ "def get_object_data(data: dict) -> dict:\n",
+ " \"\"\"Print the clicked object data.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " data : dict\n",
+ " The data of the clicked object.\n",
+ " \"\"\"\n",
" print(\"It clicked.\")\n",
" return data\n",
"\n",
"\n",
- "def getObjectRaDecProduct(data):\n",
+ "def get_object_ra_dec_product(data: dict) -> float:\n",
+ " \"\"\"Return the product of the ra and dec values of the clicked object.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " data : dict\n",
+ " The data of the clicked object.\n",
+ " \"\"\"\n",
" return data[\"ra\"] * data[\"dec\"]\n",
"\n",
"\n",
@@ -92,8 +106,8 @@
"# json object whose parameter data will be used by the python functions\n",
"# (data is a literal object on the js side, it will be converted as a dictionary\n",
"# object on the python side)\n",
- "aladin.add_listener(\"object_hovered\", getObjectRaDecProduct)\n",
- "aladin.add_listener(\"object_clicked\", getObjectData)"
+ "aladin.set_listener(\"object_hovered\", get_object_ra_dec_product)\n",
+ "aladin.set_listener(\"object_clicked\", get_object_data)"
]
},
{
diff --git a/examples/4_Importing_Tables.ipynb b/examples/4_Importing_Tables.ipynb
index 5dcb5d50..cd5df9a5 100644
--- a/examples/4_Importing_Tables.ipynb
+++ b/examples/4_Importing_Tables.ipynb
@@ -58,7 +58,8 @@
"metadata": {},
"outputs": [],
"source": [
- "aladin.add_table(table, shape=\"rhomb\", color=\"lightskyblue\", sourceSize=20)"
+ "aladin.add_table(table, shape=\"rhomb\", color=\"lightskyblue\", source_size=20)\n",
+ "# This line also works with camelCase instead of snake_case: sourceSize=20"
]
},
{
diff --git a/examples/5_Display_a_MOC.ipynb b/examples/5_Display_a_MOC.ipynb
index 51ae9e8f..61058dcc 100644
--- a/examples/5_Display_a_MOC.ipynb
+++ b/examples/5_Display_a_MOC.ipynb
@@ -284,7 +284,9 @@
" external_radius=20 * u.deg,\n",
" max_depth=16,\n",
")\n",
- "aladin.add_moc(moc, color=\"teal\", edge=True, lineWidth=3, fillColor=\"teal\", opacity=0.5)"
+ "aladin.add_moc(\n",
+ " moc, color=\"teal\", edge=True, line_width=3, fill_color=\"teal\", opacity=0.5\n",
+ ")"
]
}
],
diff --git a/examples/6_Linked-widgets.ipynb b/examples/6_Linked-widgets.ipynb
index f31212f9..1ce40493 100644
--- a/examples/6_Linked-widgets.ipynb
+++ b/examples/6_Linked-widgets.ipynb
@@ -27,12 +27,12 @@
"c = Aladin(layout=Layout(width=\"33.33%\"), survey=\"P/2MASS/color\", **cosmetic_options)\n",
"\n",
"# synchronize target between 3 widgets\n",
- "widgets.jslink((a, \"shared_target\"), (b, \"shared_target\"))\n",
- "widgets.jslink((b, \"shared_target\"), (c, \"shared_target\"))\n",
+ "widgets.jslink((a, \"_target\"), (b, \"_target\"))\n",
+ "widgets.jslink((b, \"_target\"), (c, \"_target\"))\n",
"\n",
"# synchronize FoV (zoom level) between 3 widgets\n",
- "widgets.jslink((a, \"shared_fov\"), (b, \"shared_fov\"))\n",
- "widgets.jslink((b, \"shared_fov\"), (c, \"shared_fov\"))\n",
+ "widgets.jslink((a, \"_fov\"), (b, \"_fov\"))\n",
+ "widgets.jslink((b, \"_fov\"), (c, \"_fov\"))\n",
"\n",
"items = [a, b, c]\n",
"\n",
diff --git a/examples/7_on-click-callback.ipynb b/examples/7_on-click-callback.ipynb
index 90b1af0e..814c7e21 100644
--- a/examples/7_on-click-callback.ipynb
+++ b/examples/7_on-click-callback.ipynb
@@ -37,7 +37,14 @@
"metadata": {},
"outputs": [],
"source": [
- "def process_result(data):\n",
+ "def process_result(data: dict) -> None:\n",
+ " \"\"\"Process the result of a click event on the Aladin widget.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " data : dict\n",
+ " The data returned by the click event.\n",
+ " \"\"\"\n",
" info.value = \"\"\n",
" ra = data[\"ra\"]\n",
" dec = data[\"dec\"]\n",
@@ -78,7 +85,7 @@
" info.value = \"
%s
%s\" % (obj_name, sed_img)\n",
"\n",
"\n",
- "aladin.add_listener(\"click\", process_result)"
+ "aladin.set_listener(\"click\", process_result)"
]
},
{
diff --git a/examples/8_Rectangular-selection.ipynb b/examples/8_Rectangular-selection.ipynb
index 46244e47..7bff0560 100644
--- a/examples/8_Rectangular-selection.ipynb
+++ b/examples/8_Rectangular-selection.ipynb
@@ -33,7 +33,16 @@
"button = widgets.Button(description=\"Select\")\n",
"\n",
"\n",
- "def on_button_clicked(b): # noqa: ARG001\n",
+ "def on_button_clicked(_: any) -> None:\n",
+ " \"\"\"Button click event callback.\n",
+ "\n",
+ " It will trigger the rectangular selection in the Aladin widget.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " _: any\n",
+ " The button widget that triggered the event.\n",
+ " \"\"\"\n",
" aladin.rectangular_selection()\n",
"\n",
"\n",
@@ -61,7 +70,14 @@
"aladin.add_table(table)\n",
"\n",
"\n",
- "def process_result(sources):\n",
+ "def process_result(sources: dict) -> None:\n",
+ " \"\"\"Process the sources selected in the Aladin widget and display them in the table.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " sources: dict\n",
+ " The sources selected in the Aladin widget.\n",
+ " \"\"\"\n",
" s = ''\n",
" s += \"MAIN_ID | RA | DEC |
\"\n",
" for source in sources:\n",
@@ -74,7 +90,7 @@
" table_info.value = s\n",
"\n",
"\n",
- "aladin.add_listener(\"select\", process_result)"
+ "aladin.set_listener(\"select\", process_result)"
]
}
],
diff --git a/js/aladin_lite.js b/js/aladin_lite.js
new file mode 100644
index 00000000..27c2fa99
--- /dev/null
+++ b/js/aladin_lite.js
@@ -0,0 +1,3 @@
+import A from "https://esm.sh/aladin-lite@3.4.0-beta";
+
+export default A;
diff --git a/js/models/event_handler.js b/js/models/event_handler.js
new file mode 100644
index 00000000..3c88e812
--- /dev/null
+++ b/js/models/event_handler.js
@@ -0,0 +1,209 @@
+import MessageHandler from "./message_handler";
+import { Lock } from "../utils";
+
+export default class EventHandler {
+ /**
+ * Constructor for the EventHandler class.
+ * @param aladin The Aladin instance
+ * @param aladinDiv The Aladin div
+ * @param model The model instance
+ */
+ constructor(aladin, aladinDiv, model) {
+ this.aladin = aladin;
+ this.aladinDiv = aladinDiv;
+ this.model = model;
+ this.messageHandler = new MessageHandler(aladin);
+ }
+
+ /**
+ * Subscribes to all the events needed for the Aladin Lite widget.
+ */
+ subscribeAll() {
+ /* ------------------- */
+ /* Listeners --------- */
+ /* ------------------- */
+
+ /* Position Control */
+ // there are two ways of changing the target, one from the javascript side, and
+ // one from the python side. We have to instantiate two listeners for these, but
+ // 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 */
+ const jsTargetLock = new Lock();
+ const pyTargetLock = new Lock();
+
+ // Event triggered when the user moves the map in Aladin Lite
+ this.aladin.on("positionChanged", (position) => {
+ if (pyTargetLock.locked) {
+ pyTargetLock.unlock();
+ return;
+ }
+ jsTargetLock.lock();
+ const raDec = [position.ra, position.dec];
+ // const raDec = this.aladin.getRaDec();
+ this.model.set("_target", `${raDec[0]} ${raDec[1]}`);
+ this.model.save_changes();
+ });
+
+ this.model.on("change:_target", () => {
+ if (jsTargetLock.locked) {
+ jsTargetLock.unlock();
+ return;
+ }
+ pyTargetLock.lock();
+ let target = this.model.get("_target");
+ const [ra, dec] = target.split(" ");
+ this.aladin.gotoRaDec(ra, dec);
+ });
+
+ /* Field of View control */
+ const jsFovLock = new Lock();
+ const pyFovLock = new Lock();
+
+ this.aladin.on("zoomChanged", (fov) => {
+ if (pyFovLock.locked) {
+ pyFovLock.unlock();
+ return;
+ }
+ jsFovLock.lock();
+ // fov MUST be cast into float in order to be sent to the model
+ this.model.set("_fov", parseFloat(fov.toFixed(5)));
+ this.model.save_changes();
+ });
+
+ this.model.on("change:_fov", () => {
+ if (jsFovLock.locked) {
+ jsFovLock.unlock();
+ return;
+ }
+ pyFovLock.lock();
+ let fov = this.model.get("_fov");
+ this.aladin.setFoV(fov);
+ });
+
+ /* Div control */
+ this.model.on("change:height", () => {
+ let height = this.model.get("height");
+ this.aladinDiv.style.height = `${height}px`;
+ });
+
+ /* Aladin callbacks */
+
+ this.aladin.on("objectHovered", (object) => {
+ if (object["data"] !== undefined) {
+ this.model.send({
+ event_type: "object_hovered",
+ content: {
+ ra: object["ra"],
+ dec: object["dec"],
+ },
+ });
+ }
+ });
+
+ this.aladin.on("objectClicked", (clicked) => {
+ let clickedContent = {
+ ra: clicked["ra"],
+ dec: clicked["dec"],
+ };
+ if (clicked["data"] !== undefined) {
+ clickedContent["data"] = clicked["data"];
+ }
+ this.model.set("clicked", clickedContent);
+ // send a custom message in case the user wants to define their own callbacks
+ this.model.send({
+ event_type: "object_clicked",
+ content: clickedContent,
+ });
+ this.model.save_changes();
+ });
+
+ this.aladin.on("click", (clickContent) => {
+ this.model.send({
+ event_type: "click",
+ content: clickContent,
+ });
+ });
+
+ this.aladin.on("select", (catalogs) => {
+ let objectsData = [];
+ // TODO: this flattens the selection. Each object from different
+ // catalogs are entered in the array. To change this, maybe change
+ // upstream what is returned upon selection?
+ catalogs.forEach((catalog) => {
+ catalog.forEach((object) => {
+ objectsData.push({
+ ra: object.ra,
+ dec: object.dec,
+ data: object.data,
+ x: object.x,
+ y: object.y,
+ });
+ });
+ });
+ this.model.send({
+ event_type: "select",
+ content: objectsData,
+ });
+ });
+
+ /* Aladin functionalities */
+
+ this.model.on("change:coo_frame", () => {
+ this.aladin.setFrame(this.model.get("coo_frame"));
+ });
+
+ this.model.on("change:survey", () => {
+ this.aladin.setImageSurvey(this.model.get("survey"));
+ });
+
+ this.model.on("change:overlay_survey", () => {
+ this.aladin.setOverlayImageLayer(this.model.get("overlay_survey"));
+ });
+
+ this.model.on("change:overlay_survey_opacity", () => {
+ this.aladin
+ .getOverlayImageLayer()
+ .setAlpha(this.model.get("overlay_survey_opacity"));
+ });
+
+ this.eventHandlers = {
+ change_fov: this.messageHandler.handleChangeFoV,
+ goto_ra_dec: this.messageHandler.handleGotoRaDec,
+ add_catalog_from_URL: this.messageHandler.handleAddCatalogFromURL,
+ add_MOC_from_URL: this.messageHandler.handleAddMOCFromURL,
+ add_MOC_from_dict: this.messageHandler.handleAddMOCFromDict,
+ add_overlay_from_stcs: this.messageHandler.handleAddOverlayFromSTCS,
+ change_colormap: this.messageHandler.handleChangeColormap,
+ get_JPG_thumbnail: this.messageHandler.handleGetJPGThumbnail,
+ trigger_rectangular_selection:
+ this.messageHandler.handleTriggerRectangularSelection,
+ add_table: this.messageHandler.handleAddTable,
+ };
+
+ this.model.on("msg:custom", (msg, buffers) => {
+ const eventName = msg["event_name"];
+ const handler = this.eventHandlers[eventName];
+ if (handler) handler.call(this, msg, buffers);
+ else throw new Error(`Unknown event name: ${eventName}`);
+ });
+ }
+
+ /**
+ * Unsubscribe from all the model events.
+ * There is no need to unsubscribe from the Aladin Lite events.
+ */
+ unsubscribeAll() {
+ this.model.off("change:_target");
+ this.model.off("change:_fov");
+ this.model.off("change:height");
+ this.model.off("change:coo_frame");
+ this.model.off("change:survey");
+ this.model.off("change:overlay_survey");
+ this.model.off("change:overlay_survey_opacity");
+ this.model.off("change:trigger_event");
+ this.model.off("msg:custom");
+ }
+}
diff --git a/js/models/message_handler.js b/js/models/message_handler.js
new file mode 100644
index 00000000..3dfff7b2
--- /dev/null
+++ b/js/models/message_handler.js
@@ -0,0 +1,70 @@
+import { convertOptionNamesToCamelCase } from "../utils";
+import A from "../aladin_lite";
+
+export default class MessageHandler {
+ constructor(aladin) {
+ this.aladin = aladin;
+ }
+
+ handleChangeFoV(msg) {
+ this.aladin.setFoV(msg["fov"]);
+ }
+
+ handleGotoRaDec(msg) {
+ this.aladin.gotoRaDec(msg["ra"], msg["dec"]);
+ }
+
+ handleAddCatalogFromURL(msg) {
+ const options = convertOptionNamesToCamelCase(msg["options"] || {});
+ this.aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], options));
+ }
+
+ handleAddMOCFromURL(msg) {
+ const options = convertOptionNamesToCamelCase(msg["options"] || {});
+ this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options));
+ }
+
+ handleAddMOCFromDict(msg) {
+ const options = convertOptionNamesToCamelCase(msg["options"] || {});
+ this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options));
+ }
+
+ handleAddOverlayFromSTCS(msg) {
+ const overlayOptions = convertOptionNamesToCamelCase(
+ msg["overlay_options"] || {},
+ );
+ const stcString = msg["stc_string"];
+ const overlay = A.graphicOverlay(overlayOptions);
+ this.aladin.addOverlay(overlay);
+ overlay.addFootprints(A.footprintsFromSTCS(stcString));
+ }
+
+ handleChangeColormap(msg) {
+ this.aladin.getBaseImageLayer().setColormap(msg["colormap"]);
+ }
+
+ handleGetJPGThumbnail() {
+ this.aladin.exportAsPNG();
+ }
+
+ handleTriggerRectangularSelection() {
+ this.aladin.select();
+ }
+
+ handleAddTable(msg, buffers) {
+ const options = convertOptionNamesToCamelCase(msg["options"] || {});
+ const buffer = buffers[0].buffer;
+ const decoder = new TextDecoder("utf-8");
+ const blob = new Blob([decoder.decode(buffer)]);
+ const url = URL.createObjectURL(blob);
+ A.catalogFromURL(
+ url,
+ Object.assign(options, { onClick: "showTable" }),
+ (catalog) => {
+ this.aladin.addCatalog(catalog);
+ },
+ false,
+ );
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/js/utils.js b/js/utils.js
new file mode 100644
index 00000000..746ba336
--- /dev/null
+++ b/js/utils.js
@@ -0,0 +1,44 @@
+/**
+ * Converts a string from camelCase to snake_case.
+ * @param {string} snakeCaseStr - The string to convert.
+ * @returns {string} The string converted to snake_case.
+ */
+function snakeCaseToCamelCase(snakeCaseStr) {
+ if (snakeCaseStr.charAt(0) === "_") snakeCaseStr = snakeCaseStr.slice(1);
+ let temp = snakeCaseStr.split("_");
+ for (let i = 1; i < temp.length; i++)
+ temp[i] = temp[i].charAt(0).toUpperCase() + temp[i].slice(1);
+ return temp.join("");
+}
+
+/**
+ * Converts option names in an object from snake_case to camelCase.
+ * @param {Object} options - The options object with snake_case property names.
+ * @returns {Object} An object with property names converted to camelCase.
+ */
+function convertOptionNamesToCamelCase(options) {
+ const newOptions = {};
+ for (const optionName in options)
+ newOptions[snakeCaseToCamelCase(optionName)] = options[optionName];
+ return newOptions;
+}
+
+class Lock {
+ locked = false;
+
+ /**
+ * Locks the object
+ */
+ unlock() {
+ this.locked = false;
+ }
+
+ /**
+ * Unlocks the object
+ */
+ lock() {
+ this.locked = true;
+ }
+}
+
+export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, Lock };
diff --git a/js/widget.js b/js/widget.js
index 3d997108..7b261dde 100644
--- a/js/widget.js
+++ b/js/widget.js
@@ -1,277 +1,50 @@
-import A from "https://esm.sh/aladin-lite@3.4.0-beta";
import "./widget.css";
+import EventHandler from "./models/event_handler";
+import { snakeCaseToCamelCase } from "./utils";
+import A from "./aladin_lite";
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);
- }
- return temp.join("");
-}
-
-async function initialize({ model }) {
- await A.init;
-}
-
-function render({ model, el }) {
- /* ------------------- */
- /* View -------------- */
- /* ------------------- */
-
- let init_options = {};
+function initAladinLite(model, el) {
+ let initOptions = {};
model.get("init_options").forEach((option_name) => {
- init_options[convert_pyname_to_jsname(option_name)] =
- model.get(option_name);
+ initOptions[snakeCaseToCamelCase(option_name)] = model.get(option_name);
});
let aladinDiv = document.createElement("div");
aladinDiv.classList.add("aladin-widget");
- aladinDiv.style.height = `${init_options["height"]}px`;
+ aladinDiv.style.height = `${initOptions["height"]}px`;
aladinDiv.id = `aladin-lite-div-${idxView}`;
- let aladin = new A.aladin(aladinDiv, init_options);
+ let aladin = new A.aladin(aladinDiv, initOptions);
idxView += 1;
- const ra_dec = init_options["target"].split(" ");
- aladin.gotoRaDec(ra_dec[0], ra_dec[1]);
+ // Set the target again after the initialization to be sure that the target is set
+ // from icrs coordinates because of the use of gotoObject in the Aladin Lite API
+ const raDec = initOptions["target"].split(" ");
+ aladin.gotoRaDec(raDec[0], raDec[1]);
el.appendChild(aladinDiv);
+ return { aladin, aladinDiv };
+}
+
+async function initialize({ model }) {
+ await A.init;
+}
+function render({ model, el }) {
/* ------------------- */
- /* Listeners --------- */
+ /* View -------------- */
/* ------------------- */
- /* Position Control */
- // there are two ways of changing the target, one from the javascript side, and
- // one from the python side. We have to instantiate two listeners for these, but
- // 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;
+ const { aladin, aladinDiv } = initAladinLite(model, el);
- // 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();
- });
-
- // 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 */
- let fov_py = false;
- let fov_js = false;
-
- aladin.on("zoomChanged", (fov) => {
- if (fov_py) {
- fov_py = false;
- return;
- }
- fov_js = true;
- // fov MUST be cast into float in order to be sent to the model
- model.set("_fov", parseFloat(fov.toFixed(5)));
- model.set("shared_fov", parseFloat(fov.toFixed(5)));
- model.save_changes();
- });
-
- model.on("change:shared_fov", () => {
- if (fov_js) {
- fov_js = false;
- return;
- }
- fov_py = true;
- let fov = model.get("shared_fov");
- aladin.setFoV(fov);
- });
-
- /* Div control */
-
- model.on("change:height", () => {
- let height = model.get("height");
- aladinDiv.style.height = `${height}px`;
- });
-
- /* Aladin callbacks */
-
- aladin.on("objectHovered", (object) => {
- if (object["data"] != undefined) {
- model.send({
- event_type: "object_hovered",
- content: {
- ra: object["ra"],
- dec: object["dec"],
- },
- });
- }
- });
-
- aladin.on("objectClicked", (clicked) => {
- let clicked_content = {
- ra: clicked["ra"],
- dec: clicked["dec"],
- };
- if (clicked["data"] !== undefined) {
- clicked_content["data"] = clicked["data"];
- }
- model.set("clicked", clicked_content);
- // send a custom message in case the user wants to define their own callbacks
- model.send({
- event_type: "object_clicked",
- content: clicked_content,
- });
- model.save_changes();
- });
-
- aladin.on("click", (click_content) => {
- model.send({
- event_type: "click",
- content: click_content,
- });
- });
-
- aladin.on("select", (catalogs) => {
- let objects_data = [];
- // TODO: this flattens the selection. Each object from different
- // catalogs are entered in the array. To change this, maybe change
- // upstream what is returned upon selection?
- catalogs.forEach((catalog) => {
- catalog.forEach((object) => {
- objects_data.push({
- ra: object.ra,
- dec: object.dec,
- data: object.data,
- x: object.x,
- y: object.y,
- });
- });
- });
- model.send({
- event_type: "select",
- content: objects_data,
- });
- });
-
- /* Aladin functionalities */
-
- model.on("change:coo_frame", () => {
- aladin.setFrame(model.get("coo_frame"));
- });
-
- model.on("change:survey", () => {
- aladin.setImageSurvey(model.get("survey"));
- });
-
- model.on("change:overlay_survey", () => {
- aladin.setOverlayImageLayer(model.get("overlay_survey"));
- });
-
- model.on("change:overlay_survey_opacity", () => {
- aladin.getOverlayImageLayer().setAlpha(model.get("overlay_survey_opacity"));
- });
-
- model.on("msg:custom", (msg) => {
- let options = {};
- switch (msg["event_name"]) {
- case "change_fov":
- aladin.setFoV(msg["fov"]);
- break;
- 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;
- case "add_MOC_from_URL":
- // linewidth = 3 is easier to see than the default 1 from upstream
- options = msg["options"];
- if (options["lineWidth"] === undefined) {
- options["lineWidth"] = 3;
- }
- aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options));
- break;
- case "add_MOC_from_dict":
- // linewidth = 3 is easier to see than the default 1 from upstream
- options = msg["options"];
- if (options["lineWidth"] === undefined) {
- options["lineWidth"] = 3;
- }
- aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options));
- break;
- case "add_overlay_from_stcs":
- let overlay = A.graphicOverlay(msg["overlay_options"]);
- aladin.addOverlay(overlay);
- overlay.addFootprints(A.footprintsFromSTCS(msg["stc_string"]));
- break;
- case "change_colormap":
- aladin.getBaseImageLayer().setColormap(msg["colormap"]);
- break;
- case "get_JPG_thumbnail":
- aladin.exportAsPNG();
- break;
- case "trigger_rectangular_selection":
- aladin.select();
- break;
- case "add_table":
- let table_bytes = model.get("_table");
- let decoder = new TextDecoder("utf-8");
- let blob = new Blob([decoder.decode(table_bytes)]);
- let url = URL.createObjectURL(blob);
- A.catalogFromURL(
- url,
- Object.assign(msg.options, { onClick: "showTable" }),
- (catalog) => {
- aladin.addCatalog(catalog);
- },
- false,
- );
- URL.revokeObjectURL(url);
- break;
- }
- });
+ const eventHandler = new EventHandler(aladin, aladinDiv, model);
+ eventHandler.subscribeAll();
return () => {
- // need to unsubscribe the listeners
- model.off("change:shared_target");
- model.off("change:shared_fov");
- model.off("change:height");
- model.off("change:coo_frame");
- model.off("change:survey");
- model.off("change:overlay_survey");
- model.off("change:overlay_survey_opacity");
- model.off("change:trigger_event");
- model.off("change:_table");
- model.off("msg:custom");
-
- aladin.off("positionChanged");
- aladin.off("zoomChanged");
- aladin.off("objectHovered");
- aladin.off("objectClicked");
- aladin.off("click");
- aladin.off("select");
+ // Need to unsubscribe the listeners
+ eventHandler.unsubscribeAll();
};
}
diff --git a/pyproject.toml b/pyproject.toml
index 2359e992..0c301e02 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,9 +7,12 @@ name = "ipyaladin"
dynamic = ["version"]
dependencies = ["anywidget", "astropy"]
readme = "README.md"
+license = "BSD-3-Clause"
+requires-python = ">=3.8"
[project.optional-dependencies]
-dev = ["watchfiles", "jupyterlab", "ruff"]
+dev = ["pytest", "watchfiles", "jupyterlab", "ruff"]
+recommended = ["mocpy"]
# automatically add the dev feature to the default env (e.g., hatch shell)
[tool.hatch.envs.default]
@@ -40,9 +43,13 @@ extend-include = ["*.ipynb"]
[tool.ruff.lint]
extend-select = ["E", "W", "YTT", "ASYNC", "BLE", "B", "A",
"C4", "ISC", "PIE", "PYI", "RSE", "RET", "SIM",
- "PTH", "TD", "ERA", "PL", "PERF", "RUF", "ARG"
+ "PTH", "TD", "ERA", "PL", "PERF", "RUF", "ARG",
+ "ANN", "D"
]
-ignore = ["ISC001"]
+ignore = ["ISC001", "ANN101", "D203", "D213", "D100", "D105"]
+
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"
[tool.ruff.format]
docstring-code-format = false
diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py
index 96a1133b..7b80ec63 100644
--- a/src/ipyaladin/__init__.py
+++ b/src/ipyaladin/__init__.py
@@ -1,391 +1,11 @@
+"""Top-level package for ipyaladin."""
+
import importlib.metadata
-import pathlib
-from typing import ClassVar, Union
-import warnings
-import anywidget
-from astropy.coordinates import SkyCoord, Angle
-from traitlets import (
- Float,
- Int,
- Unicode,
- Bool,
- List,
- Dict,
- Any,
- Bytes,
- default,
- Undefined,
-)
+from .aladin import Aladin # noqa: F401
-from .coordinate_parser import parse_coordinate_string
try:
__version__ = importlib.metadata.version("ipyaladin")
except importlib.metadata.PackageNotFoundError:
__version__ = "unknown"
-
-
-class Aladin(anywidget.AnyWidget):
- _esm = pathlib.Path(__file__).parent / "static" / "widget.js"
- _css = pathlib.Path(__file__).parent / "static" / "widget.css"
-
- # Options for the view initialization
- height = Int(400).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,
- help="A private trait that stores the current field of view of the widget."
- " Its public version is the 'fov' property that returns an "
- "`~astropy.units.Angle` object",
- ).tag(sync=True, init_option=True)
- shared_fov = Float(
- 60.0,
- help="A trait that can be used with `~ipywidgets.widgets.jslink`"
- "to link two Aladin Lite widgets field of view together",
- ).tag(sync=True)
- survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag(
- sync=True, init_option=True
- )
- coo_frame = Unicode("J2000").tag(sync=True, init_option=True)
- projection = Unicode("SIN").tag(sync=True, init_option=True)
- samp = Bool(False).tag(sync=True, init_option=True)
- # Buttons on/off
- background_color = Unicode("rgb(60, 60, 60)").tag(sync=True, init_option=True)
- show_zoom_control = Bool(False).tag(sync=True, init_option=True)
- show_layers_control = Bool(True).tag(sync=True, init_option=True)
- show_overlay_stack_control = Bool(True).tag(sync=True, init_option=True)
- show_fullscreen_control = Bool(True).tag(sync=True, init_option=True)
- show_simbad_pointer_control = Bool(True).tag(sync=True, init_option=True)
- show_settings_control = Bool(True).tag(sync=True, init_option=True)
- show_share_control = Bool(False).tag(sync=True, init_option=True)
- show_status_bar = Bool(True).tag(sync=True, init_option=True)
- show_frame = Bool(True).tag(sync=True, init_option=True)
- show_fov = Bool(True).tag(sync=True, init_option=True)
- show_coo_location = Bool(True).tag(sync=True, init_option=True)
- show_projection_control = Bool(True).tag(sync=True, init_option=True)
- show_context_menu = Bool(True).tag(sync=True, init_option=True)
- show_catalog = Bool(True).tag(sync=True, init_option=True)
- full_screen = Bool(False).tag(sync=True, init_option=True)
- # reticle
- show_reticle = Bool(True).tag(sync=True, init_option=True)
- reticle_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True)
- reticle_size = Int(20).tag(sync=True, init_option=True)
- # grid
- show_coo_grid = Bool(False).tag(sync=True, init_option=True)
- show_coo_grid_control = Bool(True).tag(sync=True, init_option=True)
- grid_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True)
- grid_opacity = Float(0.5).tag(sync=True, init_option=True)
- grid_options = Dict().tag(sync=True, init_option=True)
-
- # content of the last click
- clicked_object = Dict().tag(sync=True)
- # listener callback is on the python side and contains functions to link to events
- listener_callback: ClassVar = {}
-
- # overlay survey
- overlay_survey = Unicode("").tag(sync=True, init_option=True)
- overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True)
-
- # tables/catalogs
- _table = Bytes(Undefined).tag(sync=True)
-
- init_options = List(trait=Any()).tag(sync=True)
-
- @default("init_options")
- def _init_options(self):
- return list(self.traits(init_option=True))
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.target = kwargs.get("target", "0 0")
- self.fov = kwargs.get("fov", 60.0)
- self.on_msg(self._handle_custom_message)
-
- def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG002
- event_type = message["event_type"]
- message_content = message["content"]
- if (
- event_type == "object_clicked"
- and "object_clicked" in self.listener_callback
- ):
- self.listener_callback["object_clicked"](message_content)
- elif (
- event_type == "object_hovered"
- and "object_hovered" in self.listener_callback
- ):
- self.listener_callback["object_hovered"](message_content)
- elif event_type == "click" and "click" in self.listener_callback:
- self.listener_callback["click"](message_content)
- elif event_type == "select" and "select" in self.listener_callback:
- self.listener_callback["select"](message_content)
-
- @property
- def fov(self) -> Angle:
- """The field of view of the Aladin Lite widget along the horizontal axis.
-
- It can be set with either a float in degrees
- or an `~astropy.units.Angle` object.
-
- Returns
- -------
- Angle
- An astropy.units.Angle object representing the field of view.
-
- """
- return Angle(self._fov, unit="deg")
-
- @fov.setter
- def fov(self, fov: Union[float, Angle]):
- if isinstance(fov, Angle):
- fov = fov.deg
- self._fov = fov
- self.send({"event_name": "change_fov", "fov": fov})
-
- @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.
-
- Parameters
- ----------
- votable_URL: str
- votable_options: dict
-
- """
- if votable_options is None:
- votable_options = {}
- self.send(
- {
- "event_name": "add_catalog_from_URL",
- "votable_URL": votable_URL,
- "options": votable_options,
- }
- )
-
- # 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(
- {
- "event_name": "add_MOC_from_dict",
- "moc_dict": moc,
- "options": moc_options,
- }
- )
- elif isinstance(moc, str) and "://" in moc:
- self.send(
- {
- "event_name": "add_MOC_from_URL",
- "moc_URL": moc,
- "options": moc_options,
- }
- )
- else:
- try:
- from mocpy import MOC
-
- if isinstance(moc, MOC):
- self.send(
- {
- "event_name": "add_MOC_from_dict",
- "moc_dict": moc.serialize("json"),
- "options": moc_options,
- }
- )
- except ImportError as imp:
- raise ValueError(
- "A MOC can be given as an URL, a dictionnary, or a mocpy.MOC "
- "object. To read mocpy.MOC objects, you need to install the mocpy "
- "library with 'pip install mocpy'."
- ) 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.
-
- 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.",
- DeprecationWarning,
- stacklevel=2,
- )
- if moc_options is None:
- moc_options = {}
- 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.
-
- 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.",
- DeprecationWarning,
- stacklevel=2,
- )
- if moc_options is None:
- moc_options = {}
- self.add_moc(moc_dict, **moc_options)
-
- def add_table(self, table, **table_options):
- """Load a table into the widget.
-
- Parameters
- ----------
- table : astropy.table.table.QTable or astropy.table.table.Table
- table that must contain coordinates information
-
- Examples
- --------
- Cell 1:
- >>> from ipyaladin import Aladin
- >>> from astropy.table import QTable
- >>> aladin = Aladin(fov=2, target='M1')
- >>> aladin
- Cell 2:
- >>> ra = [83.63451584700, 83.61368056017, 83.58780251600]
- >>> dec = [22.05652591227, 21.97517807639, 21.99277764451]
- >>> name = ["Gaia EDR3 3403818589184411648",
- "Gaia EDR3 3403817661471500416",
- "Gaia EDR3 3403817936349408000",
- ]
- >>> table = QTable([ra, dec, name],
- names=("ra", "dec", "name"),
- 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
-
- table_bytes = io.BytesIO()
- table.write(table_bytes, format="votable")
- self._table = table_bytes.getvalue()
- self.send({"event_name": "add_table", "options": table_options})
-
- def add_overlay_from_stcs(self, stc_string, **overlay_options):
- """Add an overlay layer defined by a STC-S string.
-
- Parameters
- ----------
- stc_string: str
- The STC-S string.
- overlay_options: keyword arguments
-
- """
- self.send(
- {
- "event_name": "add_overlay_from_stcs",
- "stc_string": stc_string,
- "overlay_options": overlay_options,
- }
- )
-
- # Note: the print() options end='\r'allow us to override the previous prints,
- # thus only the last message will be displayed at the screen
-
- def get_JPEG_thumbnail(self):
- """Create a popup window with the current Aladin view."""
- self.send({"event_name": "get_JPG_thumbnail"})
-
- def set_color_map(self, color_map_name):
- self.send({"event_name": "change_colormap", "colormap": color_map_name})
-
- def rectangular_selection(self):
- self.send({"event_name": "trigger_rectangular_selection"})
-
- # Adding a listener
-
- def add_listener(self, listener_type, callback):
- """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"}:
- self.listener_callback["object_clicked"] = callback
- elif listener_type == "click":
- self.listener_callback["click"] = callback
- elif listener_type == "select":
- self.listener_callback["select"] = callback
diff --git a/src/ipyaladin/aladin.py b/src/ipyaladin/aladin.py
new file mode 100644
index 00000000..0386f215
--- /dev/null
+++ b/src/ipyaladin/aladin.py
@@ -0,0 +1,425 @@
+"""
+Aladin Lite widget for Jupyter Notebook.
+
+This module provides a Python wrapper around the Aladin Lite JavaScript library.
+It allows to display astronomical images and catalogs in an interactive way.
+"""
+
+import pathlib
+import typing
+from typing import ClassVar, Union, Final, Optional
+import warnings
+
+import anywidget
+from astropy.table.table import QTable
+from astropy.table import Table
+from astropy.coordinates import SkyCoord, Angle
+import traitlets
+from traitlets import (
+ Float,
+ Int,
+ Unicode,
+ Bool,
+ Any,
+ default,
+)
+
+from .coordinate_parser import parse_coordinate_string
+
+
+class Aladin(anywidget.AnyWidget):
+ """Aladin Lite widget.
+
+ This widget is a Python wrapper around the Aladin Lite JavaScript library.
+ It allows to display astronomical images and catalogs in an interactive way.
+ """
+
+ _esm: Final = pathlib.Path(__file__).parent / "static" / "widget.js"
+ _css: Final = pathlib.Path(__file__).parent / "static" / "widget.css"
+
+ # Options for the view initialization
+ height = Int(400).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)
+ _fov = Float(
+ 60.0,
+ help="A private trait that stores the current field of view of the widget."
+ " Its public version is the 'fov' property that returns an "
+ "`~astropy.units.Angle` object",
+ ).tag(sync=True, init_option=True)
+ survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag(
+ sync=True, init_option=True
+ )
+ coo_frame = Unicode("J2000").tag(sync=True, init_option=True)
+ projection = Unicode("SIN").tag(sync=True, init_option=True)
+ samp = Bool(False).tag(sync=True, init_option=True)
+ # Buttons on/off
+ background_color = Unicode("rgb(60, 60, 60)").tag(sync=True, init_option=True)
+ show_zoom_control = Bool(False).tag(sync=True, init_option=True)
+ show_layers_control = Bool(True).tag(sync=True, init_option=True)
+ show_overlay_stack_control = Bool(True).tag(sync=True, init_option=True)
+ show_fullscreen_control = Bool(True).tag(sync=True, init_option=True)
+ show_simbad_pointer_control = Bool(True).tag(sync=True, init_option=True)
+ show_settings_control = Bool(True).tag(sync=True, init_option=True)
+ show_share_control = Bool(False).tag(sync=True, init_option=True)
+ show_status_bar = Bool(True).tag(sync=True, init_option=True)
+ show_frame = Bool(True).tag(sync=True, init_option=True)
+ show_fov = Bool(True).tag(sync=True, init_option=True)
+ show_coo_location = Bool(True).tag(sync=True, init_option=True)
+ show_projection_control = Bool(True).tag(sync=True, init_option=True)
+ show_context_menu = Bool(True).tag(sync=True, init_option=True)
+ show_catalog = Bool(True).tag(sync=True, init_option=True)
+ full_screen = Bool(False).tag(sync=True, init_option=True)
+ # reticle
+ show_reticle = Bool(True).tag(sync=True, init_option=True)
+ reticle_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True)
+ reticle_size = Int(20).tag(sync=True, init_option=True)
+ # grid
+ show_coo_grid = Bool(False).tag(sync=True, init_option=True)
+ show_coo_grid_control = Bool(True).tag(sync=True, init_option=True)
+ grid_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True)
+ grid_opacity = Float(0.5).tag(sync=True, init_option=True)
+ grid_options = traitlets.Dict().tag(sync=True, init_option=True)
+
+ # content of the last click
+ clicked_object = traitlets.Dict().tag(sync=True)
+ # listener callback is on the python side and contains functions to link to events
+ listener_callback: ClassVar[typing.Dict[str, callable]] = {}
+
+ # overlay survey
+ overlay_survey = Unicode("").tag(sync=True, init_option=True)
+ overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True)
+
+ init_options = traitlets.List(trait=Any()).tag(sync=True)
+
+ @default("init_options")
+ def _init_options(self) -> typing.List[str]:
+ return list(self.traits(init_option=True))
+
+ def __init__(self, *args: any, **kwargs: any) -> None:
+ super().__init__(*args, **kwargs)
+ self.target = kwargs.get("target", "0 0")
+ self.fov = kwargs.get("fov", 60.0)
+ self.on_msg(self._handle_custom_message)
+
+ def _handle_custom_message(self, _: any, message: dict, __: any) -> None:
+ event_type = message["event_type"]
+ message_content = message["content"]
+ if (
+ event_type == "object_clicked"
+ and "object_clicked" in self.listener_callback
+ ):
+ self.listener_callback["object_clicked"](message_content)
+ elif (
+ event_type == "object_hovered"
+ and "object_hovered" in self.listener_callback
+ ):
+ self.listener_callback["object_hovered"](message_content)
+ elif event_type == "click" and "click" in self.listener_callback:
+ self.listener_callback["click"](message_content)
+ elif event_type == "select" and "select" in self.listener_callback:
+ self.listener_callback["select"](message_content)
+
+ @property
+ def fov(self) -> Angle:
+ """The field of view of the Aladin Lite widget along the horizontal axis.
+
+ It can be set with either a float in degrees
+ or an `~astropy.units.Angle` object.
+
+ Returns
+ -------
+ Angle
+ An astropy.units.Angle object representing the field of view.
+
+ """
+ return Angle(self._fov, unit="deg")
+
+ @fov.setter
+ def fov(self, fov: Union[float, Angle]) -> None:
+ if isinstance(fov, Angle):
+ fov = fov.deg
+ self._fov = fov
+ self.send({"event_name": "change_fov", "fov": fov})
+
+ @property
+ def target(self) -> SkyCoord:
+ """The target of the Aladin Lite widget.
+
+ It can be set with either a string or 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]) -> None:
+ 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: str, votable_options: Optional[dict] = None
+ ) -> None:
+ """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(
+ {
+ "event_name": "add_catalog_from_URL",
+ "votable_URL": votable_URL,
+ "options": votable_options,
+ }
+ )
+
+ # MOCs
+
+ def add_moc(self, moc: any, **moc_options: any) -> None:
+ """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(
+ {
+ "event_name": "add_MOC_from_dict",
+ "moc_dict": moc,
+ "options": moc_options,
+ }
+ )
+ elif isinstance(moc, str) and "://" in moc:
+ self.send(
+ {
+ "event_name": "add_MOC_from_URL",
+ "moc_URL": moc,
+ "options": moc_options,
+ }
+ )
+ else:
+ try:
+ from mocpy import MOC
+
+ if isinstance(moc, MOC):
+ self.send(
+ {
+ "event_name": "add_MOC_from_dict",
+ "moc_dict": moc.serialize("json"),
+ "options": moc_options,
+ }
+ )
+ except ImportError as imp:
+ raise ValueError(
+ "A MOC can be given as an URL, a dictionnary, or a mocpy.MOC "
+ "object. To read mocpy.MOC objects, you need to install the mocpy "
+ "library with 'pip install mocpy'."
+ ) from imp
+
+ def add_moc_from_URL(
+ self, moc_URL: str, moc_options: Optional[dict] = None
+ ) -> None:
+ """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.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ if moc_options is None:
+ moc_options = {}
+ self.add_moc(moc_URL, **moc_options)
+
+ def add_moc_from_dict(
+ self, moc_dict: dict, moc_options: Optional[dict] = None
+ ) -> None:
+ """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.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ if moc_options is None:
+ moc_options = {}
+ self.add_moc(moc_dict, **moc_options)
+
+ def add_table(self, table: Union[QTable, Table], **table_options: any) -> None:
+ """Load a table into the widget.
+
+ Parameters
+ ----------
+ table : astropy.table.table.QTable or astropy.table.table.Table
+ table that must contain coordinates information
+
+ Examples
+ --------
+ Cell 1:
+ >>> from ipyaladin import Aladin
+ >>> from astropy.table import QTable
+ >>> aladin = Aladin(fov=2, target='M1')
+ >>> aladin
+ Cell 2:
+ >>> ra = [83.63451584700, 83.61368056017, 83.58780251600]
+ >>> dec = [22.05652591227, 21.97517807639, 21.99277764451]
+ >>> name = ["Gaia EDR3 3403818589184411648",
+ "Gaia EDR3 3403817661471500416",
+ "Gaia EDR3 3403817936349408000",
+ ]
+ >>> table = QTable([ra, dec, name],
+ names=("ra", "dec", "name"),
+ meta={"name": "my sample table"})
+ >>> aladin.add_table(table)
+ And the table should appear in the output of Cell 1!
+
+ """
+ import io
+
+ table_bytes = io.BytesIO()
+ table.write(table_bytes, format="votable")
+ self.send(
+ {"event_name": "add_table", "options": table_options},
+ buffers=[table_bytes.getvalue()],
+ )
+
+ def add_overlay_from_stcs(self, stc_string: str, **overlay_options: any) -> None:
+ """Add an overlay layer defined by a STC-S string.
+
+ Parameters
+ ----------
+ stc_string: str
+ The STC-S string.
+ overlay_options: keyword arguments
+
+ """
+ self.send(
+ {
+ "event_name": "add_overlay_from_stcs",
+ "stc_string": stc_string,
+ "overlay_options": overlay_options,
+ }
+ )
+
+ def get_JPEG_thumbnail(self) -> None:
+ """Create a popup window with the current Aladin view."""
+ self.send({"event_name": "get_JPG_thumbnail"})
+
+ def set_color_map(self, color_map_name: str) -> None:
+ """Change the color map of the Aladin Lite widget.
+
+ Parameters
+ ----------
+ color_map_name: str
+ The name of the color map to use.
+
+ """
+ self.send({"event_name": "change_colormap", "colormap": color_map_name})
+
+ def rectangular_selection(self) -> None:
+ """Trigger the rectangular selection tool."""
+ self.send({"event_name": "trigger_rectangular_selection"})
+
+ # Adding a listener
+
+ def set_listener(self, listener_type: str, callback: callable) -> None:
+ """Set a listener for an event to the widget.
+
+ Parameters
+ ----------
+ listener_type: str
+ Can either be 'object_hovered', 'object_clicked', 'click' or 'select'
+ 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"}:
+ self.listener_callback["object_clicked"] = callback
+ elif listener_type == "click":
+ self.listener_callback["click"] = callback
+ elif listener_type == "select":
+ self.listener_callback["select"] = callback
+ else:
+ raise ValueError(
+ "listener_type must be 'object_hovered', "
+ "'object_clicked', 'click' or 'select'"
+ )
+
+ def add_listener(self, listener_type: str, callback: callable) -> None:
+ """Add a listener to the widget. Use set_listener instead.
+
+ Parameters
+ ----------
+ listener_type: str
+ Can either be 'object_hovered', 'object_clicked', 'click' or 'select'
+ callback: callable
+ A python function to be called when the event corresponding to the
+ listener_type is detected
+
+ Note
+ ----
+ This method is deprecated, use set_listener instead
+
+ """
+ warnings.warn(
+ "add_listener is deprecated, use set_listener instead",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ self.set_listener(listener_type, callback)
diff --git a/src/ipyaladin/coordinate_parser.py b/src/ipyaladin/coordinate_parser.py
index 5dd3dc2a..a23e1ce3 100644
--- a/src/ipyaladin/coordinate_parser.py
+++ b/src/ipyaladin/coordinate_parser.py
@@ -1,3 +1,5 @@
+from typing import Tuple
+
from astropy.coordinates import SkyCoord, Angle
import re
@@ -18,7 +20,7 @@ def parse_coordinate_string(string: str) -> SkyCoord:
"""
if not _is_coordinate_string(string):
return SkyCoord.from_name(string)
- coordinates: tuple[str, str] = _split_coordinate_string(string)
+ coordinates: Tuple[str, str] = _split_coordinate_string(string)
# Parse ra and dec to astropy Angle objects
dec: Angle = Angle(coordinates[1], unit="deg")
if _is_hour_angle_string(coordinates[0]):
@@ -51,7 +53,7 @@ def _is_coordinate_string(string: str) -> bool:
return bool(re.match(regex, string))
-def _split_coordinate_string(coo: str) -> tuple[str, str]:
+def _split_coordinate_string(coo: str) -> Tuple[str, str]:
"""Split a string containing coordinates in two parts.
Parameters
diff --git a/src/test/test_aladin.py b/src/test/test_aladin.py
index e7eba792..46edd038 100644
--- a/src/test/test_aladin.py
+++ b/src/test/test_aladin.py
@@ -1,7 +1,8 @@
import pytest
from astropy.coordinates import Angle
-from ipyaladin import Aladin, parse_coordinate_string
+from ipyaladin import Aladin
+from ipyaladin.coordinate_parser import parse_coordinate_string
test_aladin_string_target = [
"M 31",
@@ -34,7 +35,7 @@
@pytest.mark.parametrize("target", test_aladin_string_target)
-def test_aladin_string_target_set(target):
+def test_aladin_string_target_set(target: str) -> None:
"""Test setting the target of an Aladin object with a string or a SkyCoord object.
Parameters
@@ -51,7 +52,7 @@ def test_aladin_string_target_set(target):
@pytest.mark.parametrize("target", test_aladin_string_target)
-def test_aladin_sky_coord_target_set(target):
+def test_aladin_sky_coord_target_set(target: str) -> None:
"""Test setting and getting the target of an Aladin object with a SkyCoord object.
Parameters
@@ -77,7 +78,7 @@ def test_aladin_sky_coord_target_set(target):
@pytest.mark.parametrize("angle", test_aladin_float_fov)
-def test_aladin_float_fov_set(angle):
+def test_aladin_float_fov_set(angle: float) -> None:
"""Test setting the angle of an Aladin object with a float.
Parameters
@@ -92,7 +93,7 @@ def test_aladin_float_fov_set(angle):
@pytest.mark.parametrize("angle", test_aladin_float_fov)
-def test_aladin_angle_fov_set(angle):
+def test_aladin_angle_fov_set(angle: float) -> None:
"""Test setting the angle of an Aladin object with an Angle object.
Parameters
diff --git a/src/test/test_coordinate_parser.py b/src/test/test_coordinate_parser.py
index e0cd3ff6..b399bc4a 100644
--- a/src/test/test_coordinate_parser.py
+++ b/src/test/test_coordinate_parser.py
@@ -1,3 +1,4 @@
+from typing import Tuple
from ipyaladin.coordinate_parser import (
parse_coordinate_string,
_split_coordinate_string,
@@ -32,7 +33,7 @@
@pytest.mark.parametrize(("inp", "expected"), test_is_coordinate_string_values)
-def test_is_coordinate_string(inp, expected):
+def test_is_coordinate_string(inp: str, expected: bool) -> None:
"""Test the function _is_coordinate_string.
Parameters
@@ -67,7 +68,7 @@ def test_is_coordinate_string(inp, expected):
@pytest.mark.parametrize(("inp", "expected"), test_split_coordinate_string_values)
-def test_split_coordinate_string(inp, expected):
+def test_split_coordinate_string(inp: str, expected: Tuple[str, str]) -> None:
"""Test the function _split_coordinate_string.
Parameters
@@ -91,7 +92,7 @@ def test_split_coordinate_string(inp, expected):
@pytest.mark.parametrize(("inp", "expected"), test_is_hour_angle_string_values)
-def test_is_hour_angle_string(inp, expected):
+def test_is_hour_angle_string(inp: str, expected: bool) -> None:
"""Test the function _is_hour_angle_string.
Parameters
@@ -175,7 +176,7 @@ def test_is_hour_angle_string(inp, expected):
@pytest.mark.parametrize(("inp", "expected"), test_parse_coordinate_string_values)
-def test_parse_coordinate_string(inp, expected):
+def test_parse_coordinate_string(inp: str, expected: SkyCoord) -> None:
"""Test the function parse_coordinate_string.
Parameters