From 5ac2fd7fe3b8da7a4ccd8af3adf529c1c46876f3 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 23 Apr 2024 07:15:16 +0000 Subject: [PATCH 01/31] :art: Improve add_table data transmission without private traitlets --- js/widget.js | 5 ++--- src/ipyaladin/__init__.py | 11 ++++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/js/widget.js b/js/widget.js index 3d997108..ff0e17e2 100644 --- a/js/widget.js +++ b/js/widget.js @@ -191,7 +191,7 @@ function render({ model, el }) { aladin.getOverlayImageLayer().setAlpha(model.get("overlay_survey_opacity")); }); - model.on("msg:custom", (msg) => { + model.on("msg:custom", (msg, buffers) => { let options = {}; switch (msg["event_name"]) { case "change_fov": @@ -236,7 +236,7 @@ function render({ model, el }) { aladin.select(); break; case "add_table": - let table_bytes = model.get("_table"); + let table_bytes = buffers[0].buffer; let decoder = new TextDecoder("utf-8"); let blob = new Blob([decoder.decode(table_bytes)]); let url = URL.createObjectURL(blob); @@ -263,7 +263,6 @@ function render({ model, el }) { 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"); diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 96a1133b..3b0d610d 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -13,9 +13,7 @@ List, Dict, Any, - Bytes, default, - Undefined, ) from .coordinate_parser import parse_coordinate_string @@ -97,9 +95,6 @@ class Aladin(anywidget.AnyWidget): 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") @@ -333,8 +328,10 @@ def add_table(self, table, **table_options): table_bytes = io.BytesIO() table.write(table_bytes, format="votable") - self._table = table_bytes.getvalue() - self.send({"event_name": "add_table", "options": table_options}) + self.send( + {"event_name": "add_table", "options": table_options}, + buffers=[table_bytes.getvalue()], + ) def add_overlay_from_stcs(self, stc_string, **overlay_options): """Add an overlay layer defined by a STC-S string. From feb9153a165291bbc5dbd6cae7deb9ae458d565b Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 23 Apr 2024 11:58:52 +0200 Subject: [PATCH 02/31] :heavy_plus_sign: Add mocpy as a recommended dependency --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2359e992..e3093395 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" [project.optional-dependencies] -dev = ["watchfiles", "jupyterlab", "ruff"] +test = ["pytest"] +dev = ["ipyaladin[test]", "watchfiles", "jupyterlab", "ruff"] +recommended = ["mocpy"] # automatically add the dev feature to the default env (e.g., hatch shell) [tool.hatch.envs.default] From 3361e61788c037c97c471e9c5024453736b3e60b Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 23 Apr 2024 12:09:49 +0200 Subject: [PATCH 03/31] :art: Unify js variable and function naming to camelCase --- js/widget.js | 77 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/js/widget.js b/js/widget.js index ff0e17e2..c45d1d36 100644 --- a/js/widget.js +++ b/js/widget.js @@ -3,7 +3,7 @@ import "./widget.css"; let idxView = 0; -function convert_pyname_to_jsname(pyname) { +function camelCaseToSnakeCase(pyname) { if (pyname.charAt(0) === "_") pyname = pyname.slice(1); let temp = pyname.split("_"); for (let i = 1; i < temp.length; i++) { @@ -21,22 +21,21 @@ function render({ model, el }) { /* View -------------- */ /* ------------------- */ - let init_options = {}; + let initOptions = {}; model.get("init_options").forEach((option_name) => { - init_options[convert_pyname_to_jsname(option_name)] = - model.get(option_name); + initOptions[camelCaseToSnakeCase(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]); + const raDec = initOptions["target"].split(" "); + aladin.gotoRaDec(raDec[0], raDec[1]); el.appendChild(aladinDiv); @@ -52,44 +51,44 @@ function render({ model, el }) { // is also necessary for the field of view. /* Target control */ - let target_js = false; - let target_py = false; + let targetJs = false; + let targetPy = false; // Event triggered when the user moves the map in Aladin Lite aladin.on("positionChanged", () => { - if (target_py) { - target_py = false; + if (targetPy) { + targetPy = 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]}`); + targetJs = true; + const raDec = aladin.getRaDec(); + model.set("_target", `${raDec[0]} ${raDec[1]}`); + model.set("shared_target", `${raDec[0]} ${raDec[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; + if (targetJs) { + targetJs = false; return; } - target_py = true; + targetPy = 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; + let fovJs = false; + let fovPy = false; aladin.on("zoomChanged", (fov) => { - if (fov_py) { - fov_py = false; + if (fovPy) { + fovPy = false; return; } - fov_js = true; + fovJs = 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))); @@ -97,11 +96,11 @@ function render({ model, el }) { }); model.on("change:shared_fov", () => { - if (fov_js) { - fov_js = false; + if (fovJs) { + fovJs = false; return; } - fov_py = true; + fovPy = true; let fov = model.get("shared_fov"); aladin.setFoV(fov); }); @@ -116,7 +115,7 @@ function render({ model, el }) { /* Aladin callbacks */ aladin.on("objectHovered", (object) => { - if (object["data"] != undefined) { + if (object["data"] !== undefined) { model.send({ event_type: "object_hovered", content: { @@ -128,37 +127,37 @@ function render({ model, el }) { }); aladin.on("objectClicked", (clicked) => { - let clicked_content = { + let clickedContent = { ra: clicked["ra"], dec: clicked["dec"], }; if (clicked["data"] !== undefined) { - clicked_content["data"] = clicked["data"]; + clickedContent["data"] = clicked["data"]; } - model.set("clicked", clicked_content); + model.set("clicked", clickedContent); // send a custom message in case the user wants to define their own callbacks model.send({ event_type: "object_clicked", - content: clicked_content, + content: clickedContent, }); model.save_changes(); }); - aladin.on("click", (click_content) => { + aladin.on("click", (clickContent) => { model.send({ event_type: "click", - content: click_content, + content: clickContent, }); }); aladin.on("select", (catalogs) => { - let objects_data = []; + 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) => { - objects_data.push({ + objectsData.push({ ra: object.ra, dec: object.dec, data: object.data, @@ -169,7 +168,7 @@ function render({ model, el }) { }); model.send({ event_type: "select", - content: objects_data, + content: objectsData, }); }); @@ -236,9 +235,9 @@ function render({ model, el }) { aladin.select(); break; case "add_table": - let table_bytes = buffers[0].buffer; + let tableBytes = buffers[0].buffer; let decoder = new TextDecoder("utf-8"); - let blob = new Blob([decoder.decode(table_bytes)]); + let blob = new Blob([decoder.decode(tableBytes)]); let url = URL.createObjectURL(blob); A.catalogFromURL( url, From 7d7cfee9005eaeda609b9424e676241c5703485f Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 23 Apr 2024 16:04:20 +0200 Subject: [PATCH 04/31] :construction: First iteration of the js refactoring --- js/models/event_handler.js | 232 ++++++++++++++++++++++++++++++++ js/models/message_handler.js | 66 ++++++++++ js/widget.js | 248 ++--------------------------------- 3 files changed, 312 insertions(+), 234 deletions(-) create mode 100644 js/models/event_handler.js create mode 100644 js/models/message_handler.js diff --git a/js/models/event_handler.js b/js/models/event_handler.js new file mode 100644 index 00000000..652203ae --- /dev/null +++ b/js/models/event_handler.js @@ -0,0 +1,232 @@ +import MessageHandler from "./message_handler"; + +export default class EventHandler { + constructor(A, aladin, model) { + this.A = A; + this.aladin = aladin; + this.model = model; + this.messageHandler = new MessageHandler(A, aladin); + } + + 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 */ + let targetJs = false; + let targetPy = false; + + // Event triggered when the user moves the map in Aladin Lite + this.aladin.on("positionChanged", () => { + if (targetPy) { + targetPy = false; + return; + } + targetJs = true; + const raDec = this.aladin.getRaDec(); + this.model.set("_target", `${raDec[0]} ${raDec[1]}`); + this.model.set("shared_target", `${raDec[0]} ${raDec[1]}`); + this.model.save_changes(); + }); + + // Event triggered when the target is changed from the Python side using jslink + this.model.on("change:shared_target", () => { + if (targetJs) { + targetJs = false; + return; + } + targetPy = true; + const target = this.model.get("shared_target"); + const [ra, dec] = target.split(" "); + this.aladin.gotoRaDec(ra, dec); + }); + + /* Field of View control */ + let fovJs = false; + let fovPy = false; + + this.aladin.on("zoomChanged", (fov) => { + if (fovPy) { + fovPy = false; + return; + } + fovJs = true; + // fov MUST be cast into float in order to be sent to the model + this.model.set("_fov", parseFloat(fov.toFixed(5))); + this.model.set("shared_fov", parseFloat(fov.toFixed(5))); + this.model.save_changes(); + }); + + this.model.on("change:shared_fov", () => { + if (fovJs) { + fovJs = false; + return; + } + fovPy = true; + let fov = this.model.get("shared_fov"); + this.aladin.setFoV(fov); + }); + + /* Div control */ + + this.model.on("change:height", () => { + let height = this.model.get("height"); + 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.model.on("msg:custom", (msg, buffers) => { + let options = {}; + switch (msg["event_name"]) { + case "change_fov": + this.messageHandler.handleChangeFoV(msg["fov"]); + break; + case "goto_ra_dec": + this.messageHandler.handleGotoRaDec(msg["ra"], msg["dec"]); + break; + case "add_catalog_from_URL": + this.messageHandler.handleAddCatalogFromURL( + msg["votable_URL"], + msg["options"], + ); + break; + case "add_MOC_from_URL": + this.messageHandler.handleAddMOCFromURL( + msg["moc_URL"], + msg["options"], + ); + break; + case "add_MOC_from_dict": + this.messageHandler.handleAddMOCFromDict( + msg["moc_dict"], + msg["options"], + ); + break; + case "add_overlay_from_stcs": + this.messageHandler.handleAddOverlayFromSTCS( + msg["overlay_options"], + msg["stc_string"], + ); + break; + case "change_colormap": + this.messageHandler.handleChangeColormap(msg["colormap"]); + break; + case "get_JPG_thumbnail": + this.messageHandler.handleGetJPGThumbnail(); + break; + case "trigger_rectangular_selection": + this.messageHandler.handleTriggerRectangularSelection(); + break; + case "add_table": + this.messageHandler.handleAddTable(buffers[0].buffer, msg.options); + break; + } + }); + } + + unsubscribeAll() { + this.model.off("change:shared_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"); + + this.aladin.off("positionChanged"); + this.aladin.off("zoomChanged"); + this.aladin.off("objectHovered"); + this.aladin.off("objectClicked"); + this.aladin.off("click"); + this.aladin.off("select"); + } +} diff --git a/js/models/message_handler.js b/js/models/message_handler.js new file mode 100644 index 00000000..3397220f --- /dev/null +++ b/js/models/message_handler.js @@ -0,0 +1,66 @@ +export default class MessageHandler { + constructor(A, aladin) { + this.A = A; + this.aladin = aladin; + } + + handleChangeFoV(fov) { + this.aladin.setFoV(fov); + } + + handleGotoRaDec(ra, dec) { + this.aladin.gotoRaDec(ra, dec); + } + + handleAddCatalogFromURL(votableURL, options) { + this.aladin.addCatalog(this.A.catalogFromURL(votableURL, options)); + } + + handleAddMOCFromURL(mocURL, options) { + if (options["lineWidth"] === undefined) { + options["lineWidth"] = 3; + } + this.aladin.addMOC(this.A.MOCFromURL(mocURL, options)); + } + + handleAddMOCFromDict(mocDict, options) { + if (options["lineWidth"] === undefined) { + options["lineWidth"] = 3; + } + this.aladin.addMOC(this.A.MOCFromJSON(mocDict, options)); + } + + handleAddOverlayFromSTCS(overlayOptions, stcString) { + let overlay = this.A.graphicOverlay(overlayOptions); + this.aladin.addOverlay(overlay); + overlay.addFootprints(this.A.footprintsFromSTCS(stcString)); + } + + handleChangeColormap(colormap) { + this.aladin.getBaseImageLayer().setColormap(colormap); + } + + handleGetJPGThumbnail() { + this.aladin.exportAsPNG(); + } + + handleTriggerRectangularSelection() { + this.aladin.select(); + } + + handleAddTable(buffer, options) { + let tableBytes = buffer; + let decoder = new TextDecoder("utf-8"); + let blob = new Blob([decoder.decode(tableBytes)]); + let url = URL.createObjectURL(blob); + this.A.catalogFromURL( + url, + Object.assign(options, { onClick: "showTable" }), + (catalog) => { + this.aladin.addCatalog(catalog); + }, + false, + ); + URL.revokeObjectURL(url); + } +} diff --git a/js/widget.js b/js/widget.js index c45d1d36..62e8023c 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,5 +1,6 @@ import A from "https://esm.sh/aladin-lite@3.4.0-beta"; import "./widget.css"; +import EventHandler from "./models/event_handler"; let idxView = 0; @@ -12,15 +13,7 @@ function camelCaseToSnakeCase(pyname) { return temp.join(""); } -async function initialize({ model }) { - await A.init; -} - -function render({ model, el }) { - /* ------------------- */ - /* View -------------- */ - /* ------------------- */ - +function initAladinLite(model, el) { let initOptions = {}; model.get("init_options").forEach((option_name) => { initOptions[camelCaseToSnakeCase(option_name)] = model.get(option_name); @@ -38,238 +31,25 @@ function render({ model, el }) { aladin.gotoRaDec(raDec[0], raDec[1]); el.appendChild(aladinDiv); + return aladin; +} +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 targetJs = false; - let targetPy = false; - - // Event triggered when the user moves the map in Aladin Lite - aladin.on("positionChanged", () => { - if (targetPy) { - targetPy = false; - return; - } - targetJs = true; - const raDec = aladin.getRaDec(); - model.set("_target", `${raDec[0]} ${raDec[1]}`); - model.set("shared_target", `${raDec[0]} ${raDec[1]}`); - model.save_changes(); - }); - - // Event triggered when the target is changed from the Python side using jslink - model.on("change:shared_target", () => { - if (targetJs) { - targetJs = false; - return; - } - targetPy = true; - const target = model.get("shared_target"); - const [ra, dec] = target.split(" "); - aladin.gotoRaDec(ra, dec); - }); - - /* Field of View control */ - let fovJs = false; - let fovPy = false; - - aladin.on("zoomChanged", (fov) => { - if (fovPy) { - fovPy = false; - return; - } - fovJs = 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 (fovJs) { - fovJs = false; - return; - } - fovPy = 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 clickedContent = { - ra: clicked["ra"], - dec: clicked["dec"], - }; - if (clicked["data"] !== undefined) { - clickedContent["data"] = clicked["data"]; - } - model.set("clicked", clickedContent); - // send a custom message in case the user wants to define their own callbacks - model.send({ - event_type: "object_clicked", - content: clickedContent, - }); - model.save_changes(); - }); - - aladin.on("click", (clickContent) => { - model.send({ - event_type: "click", - content: clickContent, - }); - }); - - 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, - }); - }); - }); - model.send({ - event_type: "select", - content: objectsData, - }); - }); - - /* 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, buffers) => { - 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 tableBytes = buffers[0].buffer; - let decoder = new TextDecoder("utf-8"); - let blob = new Blob([decoder.decode(tableBytes)]); - let url = URL.createObjectURL(blob); - A.catalogFromURL( - url, - Object.assign(msg.options, { onClick: "showTable" }), - (catalog) => { - aladin.addCatalog(catalog); - }, - false, - ); - URL.revokeObjectURL(url); - break; - } - }); + const aladin = initAladinLite(model, el); + const eventHandler = new EventHandler(A, aladin, 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("msg:custom"); - - aladin.off("positionChanged"); - aladin.off("zoomChanged"); - aladin.off("objectHovered"); - aladin.off("objectClicked"); - aladin.off("click"); - aladin.off("select"); + eventHandler.unsubscribeAll(); }; } From 16152618d3d3de8d736b87a2643a59eec14c9499 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Wed, 24 Apr 2024 09:05:53 +0200 Subject: [PATCH 05/31] :art: Replace custom message switch by a dictionary --- js/models/event_handler.js | 64 ++++++++++-------------------------- js/models/message_handler.js | 45 ++++++++++++++----------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 652203ae..3465d7dc 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -76,7 +76,6 @@ export default class EventHandler { }); /* Div control */ - this.model.on("change:height", () => { let height = this.model.get("height"); aladinDiv.style.height = `${height}px`; @@ -162,52 +161,25 @@ export default class EventHandler { .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) => { - let options = {}; - switch (msg["event_name"]) { - case "change_fov": - this.messageHandler.handleChangeFoV(msg["fov"]); - break; - case "goto_ra_dec": - this.messageHandler.handleGotoRaDec(msg["ra"], msg["dec"]); - break; - case "add_catalog_from_URL": - this.messageHandler.handleAddCatalogFromURL( - msg["votable_URL"], - msg["options"], - ); - break; - case "add_MOC_from_URL": - this.messageHandler.handleAddMOCFromURL( - msg["moc_URL"], - msg["options"], - ); - break; - case "add_MOC_from_dict": - this.messageHandler.handleAddMOCFromDict( - msg["moc_dict"], - msg["options"], - ); - break; - case "add_overlay_from_stcs": - this.messageHandler.handleAddOverlayFromSTCS( - msg["overlay_options"], - msg["stc_string"], - ); - break; - case "change_colormap": - this.messageHandler.handleChangeColormap(msg["colormap"]); - break; - case "get_JPG_thumbnail": - this.messageHandler.handleGetJPGThumbnail(); - break; - case "trigger_rectangular_selection": - this.messageHandler.handleTriggerRectangularSelection(); - break; - case "add_table": - this.messageHandler.handleAddTable(buffers[0].buffer, msg.options); - break; - } + 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}`); }); } diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 3397220f..7aa32bd9 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -4,40 +4,46 @@ export default class MessageHandler { this.aladin = aladin; } - handleChangeFoV(fov) { - this.aladin.setFoV(fov); + handleChangeFoV(msg) { + this.aladin.setFoV(msg["fov"]); } - handleGotoRaDec(ra, dec) { - this.aladin.gotoRaDec(ra, dec); + handleGotoRaDec(msg) { + this.aladin.gotoRaDec(msg["ra"], msg["dec"]); } - handleAddCatalogFromURL(votableURL, options) { - this.aladin.addCatalog(this.A.catalogFromURL(votableURL, options)); + handleAddCatalogFromURL(msg) { + this.aladin.addCatalog( + this.A.catalogFromURL(msg["votable_URL"], msg["options"]), + ); } - handleAddMOCFromURL(mocURL, options) { + handleAddMOCFromURL(msg) { + const options = msg["options"] || {}; if (options["lineWidth"] === undefined) { options["lineWidth"] = 3; } - this.aladin.addMOC(this.A.MOCFromURL(mocURL, options)); + this.aladin.addMOC(this.A.MOCFromURL(msg["moc_URL"], options)); } - handleAddMOCFromDict(mocDict, options) { + handleAddMOCFromDict(msg) { + const options = msg["options"] || {}; if (options["lineWidth"] === undefined) { options["lineWidth"] = 3; } - this.aladin.addMOC(this.A.MOCFromJSON(mocDict, options)); + this.aladin.addMOC(this.A.MOCFromJSON(msg["moc_dict"], options)); } - handleAddOverlayFromSTCS(overlayOptions, stcString) { - let overlay = this.A.graphicOverlay(overlayOptions); + handleAddOverlayFromSTCS(msg) { + const overlayOptions = msg["overlay_options"]; + const stcString = msg["stc_string"]; + const overlay = this.A.graphicOverlay(overlayOptions); this.aladin.addOverlay(overlay); overlay.addFootprints(this.A.footprintsFromSTCS(stcString)); } - handleChangeColormap(colormap) { - this.aladin.getBaseImageLayer().setColormap(colormap); + handleChangeColormap(msg) { + this.aladin.getBaseImageLayer().setColormap(msg["colormap"]); } handleGetJPGThumbnail() { @@ -48,11 +54,12 @@ export default class MessageHandler { this.aladin.select(); } - handleAddTable(buffer, options) { - let tableBytes = buffer; - let decoder = new TextDecoder("utf-8"); - let blob = new Blob([decoder.decode(tableBytes)]); - let url = URL.createObjectURL(blob); + handleAddTable(msg, buffers) { + const options = 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); this.A.catalogFromURL( url, Object.assign(options, { onClick: "showTable" }), From fd3d09b2f4e6afbc6cdc8abd4e75004236fd14ef Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Wed, 24 Apr 2024 14:33:08 +0200 Subject: [PATCH 06/31] :bug: Fix missing aladinDiv argument to EventHandler constructor --- js/models/event_handler.js | 5 +++-- js/widget.js | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 3465d7dc..bdc14d97 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,9 +1,10 @@ import MessageHandler from "./message_handler"; export default class EventHandler { - constructor(A, aladin, model) { + constructor(A, aladin, aladinDiv, model) { this.A = A; this.aladin = aladin; + this.aladinDiv = aladinDiv; this.model = model; this.messageHandler = new MessageHandler(A, aladin); } @@ -78,7 +79,7 @@ export default class EventHandler { /* Div control */ this.model.on("change:height", () => { let height = this.model.get("height"); - aladinDiv.style.height = `${height}px`; + this.aladinDiv.style.height = `${height}px`; }); /* Aladin callbacks */ diff --git a/js/widget.js b/js/widget.js index 62e8023c..ee1ce019 100644 --- a/js/widget.js +++ b/js/widget.js @@ -31,7 +31,7 @@ function initAladinLite(model, el) { aladin.gotoRaDec(raDec[0], raDec[1]); el.appendChild(aladinDiv); - return aladin; + return { aladin, aladinDiv }; } async function initialize({ model }) { @@ -43,8 +43,9 @@ function render({ model, el }) { /* View -------------- */ /* ------------------- */ - const aladin = initAladinLite(model, el); - const eventHandler = new EventHandler(A, aladin, model); + const { aladin, aladinDiv } = initAladinLite(model, el); + + const eventHandler = new EventHandler(A, aladin, aladinDiv, model); eventHandler.subscribeAll(); return () => { From 51bf3b3840d56b1ad8951c0fb4c1773ec2e81c79 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Wed, 24 Apr 2024 14:39:40 +0200 Subject: [PATCH 07/31] :fire: Remove Aladin#off because this function does not exist in the Aladin API --- js/models/event_handler.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/js/models/event_handler.js b/js/models/event_handler.js index bdc14d97..21cf5c0a 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -194,12 +194,5 @@ export default class EventHandler { this.model.off("change:overlay_survey_opacity"); this.model.off("change:trigger_event"); this.model.off("msg:custom"); - - this.aladin.off("positionChanged"); - this.aladin.off("zoomChanged"); - this.aladin.off("objectHovered"); - this.aladin.off("objectClicked"); - this.aladin.off("click"); - this.aladin.off("select"); } } From 7c52056e8d83a5134418eeec0f77edc584510241 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Wed, 24 Apr 2024 15:50:19 +0200 Subject: [PATCH 08/31] :sparkles: Add support for snake_case and camelCase options for add_table, add_moc and add_overlay_from_stcs --- js/models/message_handler.js | 33 ++++++++++++++++++++------------- js/utils.js | 8 ++++++++ js/widget.js | 10 +--------- 3 files changed, 29 insertions(+), 22 deletions(-) create mode 100644 js/utils.js diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 7aa32bd9..09ee38fc 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,3 +1,5 @@ +import { camelCaseToSnakeCase } from "../utils"; + export default class MessageHandler { constructor(A, aladin) { this.A = A; @@ -13,29 +15,26 @@ export default class MessageHandler { } handleAddCatalogFromURL(msg) { - this.aladin.addCatalog( - this.A.catalogFromURL(msg["votable_URL"], msg["options"]), - ); + const options = MessageHandler.parseOptions(msg["options"] || {}); + this.aladin.addCatalog(this.A.catalogFromURL(msg["votable_URL"], options)); } handleAddMOCFromURL(msg) { - const options = msg["options"] || {}; - if (options["lineWidth"] === undefined) { - options["lineWidth"] = 3; - } + const options = MessageHandler.parseOptions(msg["options"] || {}); + if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(this.A.MOCFromURL(msg["moc_URL"], options)); } handleAddMOCFromDict(msg) { - const options = msg["options"] || {}; - if (options["lineWidth"] === undefined) { - options["lineWidth"] = 3; - } + const options = MessageHandler.parseOptions(msg["options"] || {}); + if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(this.A.MOCFromJSON(msg["moc_dict"], options)); } handleAddOverlayFromSTCS(msg) { - const overlayOptions = msg["overlay_options"]; + const overlayOptions = MessageHandler.parseOptions( + msg["overlay_options"] || {}, + ); const stcString = msg["stc_string"]; const overlay = this.A.graphicOverlay(overlayOptions); this.aladin.addOverlay(overlay); @@ -55,7 +54,7 @@ export default class MessageHandler { } handleAddTable(msg, buffers) { - const options = msg["options"] || {}; + const options = MessageHandler.parseOptions(msg["options"] || {}); const buffer = buffers[0].buffer; const decoder = new TextDecoder("utf-8"); const blob = new Blob([decoder.decode(buffer)]); @@ -70,4 +69,12 @@ export default class MessageHandler { ); URL.revokeObjectURL(url); } + + static parseOptions(options) { + for (const optionName in options) { + const convertedOptionName = camelCaseToSnakeCase(optionName); + options[convertedOptionName] = options[optionName]; + } + return options; + } } diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 00000000..16f42a2b --- /dev/null +++ b/js/utils.js @@ -0,0 +1,8 @@ +export function camelCaseToSnakeCase(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(""); +} diff --git a/js/widget.js b/js/widget.js index ee1ce019..d66f08c7 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,18 +1,10 @@ import A from "https://esm.sh/aladin-lite@3.4.0-beta"; import "./widget.css"; import EventHandler from "./models/event_handler"; +import { camelCaseToSnakeCase } from "./utils"; let idxView = 0; -function camelCaseToSnakeCase(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(""); -} - function initAladinLite(model, el) { let initOptions = {}; model.get("init_options").forEach((option_name) => { From f8d9433cd1f4d7ebef4cc9650e01175c4fbc7680 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Wed, 24 Apr 2024 15:53:35 +0200 Subject: [PATCH 09/31] :memo: Update examples for new snakecase support and set_table change --- examples/3_Functions.ipynb | 8 ++++---- examples/4_Importing_Tables.ipynb | 3 ++- examples/7_on-click-callback.ipynb | 2 +- examples/8_Rectangular-selection.ipynb | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/3_Functions.ipynb b/examples/3_Functions.ipynb index cb1f3ae3..63b874c3 100644 --- a/examples/3_Functions.ipynb +++ b/examples/3_Functions.ipynb @@ -79,12 +79,12 @@ "metadata": {}, "outputs": [], "source": [ - "def getObjectData(data):\n", + "def get_object_data(data):\n", " print(\"It clicked.\")\n", " return data\n", "\n", "\n", - "def getObjectRaDecProduct(data):\n", + "def get_object_ra_dec_product(data):\n", " return data[\"ra\"] * data[\"dec\"]\n", "\n", "\n", @@ -92,8 +92,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..1621bcab 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\", sourceSize=20)\n", + "# This line also works with snake_case instead of camelCase: source_size=20" ] }, { diff --git a/examples/7_on-click-callback.ipynb b/examples/7_on-click-callback.ipynb index 90b1af0e..89e4f31e 100644 --- a/examples/7_on-click-callback.ipynb +++ b/examples/7_on-click-callback.ipynb @@ -78,7 +78,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..8e432fa7 100644 --- a/examples/8_Rectangular-selection.ipynb +++ b/examples/8_Rectangular-selection.ipynb @@ -74,7 +74,7 @@ " table_info.value = s\n", "\n", "\n", - "aladin.add_listener(\"select\", process_result)" + "aladin.set_listener(\"select\", process_result)" ] } ], From 0efeff27ee0349f72c498009af952de2a8c0d34c Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 25 Apr 2024 10:08:04 +0200 Subject: [PATCH 10/31] :art: Change add_listener to set_listener for better user understanding of the function behavior Deprecate add_listener and add a warning from a "private" parameter --- src/ipyaladin/__init__.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 3b0d610d..ba63ecb3 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -366,18 +366,42 @@ def rectangular_selection(self): # Adding a listener - def add_listener(self, listener_type, callback): - """Add a listener to the widget. + def set_listener(self, listener_type, callback): + """Set a listener for an event to the widget. Parameters ---------- listener_type: str - Can either be 'objectHovered' or 'objClicked' + 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 """ + self.add_listener(listener_type, callback, False) + + def add_listener(self, listener_type, callback, _dWarning=True): + """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 + + """ + if _dWarning: + warnings.warn( + "add_listener is deprecated, use set_listener instead", + DeprecationWarning, + stacklevel=2, + ) if listener_type in {"objectHovered", "object_hovered"}: self.listener_callback["object_hovered"] = callback elif listener_type in {"objectClicked", "object_clicked"}: @@ -386,3 +410,8 @@ def add_listener(self, listener_type, callback): 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'" + ) From e33f5e5b17b65426a75d5568a90a6d7a75ff6c22 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 25 Apr 2024 10:13:32 +0200 Subject: [PATCH 11/31] :memo: Add comment line for initialization gotoRaDec function call --- js/widget.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/widget.js b/js/widget.js index d66f08c7..d43b297e 100644 --- a/js/widget.js +++ b/js/widget.js @@ -19,6 +19,7 @@ function initAladinLite(model, el) { let aladin = new A.aladin(aladinDiv, initOptions); idxView += 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]); From 94659a3b81efc55209a11f28991a0fa4f3d377a7 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 25 Apr 2024 15:32:25 +0200 Subject: [PATCH 12/31] :art: Make A only imported once and not given in objects constructor --- js/models/event_handler.js | 5 ++--- js/models/message_handler.js | 17 ++++++++--------- js/utils.js | 6 +++++- js/widget.js | 5 ++--- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 21cf5c0a..1d28d879 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,12 +1,11 @@ import MessageHandler from "./message_handler"; export default class EventHandler { - constructor(A, aladin, aladinDiv, model) { - this.A = A; + constructor(aladin, aladinDiv, model) { this.aladin = aladin; this.aladinDiv = aladinDiv; this.model = model; - this.messageHandler = new MessageHandler(A, aladin); + this.messageHandler = new MessageHandler(aladin); } subscribeAll() { diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 09ee38fc..fe0d251a 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,8 +1,7 @@ -import { camelCaseToSnakeCase } from "../utils"; +import { A, camelCaseToSnakeCase } from "../utils"; export default class MessageHandler { - constructor(A, aladin) { - this.A = A; + constructor(aladin) { this.aladin = aladin; } @@ -16,19 +15,19 @@ export default class MessageHandler { handleAddCatalogFromURL(msg) { const options = MessageHandler.parseOptions(msg["options"] || {}); - this.aladin.addCatalog(this.A.catalogFromURL(msg["votable_URL"], options)); + this.aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], options)); } handleAddMOCFromURL(msg) { const options = MessageHandler.parseOptions(msg["options"] || {}); if (options["lineWidth"] === undefined) options["lineWidth"] = 3; - this.aladin.addMOC(this.A.MOCFromURL(msg["moc_URL"], options)); + this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); } handleAddMOCFromDict(msg) { const options = MessageHandler.parseOptions(msg["options"] || {}); if (options["lineWidth"] === undefined) options["lineWidth"] = 3; - this.aladin.addMOC(this.A.MOCFromJSON(msg["moc_dict"], options)); + this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); } handleAddOverlayFromSTCS(msg) { @@ -36,9 +35,9 @@ export default class MessageHandler { msg["overlay_options"] || {}, ); const stcString = msg["stc_string"]; - const overlay = this.A.graphicOverlay(overlayOptions); + const overlay = A.graphicOverlay(overlayOptions); this.aladin.addOverlay(overlay); - overlay.addFootprints(this.A.footprintsFromSTCS(stcString)); + overlay.addFootprints(A.footprintsFromSTCS(stcString)); } handleChangeColormap(msg) { @@ -59,7 +58,7 @@ export default class MessageHandler { const decoder = new TextDecoder("utf-8"); const blob = new Blob([decoder.decode(buffer)]); const url = URL.createObjectURL(blob); - this.A.catalogFromURL( + A.catalogFromURL( url, Object.assign(options, { onClick: "showTable" }), (catalog) => { diff --git a/js/utils.js b/js/utils.js index 16f42a2b..3be0f18c 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,4 +1,6 @@ -export function camelCaseToSnakeCase(pyname) { +import A from "https://esm.sh/aladin-lite@3.3.3-beta"; + +function camelCaseToSnakeCase(pyname) { if (pyname.charAt(0) === "_") pyname = pyname.slice(1); let temp = pyname.split("_"); for (let i = 1; i < temp.length; i++) { @@ -6,3 +8,5 @@ export function camelCaseToSnakeCase(pyname) { } return temp.join(""); } + +export { camelCaseToSnakeCase, A }; diff --git a/js/widget.js b/js/widget.js index d43b297e..40741a4f 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,7 +1,6 @@ -import A from "https://esm.sh/aladin-lite@3.4.0-beta"; import "./widget.css"; import EventHandler from "./models/event_handler"; -import { camelCaseToSnakeCase } from "./utils"; +import { A, camelCaseToSnakeCase } from "./utils"; let idxView = 0; @@ -38,7 +37,7 @@ function render({ model, el }) { const { aladin, aladinDiv } = initAladinLite(model, el); - const eventHandler = new EventHandler(A, aladin, aladinDiv, model); + const eventHandler = new EventHandler(aladin, aladinDiv, model); eventHandler.subscribeAll(); return () => { From 64383d62ebd94b90928723de7c8e595ac921fb97 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Fri, 26 Apr 2024 09:51:19 +0200 Subject: [PATCH 13/31] :art: Add typing check to python and add minor missing documentation --- src/ipyaladin/__init__.py | 45 ++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index ba63ecb3..2af4ba31 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,9 +1,11 @@ import importlib.metadata import pathlib -from typing import ClassVar, Union +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 from traitlets import ( Float, @@ -25,8 +27,8 @@ class Aladin(anywidget.AnyWidget): - _esm = pathlib.Path(__file__).parent / "static" / "widget.js" - _css = pathlib.Path(__file__).parent / "static" / "widget.css" + _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) @@ -89,7 +91,7 @@ class Aladin(anywidget.AnyWidget): # 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 = {} + listener_callback: ClassVar[dict[str, callable]] = {} # overlay survey overlay_survey = Unicode("").tag(sync=True, init_option=True) @@ -107,7 +109,7 @@ def __init__(self, *args, **kwargs): 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 + def _handle_custom_message(self, model, message: dict, buffers: list): # noqa: ARG002 event_type = message["event_type"] message_content = message["content"] if ( @@ -184,7 +186,7 @@ def target(self, target: Union[str, SkyCoord]): } ) - def add_catalog_from_URL(self, votable_URL, votable_options=None): + def add_catalog_from_URL(self, votable_URL: str, votable_options: Dict = None): """Load a VOTable table from an url and load its data into the widget. Parameters @@ -252,7 +254,7 @@ def add_moc(self, moc, **moc_options): "library with 'pip install mocpy'." ) from imp - def add_moc_from_URL(self, moc_URL, moc_options=None): + def add_moc_from_URL(self, moc_URL: str, moc_options: Optional[dict] = None): """Load a MOC from a URL and display it in Aladin Lite widget. Parameters @@ -272,7 +274,7 @@ def add_moc_from_URL(self, moc_URL, moc_options=None): moc_options = {} self.add_moc(moc_URL, **moc_options) - def add_moc_from_dict(self, moc_dict, moc_options=None): + def add_moc_from_dict(self, moc_dict: dict, moc_options: Optional[dict] = None): """Load a MOC from a dict object and display it in Aladin Lite widget. Parameters @@ -293,7 +295,7 @@ def add_moc_from_dict(self, moc_dict, moc_options=None): moc_options = {} self.add_moc(moc_dict, **moc_options) - def add_table(self, table, **table_options): + def add_table(self, table: Union[QTable, Table], **table_options): """Load a table into the widget. Parameters @@ -333,7 +335,7 @@ def add_table(self, table, **table_options): buffers=[table_bytes.getvalue()], ) - def add_overlay_from_stcs(self, stc_string, **overlay_options): + def add_overlay_from_stcs(self, stc_string: str, **overlay_options): """Add an overlay layer defined by a STC-S string. Parameters @@ -358,38 +360,51 @@ 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): + def set_color_map(self, color_map_name: str): + """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): + """Trigger the rectangular selection tool.""" self.send({"event_name": "trigger_rectangular_selection"}) # Adding a listener - def set_listener(self, listener_type, callback): + def set_listener(self, listener_type: str, callback: callable): """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 + callback: callable A python function to be called when the event corresponding to the listener_type is detected """ self.add_listener(listener_type, callback, False) - def add_listener(self, listener_type, callback, _dWarning=True): + def add_listener( + self, listener_type: str, callback: callable, _dWarning: bool = True + ): """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 + callback: callable A python function to be called when the event corresponding to the listener_type is detected + _dWarning: bool + If True, a deprecation warning is raised Note ---- From c88f253becc916334f40341c1217a249ed50d323 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Fri, 26 Apr 2024 13:02:37 +0200 Subject: [PATCH 14/31] :art: Improve structure and add jsdoc when needed --- js/models/event_handler.js | 13 +++++++++++++ js/models/message_handler.js | 20 ++++++-------------- js/utils.js | 28 ++++++++++++++++++++++------ js/widget.js | 9 +++++---- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 1d28d879..df62997e 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,6 +1,12 @@ import MessageHandler from "./message_handler"; 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; @@ -8,6 +14,9 @@ export default class EventHandler { this.messageHandler = new MessageHandler(aladin); } + /** + * Subscribes to all the events needed for the Aladin Lite widget. + */ subscribeAll() { /* ------------------- */ /* Listeners --------- */ @@ -183,6 +192,10 @@ export default class EventHandler { }); } + /** + * Unsubscribe from all the model events. + * There is no need to unsubscribe from the Aladin Lite events. + */ unsubscribeAll() { this.model.off("change:shared_target"); this.model.off("change:fov"); diff --git a/js/models/message_handler.js b/js/models/message_handler.js index fe0d251a..e1014f12 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,4 +1,4 @@ -import { A, camelCaseToSnakeCase } from "../utils"; +import { A, convertOptionNamesToCamelCase } from "../utils"; export default class MessageHandler { constructor(aladin) { @@ -14,24 +14,24 @@ export default class MessageHandler { } handleAddCatalogFromURL(msg) { - const options = MessageHandler.parseOptions(msg["options"] || {}); + const options = convertOptionNamesToCamelCase(msg["options"] || {}); this.aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], options)); } handleAddMOCFromURL(msg) { - const options = MessageHandler.parseOptions(msg["options"] || {}); + const options = convertOptionNamesToCamelCase(msg["options"] || {}); if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); } handleAddMOCFromDict(msg) { - const options = MessageHandler.parseOptions(msg["options"] || {}); + const options = convertOptionNamesToCamelCase(msg["options"] || {}); if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); } handleAddOverlayFromSTCS(msg) { - const overlayOptions = MessageHandler.parseOptions( + const overlayOptions = convertOptionNamesToCamelCase( msg["overlay_options"] || {}, ); const stcString = msg["stc_string"]; @@ -53,7 +53,7 @@ export default class MessageHandler { } handleAddTable(msg, buffers) { - const options = MessageHandler.parseOptions(msg["options"] || {}); + const options = convertOptionNamesToCamelCase(msg["options"] || {}); const buffer = buffers[0].buffer; const decoder = new TextDecoder("utf-8"); const blob = new Blob([decoder.decode(buffer)]); @@ -68,12 +68,4 @@ export default class MessageHandler { ); URL.revokeObjectURL(url); } - - static parseOptions(options) { - for (const optionName in options) { - const convertedOptionName = camelCaseToSnakeCase(optionName); - options[convertedOptionName] = options[optionName]; - } - return options; - } } diff --git a/js/utils.js b/js/utils.js index 3be0f18c..90af5aa8 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,12 +1,28 @@ import A from "https://esm.sh/aladin-lite@3.3.3-beta"; -function camelCaseToSnakeCase(pyname) { - if (pyname.charAt(0) === "_") pyname = pyname.slice(1); - let temp = pyname.split("_"); - for (let i = 1; i < temp.length; i++) { +/** + * 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(""); } -export { camelCaseToSnakeCase, A }; +/** + * 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; +} + +export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, A }; diff --git a/js/widget.js b/js/widget.js index 40741a4f..93273522 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,13 +1,13 @@ import "./widget.css"; import EventHandler from "./models/event_handler"; -import { A, camelCaseToSnakeCase } from "./utils"; +import { A, snakeCaseToCamelCase } from "./utils"; let idxView = 0; function initAladinLite(model, el) { let initOptions = {}; model.get("init_options").forEach((option_name) => { - initOptions[camelCaseToSnakeCase(option_name)] = model.get(option_name); + initOptions[snakeCaseToCamelCase(option_name)] = model.get(option_name); }); let aladinDiv = document.createElement("div"); @@ -18,7 +18,8 @@ function initAladinLite(model, el) { let aladin = new A.aladin(aladinDiv, initOptions); idxView += 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 + // 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]); @@ -41,7 +42,7 @@ function render({ model, el }) { eventHandler.subscribeAll(); return () => { - // need to unsubscribe the listeners + // Need to unsubscribe the listeners eventHandler.unsubscribeAll(); }; } From 67db8d1c6648cd24fcab580f09b40ccddbe77edd Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 09:47:32 +0200 Subject: [PATCH 15/31] :art: Improve python typing --- pyproject.toml | 5 +++-- src/ipyaladin/__init__.py | 36 +++++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e3093395..0b0c6108 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,10 @@ 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" ] -ignore = ["ISC001"] +ignore = ["ISC001", "ANN101"] [tool.ruff.format] docstring-code-format = false diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 2af4ba31..b968c5dc 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -100,16 +100,16 @@ class Aladin(anywidget.AnyWidget): init_options = List(trait=Any()).tag(sync=True) @default("init_options") - def _init_options(self): + def _init_options(self) -> list[str]: return list(self.traits(init_option=True)) - def __init__(self, *args, **kwargs): + 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, model, message: dict, buffers: list): # noqa: ARG002 + def _handle_custom_message(self, _: any, message: dict, __: any) -> None: event_type = message["event_type"] message_content = message["content"] if ( @@ -170,7 +170,7 @@ def target(self) -> SkyCoord: ) @target.setter - def target(self, target: Union[str, SkyCoord]): + 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 @@ -186,7 +186,9 @@ def target(self, target: Union[str, SkyCoord]): } ) - def add_catalog_from_URL(self, votable_URL: str, votable_options: Dict = None): + 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 @@ -207,7 +209,7 @@ def add_catalog_from_URL(self, votable_URL: str, votable_options: Dict = None): # MOCs - def add_moc(self, moc, **moc_options): + def add_moc(self, moc: any, **moc_options: any) -> None: """Add a MOC to the Aladin-Lite widget. Parameters @@ -254,7 +256,9 @@ def add_moc(self, moc, **moc_options): "library with 'pip install mocpy'." ) from imp - def add_moc_from_URL(self, moc_URL: str, moc_options: Optional[dict] = None): + 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 @@ -274,7 +278,9 @@ def add_moc_from_URL(self, moc_URL: str, moc_options: Optional[dict] = None): moc_options = {} self.add_moc(moc_URL, **moc_options) - def add_moc_from_dict(self, moc_dict: dict, moc_options: Optional[dict] = None): + 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 @@ -295,7 +301,7 @@ def add_moc_from_dict(self, moc_dict: dict, moc_options: Optional[dict] = None): moc_options = {} self.add_moc(moc_dict, **moc_options) - def add_table(self, table: Union[QTable, Table], **table_options): + def add_table(self, table: Union[QTable, Table], **table_options: any) -> None: """Load a table into the widget. Parameters @@ -335,7 +341,7 @@ def add_table(self, table: Union[QTable, Table], **table_options): buffers=[table_bytes.getvalue()], ) - def add_overlay_from_stcs(self, stc_string: str, **overlay_options): + def add_overlay_from_stcs(self, stc_string: str, **overlay_options: any) -> None: """Add an overlay layer defined by a STC-S string. Parameters @@ -356,11 +362,11 @@ def add_overlay_from_stcs(self, stc_string: str, **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): + 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): + def set_color_map(self, color_map_name: str) -> None: """Change the color map of the Aladin Lite widget. Parameters @@ -371,13 +377,13 @@ def set_color_map(self, color_map_name: str): """ self.send({"event_name": "change_colormap", "colormap": color_map_name}) - def rectangular_selection(self): + 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): + def set_listener(self, listener_type: str, callback: callable) -> None: """Set a listener for an event to the widget. Parameters @@ -393,7 +399,7 @@ def set_listener(self, listener_type: str, callback: callable): def add_listener( self, listener_type: str, callback: callable, _dWarning: bool = True - ): + ) -> None: """Add a listener to the widget. Use set_listener instead. Parameters From d7da494b2ea080fc3f844f9d78974ca868b99637 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 11:28:45 +0200 Subject: [PATCH 16/31] :rotating_light: Fix all linter errors --- examples/10_Advanced-GUI.ipynb | 8 ++++---- examples/3_Functions.ipynb | 4 ++-- examples/7_on-click-callback.ipynb | 2 +- examples/8_Rectangular-selection.ipynb | 4 ++-- src/test/test_aladin.py | 8 ++++---- src/test/test_coordinate_parser.py | 8 ++++---- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/10_Advanced-GUI.ipynb b/examples/10_Advanced-GUI.ipynb index dfdab0bc..4c5dd064 100644 --- a/examples/10_Advanced-GUI.ipynb +++ b/examples/10_Advanced-GUI.ipynb @@ -36,7 +36,7 @@ ")\n", "\n", "\n", - "def on_survey_value_change(change):\n", + "def on_survey_value_change(change: dict) -> None:\n", " aladin.survey = change[\"new\"]\n", "\n", "\n", @@ -63,7 +63,7 @@ ")\n", "\n", "\n", - "def on_survey_overlay_value_change(change):\n", + "def on_survey_overlay_value_change(change: dict) -> None:\n", " aladin.overlay_survey = change[\"new\"]\n", " aladin.overlay_survey_opacity = aladin.overlay_survey_opacity + 0.00000001\n", "\n", @@ -84,7 +84,7 @@ ")\n", "\n", "\n", - "def on_surveyoverlay_opacity_value_change(change):\n", + "def on_surveyoverlay_opacity_value_change(change: dict) -> None:\n", " aladin.overlay_survey_opacity = change[\"new\"]\n", "\n", "\n", @@ -105,7 +105,7 @@ ")\n", "\n", "\n", - "def on_zoom_slider_value_change(change):\n", + "def on_zoom_slider_value_change(change: dict) -> None:\n", " aladin.fov = 180 / change[\"new\"]\n", "\n", "\n", diff --git a/examples/3_Functions.ipynb b/examples/3_Functions.ipynb index 63b874c3..1db511f7 100644 --- a/examples/3_Functions.ipynb +++ b/examples/3_Functions.ipynb @@ -79,12 +79,12 @@ "metadata": {}, "outputs": [], "source": [ - "def get_object_data(data):\n", + "def get_object_data(data: dict) -> dict:\n", " print(\"It clicked.\")\n", " return data\n", "\n", "\n", - "def get_object_ra_dec_product(data):\n", + "def get_object_ra_dec_product(data: dict) -> float:\n", " return data[\"ra\"] * data[\"dec\"]\n", "\n", "\n", diff --git a/examples/7_on-click-callback.ipynb b/examples/7_on-click-callback.ipynb index 89e4f31e..6fad052d 100644 --- a/examples/7_on-click-callback.ipynb +++ b/examples/7_on-click-callback.ipynb @@ -37,7 +37,7 @@ "metadata": {}, "outputs": [], "source": [ - "def process_result(data):\n", + "def process_result(data: dict) -> None:\n", " info.value = \"\"\n", " ra = data[\"ra\"]\n", " dec = data[\"dec\"]\n", diff --git a/examples/8_Rectangular-selection.ipynb b/examples/8_Rectangular-selection.ipynb index 8e432fa7..62605ac4 100644 --- a/examples/8_Rectangular-selection.ipynb +++ b/examples/8_Rectangular-selection.ipynb @@ -33,7 +33,7 @@ "button = widgets.Button(description=\"Select\")\n", "\n", "\n", - "def on_button_clicked(b): # noqa: ARG001\n", + "def on_button_clicked(_: any) -> None:\n", " aladin.rectangular_selection()\n", "\n", "\n", @@ -61,7 +61,7 @@ "aladin.add_table(table)\n", "\n", "\n", - "def process_result(sources):\n", + "def process_result(sources: dict) -> None:\n", " s = ''\n", " s += \"\"\n", " for source in sources:\n", diff --git a/src/test/test_aladin.py b/src/test/test_aladin.py index e7eba792..4e524692 100644 --- a/src/test/test_aladin.py +++ b/src/test/test_aladin.py @@ -34,7 +34,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 +51,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 +77,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 +92,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..7f2c317e 100644 --- a/src/test/test_coordinate_parser.py +++ b/src/test/test_coordinate_parser.py @@ -32,7 +32,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 +67,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 +91,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 +175,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 From 8685c2028c58b60eb897129150ddd06cbce16e90 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 25 Apr 2024 12:15:45 +0200 Subject: [PATCH 17/31] :pushpin: Define minimal python version to 3.8 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0b0c6108..537db4e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dynamic = ["version"] dependencies = ["anywidget", "astropy"] readme = "README.md" license = "BSD-3-Clause" +requires-python = ">=3.8" [project.optional-dependencies] test = ["pytest"] From fda7d5b9c2fdbbaf0754137bcf97ea230635e40a Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 12:46:35 +0200 Subject: [PATCH 18/31] :art: Merge test optional dependency to dev --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 537db4e7..19cae0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,7 @@ license = "BSD-3-Clause" requires-python = ">=3.8" [project.optional-dependencies] -test = ["pytest"] -dev = ["ipyaladin[test]", "watchfiles", "jupyterlab", "ruff"] +dev = ["pytest", "watchfiles", "jupyterlab", "ruff"] recommended = ["mocpy"] # automatically add the dev feature to the default env (e.g., hatch shell) From 35ae8bd97f7b88dd5261dd6e2201f396b36c8f46 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 12:48:22 +0200 Subject: [PATCH 19/31] :memo: Add add_listener deprecation to CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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] From 259238b772be0176d55f9f01aa70ca7332f4a835 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 12:50:38 +0200 Subject: [PATCH 20/31] :construction_worker: Add python testing for 3.8 and 3.12 --- .github/workflows/python-tests.yml | 5 ++++- src/ipyaladin/__init__.py | 14 +++++++------- src/ipyaladin/coordinate_parser.py | 6 ++++-- src/test/test_coordinate_parser.py | 3 ++- 4 files changed, 17 insertions(+), 11 deletions(-) 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/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index b968c5dc..67a9499e 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,5 +1,6 @@ import importlib.metadata import pathlib +import typing from typing import ClassVar, Union, Final, Optional import warnings @@ -7,13 +8,12 @@ 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, - List, - Dict, Any, default, ) @@ -86,21 +86,21 @@ class Aladin(anywidget.AnyWidget): 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) + grid_options = traitlets.Dict().tag(sync=True, init_option=True) # content of the last click - clicked_object = Dict().tag(sync=True) + clicked_object = traitlets.Dict().tag(sync=True) # listener callback is on the python side and contains functions to link to events - listener_callback: ClassVar[dict[str, callable]] = {} + 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 = List(trait=Any()).tag(sync=True) + init_options = traitlets.List(trait=Any()).tag(sync=True) @default("init_options") - def _init_options(self) -> list[str]: + def _init_options(self) -> typing.List[str]: return list(self.traits(init_option=True)) def __init__(self, *args: any, **kwargs: any) -> None: 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_coordinate_parser.py b/src/test/test_coordinate_parser.py index 7f2c317e..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, @@ -67,7 +68,7 @@ def test_is_coordinate_string(inp: str, expected: bool) -> None: @pytest.mark.parametrize(("inp", "expected"), test_split_coordinate_string_values) -def test_split_coordinate_string(inp: str, expected: tuple[str, str]) -> None: +def test_split_coordinate_string(inp: str, expected: Tuple[str, str]) -> None: """Test the function _split_coordinate_string. Parameters From c48e5bd330b13cc18182bc3e4fc60ce11c57e73c Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Mon, 29 Apr 2024 15:55:55 +0200 Subject: [PATCH 21/31] :rotating_light: Fix linter error --- src/ipyaladin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 67a9499e..d9faa7a7 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -143,7 +143,7 @@ def fov(self) -> Angle: return Angle(self._fov, unit="deg") @fov.setter - def fov(self, fov: Union[float, Angle]): + def fov(self, fov: Union[float, Angle]) -> None: if isinstance(fov, Angle): fov = fov.deg self._fov = fov From 03b40dfefb259971e75346f188a1bd774704e476 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 30 Apr 2024 09:12:20 +0200 Subject: [PATCH 22/31] :rotating_light: Add ruff rules for docstring --- examples/10_Advanced-GUI.ipynb | 28 ++++++++++++++++++++++++++ examples/3_Functions.ipynb | 14 +++++++++++++ examples/7_on-click-callback.ipynb | 7 +++++++ examples/8_Rectangular-selection.ipynb | 16 +++++++++++++++ pyproject.toml | 7 +++++-- src/ipyaladin/__init__.py | 13 ++++++++++++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/examples/10_Advanced-GUI.ipynb b/examples/10_Advanced-GUI.ipynb index 4c5dd064..8ce02836 100644 --- a/examples/10_Advanced-GUI.ipynb +++ b/examples/10_Advanced-GUI.ipynb @@ -37,6 +37,13 @@ "\n", "\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", @@ -64,6 +71,13 @@ "\n", "\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", @@ -85,6 +99,13 @@ "\n", "\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", @@ -106,6 +127,13 @@ "\n", "\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/3_Functions.ipynb b/examples/3_Functions.ipynb index 1db511f7..7864685c 100644 --- a/examples/3_Functions.ipynb +++ b/examples/3_Functions.ipynb @@ -80,11 +80,25 @@ "outputs": [], "source": [ "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 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", diff --git a/examples/7_on-click-callback.ipynb b/examples/7_on-click-callback.ipynb index 6fad052d..814c7e21 100644 --- a/examples/7_on-click-callback.ipynb +++ b/examples/7_on-click-callback.ipynb @@ -38,6 +38,13 @@ "outputs": [], "source": [ "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", diff --git a/examples/8_Rectangular-selection.ipynb b/examples/8_Rectangular-selection.ipynb index 62605ac4..7bff0560 100644 --- a/examples/8_Rectangular-selection.ipynb +++ b/examples/8_Rectangular-selection.ipynb @@ -34,6 +34,15 @@ "\n", "\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", @@ -62,6 +71,13 @@ "\n", "\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 = '
MAIN_IDRADEC
'\n", " s += \"\"\n", " for source in sources:\n", diff --git a/pyproject.toml b/pyproject.toml index 19cae0d0..0c301e02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,9 +44,12 @@ extend-include = ["*.ipynb"] extend-select = ["E", "W", "YTT", "ASYNC", "BLE", "B", "A", "C4", "ISC", "PIE", "PYI", "RSE", "RET", "SIM", "PTH", "TD", "ERA", "PL", "PERF", "RUF", "ARG", - "ANN" + "ANN", "D" ] -ignore = ["ISC001", "ANN101"] +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 d9faa7a7..5601631e 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,3 +1,10 @@ +""" +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 importlib.metadata import pathlib import typing @@ -27,6 +34,12 @@ 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" From ad5311c50e062720cb3a8d778618a2af7c502a06 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 30 Apr 2024 09:19:44 +0200 Subject: [PATCH 23/31] :art: Migrate Aladin Lite API import to dedicated file --- js/imports.js | 3 +++ js/models/message_handler.js | 3 ++- js/utils.js | 4 +--- js/widget.js | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 js/imports.js diff --git a/js/imports.js b/js/imports.js new file mode 100644 index 00000000..d5274429 --- /dev/null +++ b/js/imports.js @@ -0,0 +1,3 @@ +import A from "https://esm.sh/aladin-lite@3.3.3-beta"; + +export default A; diff --git a/js/models/message_handler.js b/js/models/message_handler.js index e1014f12..d00e806b 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,4 +1,5 @@ -import { A, convertOptionNamesToCamelCase } from "../utils"; +import { convertOptionNamesToCamelCase } from "../utils"; +import A from "../imports"; export default class MessageHandler { constructor(aladin) { diff --git a/js/utils.js b/js/utils.js index 90af5aa8..fa0f283f 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,5 +1,3 @@ -import A from "https://esm.sh/aladin-lite@3.3.3-beta"; - /** * Converts a string from camelCase to snake_case. * @param {string} snakeCaseStr - The string to convert. @@ -25,4 +23,4 @@ function convertOptionNamesToCamelCase(options) { return newOptions; } -export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, A }; +export { snakeCaseToCamelCase, convertOptionNamesToCamelCase }; diff --git a/js/widget.js b/js/widget.js index 93273522..4e6028be 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,6 +1,7 @@ import "./widget.css"; import EventHandler from "./models/event_handler"; -import { A, snakeCaseToCamelCase } from "./utils"; +import { snakeCaseToCamelCase } from "./utils"; +import A from "./imports"; let idxView = 0; From e617c950aa9dbaa57858053645aff786015914ae Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 30 Apr 2024 09:21:57 +0200 Subject: [PATCH 24/31] :art: Change the way add_listener deprecation warning is display --- src/ipyaladin/__init__.py | 45 +++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 5601631e..fda7077a 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -408,11 +408,21 @@ def set_listener(self, listener_type: str, callback: callable) -> None: listener_type is detected """ - self.add_listener(listener_type, callback, False) + 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, _dWarning: bool = True - ) -> None: + def add_listener(self, listener_type: str, callback: callable) -> None: """Add a listener to the widget. Use set_listener instead. Parameters @@ -422,30 +432,15 @@ def add_listener( callback: callable A python function to be called when the event corresponding to the listener_type is detected - _dWarning: bool - If True, a deprecation warning is raised Note ---- This method is deprecated, use set_listener instead """ - if _dWarning: - warnings.warn( - "add_listener is deprecated, use set_listener instead", - DeprecationWarning, - stacklevel=2, - ) - 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'" - ) + warnings.warn( + "add_listener is deprecated, use set_listener instead", + DeprecationWarning, + stacklevel=2, + ) + self.set_listener(listener_type, callback) From bdc1330df3f93a787d2919eb463c442f1f82e5fa Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 30 Apr 2024 09:41:48 +0200 Subject: [PATCH 25/31] :art: Migrate Aladin object from __init__ to dedicated file --- src/ipyaladin/__init__.py | 439 +------------------------------------ src/ipyaladin/aladin.py | 440 ++++++++++++++++++++++++++++++++++++++ src/test/test_aladin.py | 3 +- 3 files changed, 444 insertions(+), 438 deletions(-) create mode 100644 src/ipyaladin/aladin.py diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index fda7077a..7b80ec63 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,446 +1,11 @@ -""" -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. -""" +"""Top-level package for ipyaladin.""" import importlib.metadata -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 .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): - """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) - 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 = 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 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]) -> 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! - - """ - # 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.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, - } - ) - - # 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) -> 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/aladin.py b/src/ipyaladin/aladin.py new file mode 100644 index 00000000..bf13df64 --- /dev/null +++ b/src/ipyaladin/aladin.py @@ -0,0 +1,440 @@ +""" +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) + 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 = 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 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]) -> 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! + + """ + # 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.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, + } + ) + + # 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) -> 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/test/test_aladin.py b/src/test/test_aladin.py index 4e524692..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", From 03c8b75ae7450cb183d8ec23e988bc6ee4a5b124 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 30 Apr 2024 11:20:40 +0200 Subject: [PATCH 26/31] :art: Improve target and fov control flow using new Lock object --- js/imports.js | 2 +- js/models/event_handler.js | 29 +++++++++-------------------- js/utils.js | 22 +++++++++++++++++++++- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/js/imports.js b/js/imports.js index d5274429..27c2fa99 100644 --- a/js/imports.js +++ b/js/imports.js @@ -1,3 +1,3 @@ -import A from "https://esm.sh/aladin-lite@3.3.3-beta"; +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 index df62997e..40bac042 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,4 +1,5 @@ import MessageHandler from "./message_handler"; +import { Lock } from "../utils"; export default class EventHandler { /** @@ -30,16 +31,15 @@ export default class EventHandler { // is also necessary for the field of view. /* Target control */ - let targetJs = false; - let targetPy = false; + let targetLock = new Lock(); // Event triggered when the user moves the map in Aladin Lite this.aladin.on("positionChanged", () => { - if (targetPy) { - targetPy = false; + if (targetLock.locked) { + targetLock.unlock(); return; } - targetJs = true; + targetLock.lock(); const raDec = this.aladin.getRaDec(); this.model.set("_target", `${raDec[0]} ${raDec[1]}`); this.model.set("shared_target", `${raDec[0]} ${raDec[1]}`); @@ -48,26 +48,20 @@ export default class EventHandler { // Event triggered when the target is changed from the Python side using jslink this.model.on("change:shared_target", () => { - if (targetJs) { - targetJs = false; - return; - } - targetPy = true; const target = this.model.get("shared_target"); const [ra, dec] = target.split(" "); this.aladin.gotoRaDec(ra, dec); }); /* Field of View control */ - let fovJs = false; - let fovPy = false; + let fovLock = new Lock(); this.aladin.on("zoomChanged", (fov) => { - if (fovPy) { - fovPy = false; + if (fovLock.locked) { + fovLock.unlock(); return; } - fovJs = true; + fovLock.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.set("shared_fov", parseFloat(fov.toFixed(5))); @@ -75,11 +69,6 @@ export default class EventHandler { }); this.model.on("change:shared_fov", () => { - if (fovJs) { - fovJs = false; - return; - } - fovPy = true; let fov = this.model.get("shared_fov"); this.aladin.setFoV(fov); }); diff --git a/js/utils.js b/js/utils.js index fa0f283f..b78bfdd6 100644 --- a/js/utils.js +++ b/js/utils.js @@ -23,4 +23,24 @@ function convertOptionNamesToCamelCase(options) { return newOptions; } -export { snakeCaseToCamelCase, convertOptionNamesToCamelCase }; +class Lock { + locked = false; + + /** + * Locks the object + * @returns {boolean} True if the object was locked, false otherwise + */ + unlock() { + return false; + } + + /** + * Unlocks the object + * @returns {boolean} True if the object was unlocked, false otherwise + */ + lock() { + return true; + } +} + +export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, Lock }; From 15aa666c58f55dde687701fd1a036a5a1f7d576d Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 2 May 2024 09:40:41 +0200 Subject: [PATCH 27/31] :fire: Remove useless default lineWidth for add_moc --- js/models/message_handler.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/models/message_handler.js b/js/models/message_handler.js index d00e806b..3186d499 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -21,13 +21,11 @@ export default class MessageHandler { handleAddMOCFromURL(msg) { const options = convertOptionNamesToCamelCase(msg["options"] || {}); - if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); } handleAddMOCFromDict(msg) { const options = convertOptionNamesToCamelCase(msg["options"] || {}); - if (options["lineWidth"] === undefined) options["lineWidth"] = 3; this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); } From 795b77037789b45c352e67836912200016c2cbd7 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Thu, 2 May 2024 09:41:52 +0200 Subject: [PATCH 28/31] :art: Change imports.js to aladin_lite.js for better understanding --- js/{imports.js => aladin_lite.js} | 0 js/models/message_handler.js | 2 +- js/widget.js | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename js/{imports.js => aladin_lite.js} (100%) diff --git a/js/imports.js b/js/aladin_lite.js similarity index 100% rename from js/imports.js rename to js/aladin_lite.js diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 3186d499..3dfff7b2 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,5 +1,5 @@ import { convertOptionNamesToCamelCase } from "../utils"; -import A from "../imports"; +import A from "../aladin_lite"; export default class MessageHandler { constructor(aladin) { diff --git a/js/widget.js b/js/widget.js index 4e6028be..7b261dde 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,7 +1,7 @@ import "./widget.css"; import EventHandler from "./models/event_handler"; import { snakeCaseToCamelCase } from "./utils"; -import A from "./imports"; +import A from "./aladin_lite"; let idxView = 0; From 6771c6f3b5e7dd41d1cba20a7f15be17ca615f54 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Fri, 3 May 2024 11:18:49 +0200 Subject: [PATCH 29/31] :ambulance: Fix widget crash when moving from Aladin Lite view --- examples/6_Linked-widgets.ipynb | 8 +++--- js/models/event_handler.js | 48 ++++++++++++++++++++------------- js/utils.js | 6 ++--- src/ipyaladin/aladin.py | 10 ------- 4 files changed, 35 insertions(+), 37 deletions(-) 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/js/models/event_handler.js b/js/models/event_handler.js index 40bac042..3c88e812 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -31,45 +31,55 @@ export default class EventHandler { // is also necessary for the field of view. /* Target control */ - let targetLock = new Lock(); + const jsTargetLock = new Lock(); + const pyTargetLock = new Lock(); // Event triggered when the user moves the map in Aladin Lite - this.aladin.on("positionChanged", () => { - if (targetLock.locked) { - targetLock.unlock(); + this.aladin.on("positionChanged", (position) => { + if (pyTargetLock.locked) { + pyTargetLock.unlock(); return; } - targetLock.lock(); - const raDec = this.aladin.getRaDec(); + jsTargetLock.lock(); + const raDec = [position.ra, position.dec]; + // const raDec = this.aladin.getRaDec(); this.model.set("_target", `${raDec[0]} ${raDec[1]}`); - this.model.set("shared_target", `${raDec[0]} ${raDec[1]}`); this.model.save_changes(); }); - // Event triggered when the target is changed from the Python side using jslink - this.model.on("change:shared_target", () => { - const target = this.model.get("shared_target"); + 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 */ - let fovLock = new Lock(); + const jsFovLock = new Lock(); + const pyFovLock = new Lock(); this.aladin.on("zoomChanged", (fov) => { - if (fovLock.locked) { - fovLock.unlock(); + if (pyFovLock.locked) { + pyFovLock.unlock(); return; } - fovLock.lock(); + 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.set("shared_fov", parseFloat(fov.toFixed(5))); this.model.save_changes(); }); - this.model.on("change:shared_fov", () => { - let fov = this.model.get("shared_fov"); + this.model.on("change:_fov", () => { + if (jsFovLock.locked) { + jsFovLock.unlock(); + return; + } + pyFovLock.lock(); + let fov = this.model.get("_fov"); this.aladin.setFoV(fov); }); @@ -186,8 +196,8 @@ export default class EventHandler { * There is no need to unsubscribe from the Aladin Lite events. */ unsubscribeAll() { - this.model.off("change:shared_target"); - this.model.off("change:fov"); + 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"); diff --git a/js/utils.js b/js/utils.js index b78bfdd6..746ba336 100644 --- a/js/utils.js +++ b/js/utils.js @@ -28,18 +28,16 @@ class Lock { /** * Locks the object - * @returns {boolean} True if the object was locked, false otherwise */ unlock() { - return false; + this.locked = false; } /** * Unlocks the object - * @returns {boolean} True if the object was unlocked, false otherwise */ lock() { - return true; + this.locked = true; } } diff --git a/src/ipyaladin/aladin.py b/src/ipyaladin/aladin.py index bf13df64..330ea84f 100644 --- a/src/ipyaladin/aladin.py +++ b/src/ipyaladin/aladin.py @@ -45,22 +45,12 @@ class Aladin(anywidget.AnyWidget): " 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 ) From 8c1bdce2fb648c77d3fa82ec5660668985b19bfa Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 7 May 2024 08:27:47 +0200 Subject: [PATCH 30/31] :memo: Fix documentation --- src/ipyaladin/aladin.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/ipyaladin/aladin.py b/src/ipyaladin/aladin.py index 330ea84f..0386f215 100644 --- a/src/ipyaladin/aladin.py +++ b/src/ipyaladin/aladin.py @@ -150,7 +150,7 @@ def fov(self, fov: Union[float, Angle]) -> None: 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. + It can be set with either a string or an `~astropy.coordinates.SkyCoord` object. Returns ------- @@ -327,8 +327,6 @@ def add_table(self, table: Union[QTable, Table], **table_options: any) -> None: 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() @@ -356,9 +354,6 @@ def add_overlay_from_stcs(self, stc_string: str, **overlay_options: any) -> None } ) - # 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) -> None: """Create a popup window with the current Aladin view.""" self.send({"event_name": "get_JPG_thumbnail"}) From 576f3dc821b38f6072827cdaeee061ea388e06f8 Mon Sep 17 00:00:00 2001 From: Xen0Xys Date: Tue, 7 May 2024 08:33:58 +0200 Subject: [PATCH 31/31] :memo: Improve examples --- examples/2_Base_Commands.ipynb | 33 +++++++++++++++++++++++++++++++ examples/3_Functions.ipynb | 2 +- examples/4_Importing_Tables.ipynb | 4 ++-- examples/5_Display_a_MOC.ipynb | 4 +++- 4 files changed, 39 insertions(+), 4 deletions(-) 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 7864685c..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)" ] }, diff --git a/examples/4_Importing_Tables.ipynb b/examples/4_Importing_Tables.ipynb index 1621bcab..cd5df9a5 100644 --- a/examples/4_Importing_Tables.ipynb +++ b/examples/4_Importing_Tables.ipynb @@ -58,8 +58,8 @@ "metadata": {}, "outputs": [], "source": [ - "aladin.add_table(table, shape=\"rhomb\", color=\"lightskyblue\", sourceSize=20)\n", - "# This line also works with snake_case instead of camelCase: source_size=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", + ")" ] } ],
MAIN_IDRADEC